Compare commits

...

5 commits

45 changed files with 1770 additions and 4 deletions

3
.gitmodules vendored
View file

@ -4,3 +4,6 @@
[submodule "doxygen-awesome-css"]
path = doc/doxygen/theme
url = https://github.com/jothepro/doxygen-awesome-css.git
[submodule "external/qttesting"]
path = external/qttesting
url = https://github.com/Kitware/QtTesting.git

View file

@ -20,6 +20,8 @@ option(WITH_CLIENT "Build Cockatrice client" ON)
option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
# Compile Servatrice
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile GUI tests (QtTesting widget-level playback)
option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" ON)
# Compile tests
option(TEST "Build tests" OFF)
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
@ -29,6 +31,14 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
# The GUI test harness lives in a submodule; a checkout without it must still configure.
if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt")
message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n"
"Run 'git submodule update --init external/qttesting' to enable them."
)
set(WITH_GUI_TEST OFF)
endif()
# Default to "Release" build type
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
if(DEFINED CMAKE_BUILD_TYPE)
@ -455,6 +465,34 @@ if(WITH_ORACLE)
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
if(WITH_GUI_TEST)
set(QtTesting_QT_VERSION "6")
# QtTesting registers its own unit tests when BUILD_TESTING is on. They target the
# Qt version the harness was written for and fail on Qt 6.11 (toolbutton
# long-press recording), and they are not Cockatrice's tests, so suppress them
# for the duration of the subdirectory instead of painting ctest red.
if(DEFINED BUILD_TESTING)
set(_cockatrice_saved_build_testing ${BUILD_TESTING})
endif()
set(BUILD_TESTING
OFF
CACHE BOOL "Build the testing tree." FORCE
)
add_subdirectory(external/qttesting)
if(DEFINED _cockatrice_saved_build_testing)
set(BUILD_TESTING
${_cockatrice_saved_build_testing}
CACHE BOOL "Build the testing tree." FORCE
)
else()
unset(BUILD_TESTING CACHE)
endif()
# cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021.
if(NOT MSVC)
target_compile_options(qttesting PRIVATE -Wno-error)
endif()
endif()
if(TEST)
include(CTest)
add_subdirectory(tests)

66
Testing/README.md Normal file
View file

@ -0,0 +1,66 @@
# GUI tests
Widget-level playback tests for the Cockatrice client, driven by
[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule).
Each `*_test.xml` file is a list of events that QtTesting replays against the running
application; a test passes when every event is handled without error.
## Running
```bash
git submodule update --init external/qttesting # once
cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON
cmake --build build/debug -j 24
./Testing/run_test.sh --all # headless, offscreen
./Testing/run_test.sh Testing/smoke_test.xml # one file
./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window
```
`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a
warning and no harness. `COCKATRICE_BIN` points the runner at another build, and
`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a
modal dialog can otherwise leave the process alive. The client also hard-exits a few
seconds after playback returns, whatever the result.
The tests are **not** part of `ctest`: they need a card database and a developer's
settings, so they stay a local loop for now.
## Caveats
- **Not hermetic.** The client reads the local settings and card database, so a test can
pass on one machine and fail on another. Avoid asserting things that restored
workspace state or user preferences decide (for example the number of open tabs).
- **English labels only.** Menu entries are addressed by their (translated) text, so the
tests expect the source language. Widgets are addressed by objectName paths, which are
stable across translations.
- Actions that open a dialog are followed by a `pause` event: playback continues in the
dialog's own event loop, and the pause gives the UI time to build itself.
- Widget paths include Qt-internal names where a QTabWidget is involved
(`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed
(`tabbedDeckView_0`, `tabbedDeckView_1`).
## Writing a test
A test is a `<events>` list. Widgets are named by their path from the main window, e.g.
`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands:
| Command | Arguments | Effect |
|---------|-----------|--------|
| `activate` | action or menu name | triggers a QAction (menus are opened first) |
| `key` | Qt key code | presses that key on the focused widget |
| `pause` | milliseconds | waits, letting the UI settle |
| `clickCard` | `card,zone` | left click the card found in that zone |
| `flipCard` | `card,zone` | right-click → *Turn Over* |
| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) |
| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone |
| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards |
| `createToken` | `name,color,pt` | creates a token as the active local player |
| `selectItem` | `item label` | selects the tree/list entry with that label |
| `typeText` | `text` | types text one key event at a time |
| `expectProperty` | `property,value` | fails playback unless the widget property matches |
The card, item and property commands are `pqCockatriceEventPlayer`
(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name
and zone through the `QGraphicsScene` and send real mouse events at the card's current
viewport position, so a test does not depend on the window size or screen resolution.

View file

@ -0,0 +1,69 @@
<events>
<!--
Card actions (custom commands of pqCockatriceEventPlayer).
Exercises the context menu path (tap, counters in a submenu, flip),
arrow drawing, and dragging a card between zones. All of it through
mouse events on the game view, so the same file runs at any resolution.
-->
<!-- ===== Two-player local game ===== -->
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &amp;local game..." />
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- ===== Player 1 loads a one-card deck ===== -->
<!-- (No tab-count assertion: the restored workspace state decides how many other
tabs exist, and the paths below already prove the game tab is there.) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- ===== Player 2 loads the same deck and readies up ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- ===== Player 1, precombat main ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== A token to interact with ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Goblin,r,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- Tap it through the context menu -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="cardAction" arguments="Goblin,table,Tap / Untap" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- Add a counter: the entry lives in the "Card counters" submenu -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="cardAction" arguments="Goblin,table,Add counter (A)" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- Flip it face down -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="flipCard" arguments="Goblin,table" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- ===== An arrow from a second token to the Goblin ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Elf,w,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="drawArrow" arguments="Elf,table,Goblin,table" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== Drag the Goblin into the graveyard ===== -->
<!-- A token that leaves the battlefield is destroyed rather than moved, so
there is nothing to drag back afterwards. -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="moveCard" arguments="Goblin,table,grave" />
<event widget="MainWindow" command="pause" arguments="1000" />
</events>

View file

@ -0,0 +1,4 @@
<events>
<!-- Open a new deck editor tab from the home page -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
</events>

View file

@ -0,0 +1,14 @@
<events>
<!-- Open a new visual deck editor tab from the home page -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<!-- Type "Lightning" into the search box (keycodes for L-i-g-h-t-n-i-n-g) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="104" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="116" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="110" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="110" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
</events>

View file

@ -0,0 +1,11 @@
<events>
<!-- Open and close Settings dialog (modal exec) -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow/settingsDialog" command="key" arguments="16777216" />
<!-- Open and close About dialog (modal exec) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;About Cockatrice" />
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
</events>

View file

@ -0,0 +1,101 @@
<events>
<!--
Tier 7: Game Mechanics
Tests: play cards (clickCard), flip cards (flipCard),
create tokens (createToken), draw arrows (drawArrow).
All interactions are pure UI mouse events through the QGraphicsView viewport.
-->
<!-- ===== Start a local 2-player game ===== -->
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &amp;local game..." />
<!-- Force two players so both deck views exist, independent of the user's settings -->
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
<!-- ===== Player 1: Load 3-card deck ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="3 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- ===== Player 2: Load same deck ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="3 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- ===== Player 1, Turn 1 ===== -->
<!-- Upkeep → Draw → Main 1 -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Create a Goblin token -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Goblin,r,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- Create a second token (Elf) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Elf,w,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- Flip the Goblin (right-click → Turn Over) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="flipCard" arguments="Goblin,table" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Play a Lightning Bolt from hand (double-click) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="clickCard" arguments="Lightning Bolt,hand" />
<event widget="MainWindow" command="pause" arguments="500" -->
<!-- Draw arrow from Elf to Goblin (right-drag) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="drawArrow" arguments="Elf,table,Goblin,table" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Advance through all remaining phases to end turn -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== Player 2, Turn 1 ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Advance through all phases to end turn -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
</events>

View file

@ -0,0 +1,45 @@
<events>
<!-- Integration test: exercises multiple tiers in sequence -->
<!-- Tier 2: Open and close all menus -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<event widget="MainWindow/menubar" command="activate" arguments="tabsMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Tier 2: Open and close About (modal) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;About Cockatrice" />
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
<!-- Tier 4: Open and close ViewLog (non-modal) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="View &amp;Debug Log" />
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
<!-- Tier 4: Open and close Tip of the Day (non-modal) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;Tip of the Day" />
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
<!-- Tier 5: Settings search (state interaction) -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="100" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="101" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="98" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="117" />
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
<!-- Tier 3: Open deck editor from home and type in search -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="104" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="116" />
</events>

View file

@ -0,0 +1,178 @@
<events>
<!--
Tier 6: Local Game Lifecycle (Extended)
Tests: create game -> load decks -> ready up -> game starts
-> play through turns using menu activate commands
Phase actions triggered via activate on phasesMenu:
aNextPhaseAction advances phase + triggers phase action (e.g., draw at draw step)
aNextTurn in gameMenu triggers next turn
This avoids keyboard shortcut issues in offscreen/test environments.
-->
<!-- ===== Phase 1: Start a local game via main menu ===== -->
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &amp;local game..." />
<!-- Force two players so both deck views exist, independent of the user's settings -->
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
<!-- ===== Phase 2: Player 1 loads deck from clipboard ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<!-- 1 Lightning Bolt -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<!-- Ctrl+Return to accept clipboard dialog -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<!-- Delay -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Player 1 ready up -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 3: Player 2 loads deck from clipboard ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<!-- 1 Lightning Bolt -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<!-- Ctrl+Return to accept clipboard dialog -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<!-- Delay -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Player 2 ready up -> game auto-starts -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<!-- Delay for game to start -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 4: Advance through all 11 phases via phasesMenu activate ===== -->
<!-- Using activate on phasesMenu triggers aNextPhaseAction which:
- advances the phase by 1
- calls triggerPhaseAction (e.g., draw card at draw step, untap all at untap) -->
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 1 draws 1 card -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Phase 2 (Draw) → 3 (Main 1) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== Main 1: Play a card from hand by double-click ===== -->
<!-- clickCard resolves card by name in the scene, maps to viewport coords at current window size. -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="clickCard" arguments="Lightning Bolt,hand" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Phase 3 → 4 (Begin Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- Phase 4 → 5 (Declare Attackers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 5 → 6 (Declare Blockers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 6 → 7 (Combat Damage) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 7 → 8 (End Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 8 → 9 (Main 2) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 9 → 10 (Cleanup) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 10 → 0 (Untap): TRIGGERS NEXT TURN + UNTAP ALL — now Player 2's turn -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<!-- Delay for turn transition -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 5: Player 2's turn — advance through all 11 phases ===== -->
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 2 draws 1 card -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 2 (Draw) → 3 (Main 1) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 3 → 4 (Begin Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 4 → 5 (Declare Attackers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 5 → 6 (Declare Blockers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 6 → 7 (Combat Damage) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 7 → 8 (End Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 8 → 9 (Main 2) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 9 → 10 (Cleanup) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 10 → 0 (Untap): TRIGGERS NEXT TURN — back to Player 1 -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<!-- Delay -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 6: Player 1 turn 2 — advance to draw again ===== -->
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 1 draws again -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
</events>

View file

@ -0,0 +1,19 @@
<events>
<!-- Test 1: Open and close each top-level menu -->
<!-- Cockatrice menu -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Database menu -->
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Tabs menu -->
<event widget="MainWindow/menubar" command="activate" arguments="tabsMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Help menu -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
</events>

View file

@ -0,0 +1,6 @@
<events>
<!-- Test non-modal dialog lifecycle: open and close Manage Sets (QMainWindow) -->
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
<event widget="MainWindow/menubar/dbMenu" command="activate" arguments="&amp;Manage sets..." />
<event widget="MainWindow/manageSetsWindow" command="key" arguments="16777216" />
</events>

View file

@ -0,0 +1,6 @@
<events>
<!-- Test non-modal dialog lifecycle: open and close Tip of the Day -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;Tip of the Day" />
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
</events>

View file

@ -0,0 +1,6 @@
<events>
<!-- Test non-modal dialog lifecycle: open and close ViewLog -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="View &amp;Debug Log" />
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
</events>

101
Testing/run_test.sh Executable file
View file

@ -0,0 +1,101 @@
#!/bin/bash
# Run QtTesting XML test files against the Cockatrice build.
#
# Usage:
# ./Testing/run_test.sh [--visual] [--all | <test-file.xml> ...]
#
# Options:
# --visual Run with native display (shows the GUI window).
# Default is headless (QT_QPA_PLATFORM=offscreen).
# --all Run every *.xml file in Testing/.
#
# Environment:
# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice).
# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180).
#
# Requires the debug build to exist at build/debug/cockatrice/cockatrice.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
COCKATRICE="${COCKATRICE_BIN:-$REPO_ROOT/build/debug/cockatrice/cockatrice}"
TEST_DIR="$REPO_ROOT/Testing"
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
if [ ! -f "$COCKATRICE" ]; then
echo "Error: $COCKATRICE not found. Build first:" >&2
echo " cmake --build build/debug" >&2
exit 1
fi
VISUAL=0
if [ "${1:-}" = "--visual" ]; then
VISUAL=1
shift
fi
if [ "${1:-}" = "--all" ]; then
shift
TEST_FILES=("$TEST_DIR"/*.xml)
if [ ${#TEST_FILES[@]} -eq 0 ]; then
echo "No *.xml files found in $TEST_DIR" >&2
exit 1
fi
elif [ $# -ge 1 ]; then
TEST_FILES=("$@")
else
echo "Usage: $0 [--visual] [--all | <test-file.xml> ...]" >&2
echo " Run a single test: $0 Testing/smoke_test.xml" >&2
echo " Run all tests: $0 --all" >&2
echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2
echo " Run all visually: $0 --visual --all" >&2
exit 1
fi
PASS=0
FAIL=0
FAILED_TESTS=()
run_one() {
local file="$1"
local name
name="$(basename "$file")"
if [ "$VISUAL" -eq 1 ]; then
if timeout --foreground "$TEST_TIMEOUT" "$COCKATRICE" --no-connect --test "$file"; then
return 0
else
return 1
fi
else
if timeout --foreground "$TEST_TIMEOUT" env QT_QPA_PLATFORM=offscreen "$COCKATRICE" --no-connect --test "$file"; then
return 0
else
return 1
fi
fi
}
for f in "${TEST_FILES[@]}"; do
name="$(basename "$f")"
printf "%-40s " "$name"
if run_one "$f"; then
echo "PASS"
PASS=$((PASS + 1))
else
echo "FAIL"
FAIL=$((FAIL + 1))
FAILED_TESTS+=("$name")
fi
done
TOTAL=$((PASS + FAIL))
echo ""
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "Failed:"
for t in "${FAILED_TESTS[@]}"; do
echo " $t"
done
exit 1
fi

View file

@ -0,0 +1,6 @@
<events>
<!-- Open Settings dialog via Cockatrice menu, then close with Escape -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow/settingsDialog" command="key" arguments="16777216" />
</events>

View file

@ -0,0 +1,13 @@
<events>
<!-- Tier 5: State verification — open Settings, search for a page, close -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<!-- Type "sound" in the settings search to filter pages -->
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="115" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="111" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="117" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="110" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="100" />
<!-- Close settings -->
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
</events>

8
Testing/smoke_test.xml Normal file
View file

@ -0,0 +1,8 @@
<events>
<!-- Open Help menu by activating it on the menu bar -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<!-- Click About Cockatrice (opens modal QMessageBox) -->
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;About Cockatrice" />
<!-- Close the modal dialog by pressing Escape (Qt::Key_Escape = 16777216) -->
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
</events>

View file

@ -562,6 +562,14 @@ target_link_libraries(
cockatrice_target_enable_crash_reporting(cockatrice)
if(WITH_GUI_TEST)
# These two translate between XML test files and the QtTesting event pipeline;
# they need the QtTesting headers, so they only exist with the harness.
target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp)
target_link_libraries(cockatrice PRIVATE QtTesting)
target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST)
endif()
if(UNIX)
if(APPLE)
set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}")

View file

@ -10,6 +10,7 @@
# or set <category> = false to disable logging
#main = true
#recording = true
#qt_translator = true
#window_main.* = true
#release_channel = true

View file

@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state)
DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
: QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId)
{
setObjectName("deckViewContainer");
loadLocalButton = new QPushButton;
loadRemoteButton = new QPushButton;
loadFromClipboardButton = new QPushButton;
@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
forceStartGameButton = new QPushButton;
sideboardLockButton = new ToggleButton;
loadLocalButton->setObjectName("loadLocalButton");
loadRemoteButton->setObjectName("loadRemoteButton");
loadFromClipboardButton->setObjectName("loadFromClipboardButton");
readyStartButton->setObjectName("readyStartButton");
forceStartGameButton->setObjectName("forceStartGameButton");
unloadDeckButton->setObjectName("unloadDeckButton");
sideboardLockButton->setObjectName("sideboardLockButton");
connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck);
connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck);
connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard);

View file

@ -14,6 +14,7 @@ class TabGame;
TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent)
: QTabWidget(nullptr), playerId(_playerId), parentGame(parent)
{
setObjectName(QString("tabbedDeckView_%1").arg(_playerId));
setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab);

View file

@ -48,6 +48,7 @@ protected:
GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0)
{
setObjectName("gameView");
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
setViewportUpdateMode(BoundingRectViewportUpdate);

View file

@ -0,0 +1,599 @@
#include "pq_cockatrice_event_player.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "board/card_item.h"
#include "game_scene.h"
#include "game_view.h"
#include "zones/card_zone.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QAction>
#include <QApplication>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QMenu>
#include <QMouseEvent>
#include <QTreeWidget>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
namespace
{
/// Strips Qt's mnemonic markers so a test can name a menu entry by the label
/// the user sees ("Turn Over" rather than "T&urn Over").
QString normalizeActionText(const QString &text)
{
QString normalized = text;
normalized.remove('&');
return normalized.simplified();
}
/// Maps a printable character onto the key a US keyboard would report for it,
/// so typing produces the same QKeyEvents a recording would.
Qt::Key keyForCharacter(QChar character)
{
if (character.isLetter()) {
return static_cast<Qt::Key>(Qt::Key_A + (character.toUpper().unicode() - 'A'));
}
if (character.isDigit()) {
return static_cast<Qt::Key>(Qt::Key_0 + (character.unicode() - '0'));
}
switch (character.unicode()) {
case ' ':
return Qt::Key_Space;
case '\n':
return Qt::Key_Return;
case '\t':
return Qt::Key_Tab;
case '-':
return Qt::Key_Minus;
case '_':
return Qt::Key_Underscore;
case '/':
return Qt::Key_Slash;
case ',':
return Qt::Key_Comma;
case '.':
return Qt::Key_Period;
case '\'':
return Qt::Key_Apostrophe;
case ';':
return Qt::Key_Semicolon;
case ':':
return Qt::Key_Colon;
case '"':
return Qt::Key_QuoteDbl;
case '&':
return Qt::Key_Ampersand;
case '(':
return Qt::Key_ParenLeft;
case ')':
return Qt::Key_ParenRight;
case '[':
return Qt::Key_BracketLeft;
case ']':
return Qt::Key_BracketRight;
default:
return Qt::Key_unknown;
}
}
/// Depth-first search for the first model index whose label matches @p text.
QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {})
{
for (int row = 0; row < model->rowCount(parent); ++row) {
const QModelIndex index = model->index(row, 0, parent);
if (index.data(Qt::DisplayRole).toString() == text) {
return index;
}
const QModelIndex child = findModelIndexByText(model, text, index);
if (child.isValid()) {
return child;
}
}
return {};
}
bool needsShift(QChar character)
{
if (character.isUpper()) {
return true;
}
return QStringLiteral("_:\"&()").contains(character);
}
} // namespace
pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
{
}
bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
{
if (command == "clickCard") {
return playClickCard(object, arguments);
} else if (command == "flipCard") {
return playFlipCard(object, arguments);
} else if (command == "cardAction") {
return playCardAction(object, arguments);
} else if (command == "moveCard") {
return playMoveCard(object, arguments);
} else if (command == "drawArrow") {
return playDrawArrow(object, arguments);
} else if (command == "createToken") {
return playCreateToken(object, arguments);
} else if (command == "selectItem") {
return playSelectItem(object, arguments);
} else if (command == "typeText") {
return playTypeText(object, arguments);
} else if (command == "expectProperty") {
return playExpectProperty(object, arguments, error);
}
return false;
}
QGraphicsItem *
pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName)
{
for (QGraphicsItem *gi : scene->items()) {
if (gi->type() != typeCard) {
continue;
}
auto *card = static_cast<CardItem *>(gi);
if (!card->getZone() || card->getZone()->getName() != zoneName) {
continue;
}
QString actualName = card->getName();
if (actualName == cardName || actualName.endsWith(" " + cardName)) {
return card;
}
}
return nullptr;
}
QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName)
{
for (QGraphicsItem *gi : scene->items()) {
if (gi->type() != typeZone) {
continue;
}
auto *zone = static_cast<CardZone *>(gi);
if (zone->getLogic() && zone->getLogic()->getName() == zoneName) {
return zone;
}
}
return nullptr;
}
QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText)
{
if (!menu || actionText.isEmpty()) {
return nullptr;
}
for (QAction *action : menu->actions()) {
if (action->isSeparator()) {
continue;
}
if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) {
return action;
}
if (QMenu *subMenu = action->menu()) {
if (QAction *found = findActionInMenu(subMenu, actionText)) {
return found;
}
}
}
return nullptr;
}
QMenu *pqCockatriceEventPlayer::visiblePopupMenu()
{
for (QWidget *w : QApplication::topLevelWidgets()) {
auto *menu = qobject_cast<QMenu *>(w);
if (menu && menu->isVisible()) {
return menu;
}
}
return nullptr;
}
QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item)
{
return view->mapFromScene(item->sceneBoundingRect().center());
}
void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view,
QEvent::Type type,
const QPoint &pos,
Qt::MouseButton button,
Qt::MouseButtons buttons)
{
QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier);
qApp->notify(view->viewport(), &event);
}
bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCritical() << "clickCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCritical() << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCritical() << "clickCard: no scene on the QGraphicsView";
return false;
}
auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed());
if (!target) {
qCritical() << "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
const QPoint pos = viewportPoint(gv, target);
sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
return true;
}
bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCritical() << "flipCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCritical() << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCritical() << "flipCard: no scene";
return false;
}
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
if (!card) {
qCritical() << "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over"));
}
bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCritical() << "cardAction: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCritical() << "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCritical() << "cardAction: no scene";
return false;
}
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
if (!card) {
qCritical() << "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed());
}
bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command,
QGraphicsView *view,
CardItem *card,
const QString &actionText)
{
const QPoint pos = viewportPoint(view, card);
sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton);
qApp->processEvents();
QMenu *menu = visiblePopupMenu();
if (!menu) {
qWarning() << command << ": no popup menu appeared after right-click";
return false;
}
QAction *action = findActionInMenu(menu, actionText);
if (!action) {
qWarning() << command << ": no context menu action matching" << actionText;
menu->close();
return false;
}
action->trigger();
menu->close();
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCritical() << "moveCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCritical() << "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments;
return false;
}
const QString cardName = parts[0].trimmed();
const QString srcZoneName = parts[1].trimmed();
const QString dstZoneName = parts[2].trimmed();
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCritical() << "moveCard: no scene";
return false;
}
auto *card = findCardInScene(scene, cardName, srcZoneName);
if (!card) {
qCritical() << "moveCard: card" << cardName << "not found in zone" << srcZoneName;
return false;
}
auto *dstZone = findZoneInScene(scene, dstZoneName);
if (!dstZone) {
qCritical() << "moveCard: destination zone" << dstZoneName << "not found";
return false;
}
const QPoint srcPos = viewportPoint(gv, card);
const QPoint dstPos = viewportPoint(gv, dstZone);
// A drag has to travel past Qt's start distance before the scene turns it
// into a card drag item, otherwise the move is interpreted as a click.
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton);
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCritical() << "drawArrow: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 4) {
qCritical() << "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments;
return false;
}
const QString srcCardName = parts[0].trimmed();
const QString srcZoneName = parts[1].trimmed();
const QString dstCardName = parts[2].trimmed();
const QString dstZoneName = parts[3].trimmed();
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCritical() << "drawArrow: no scene";
return false;
}
auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName);
if (!srcCard) {
qCritical() << "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName;
return false;
}
auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName);
if (!dstCard) {
qCritical() << "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName;
return false;
}
const QPoint srcPos = viewportPoint(gv, srcCard);
const QPoint dstPos = viewportPoint(gv, dstCard);
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton);
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCritical() << "createToken: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCritical() << "createToken: arguments must be 'name,color,pt', got:" << arguments;
return false;
}
QString tokenName = parts[0].trimmed();
QString tokenColor = parts[1].trimmed();
QString tokenPt = parts[2].trimmed();
TabGame *tabGame = nullptr;
QWidget *w = gv;
while (w) {
tabGame = qobject_cast<TabGame *>(w);
if (tabGame) {
break;
}
w = w->parentWidget();
}
if (!tabGame) {
qCritical() << "createToken: TabGame not found";
return false;
}
AbstractGame *game = tabGame->getGame();
if (!game) {
qCritical() << "createToken: no game";
return false;
}
int activePlayerId = game->getGameState()->getActivePlayer();
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId);
if (!player) {
qCritical() << "createToken: no active local player";
return false;
}
TokenInfo info;
info.name = tokenName;
info.color = tokenColor;
info.pt = tokenPt;
info.destroy = true;
info.faceDown = false;
player->getPlayerActions()->actCreateToken(info);
for (int i = 0; i < 10; ++i) {
qApp->processEvents();
}
return true;
}
bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments)
{
const QString text = arguments.trimmed();
if (text.isEmpty()) {
qCritical() << "selectItem: needs the label of the item to select";
return false;
}
if (auto *tree = qobject_cast<QTreeWidget *>(object)) {
QTreeWidgetItemIterator item(tree);
while (*item) {
if ((*item)->text(0) == text) {
tree->setCurrentItem(*item);
qApp->processEvents();
return true;
}
++item;
}
qCritical() << "selectItem: no tree item labelled" << text;
return false;
}
if (auto *view = qobject_cast<QAbstractItemView *>(object)) {
const QModelIndex index = findModelIndexByText(view->model(), text);
if (!index.isValid()) {
qCritical() << "selectItem: no item labelled" << text;
return false;
}
view->setCurrentIndex(index);
qApp->processEvents();
return true;
}
qCritical() << "selectItem: target is neither a tree widget nor an item view";
return false;
}
bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments)
{
auto *widget = qobject_cast<QWidget *>(object);
if (!widget) {
qCritical() << "typeText: target is not a QWidget";
return false;
}
for (const QChar character : arguments) {
const Qt::Key key = keyForCharacter(character);
const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier;
QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character));
QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character));
qApp->notify(widget, &press);
qApp->notify(widget, &release);
}
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error)
{
if (!object) {
qCritical() << "expectProperty: no target object";
error = true;
return true;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCritical() << "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments;
error = true;
return true;
}
const QString propertyName = parts[0].trimmed();
const QString expected = parts.mid(1).join(',');
const QVariant actual = object->property(propertyName.toUtf8().constData());
if (!actual.isValid()) {
qCritical() << "expectProperty:" << object->objectName() << "has no property" << propertyName;
error = true;
return true;
}
if (actual.toString() != expected) {
qCritical() << "expectProperty:" << object->objectName() << propertyName << "is" << actual.toString()
<< "but the test expected" << expected;
error = true;
return true;
}
return true;
}

View file

@ -0,0 +1,91 @@
#ifndef PQ_COCKATRICE_EVENT_PLAYER_H
#define PQ_COCKATRICE_EVENT_PLAYER_H
#include "pqWidgetEventPlayer.h"
#include <QModelIndex>
class CardItem;
class QAction;
class QGraphicsItem;
class QGraphicsScene;
class QGraphicsView;
class QMenu;
/**
* Custom event player for Cockatrice game interactions.
*
* Card commands are resolution independent: the card is looked up in the
* QGraphicsScene by name and zone, then mouse events are sent at the card's
* current viewport position. That keeps recordings usable across screen sizes
* and window geometries.
*
* Commands:
* clickCard "cardName,zoneName"
* left press/release + double click — plays a card from hand, taps a
* permanent, opens the deck view entry, depending on the zone
* flipCard "cardName,zoneName"
* right-click → "Turn Over" (shorthand for cardAction)
* cardAction "cardName,zoneName,actionText"
* right-click the card and trigger the first context menu action whose
* label contains actionText; submenus are searched as well, so
* "Add counter (A)" works
* moveCard "cardName,srcZoneName,dstZoneName"
* left-drag the card onto the destination zone — plays a card, moves it
* to the graveyard/exile, puts it back into hand, ...
* drawArrow "srcCard,srcZone,dstCard,dstZone"
* right-drag an arrow from one card to another
* createToken "name,color,pt"
* create a token through the active local player, as the table context
* menu's "Create token" dialog would
* selectItem "item text"
* selects the first item of a tree/list widget whose label matches, for
* navigating the settings navigation tree or similar label-driven views
* typeText "some text"
* types the text into the widget one key event at a time — use it for
* deck lists and search boxes instead of one event per character
* expectProperty "propertyName,expectedValue"
* assertion: playback fails unless object->property(propertyName)
* equals expectedValue — for example "count,2" on the tab widget
*/
class pqCockatriceEventPlayer : public pqWidgetEventPlayer
{
Q_OBJECT
typedef pqWidgetEventPlayer Superclass;
public:
explicit pqCockatriceEventPlayer(QObject *parent = nullptr);
using Superclass::playEvent;
bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override;
private:
bool playClickCard(QObject *object, const QString &arguments);
bool playFlipCard(QObject *object, const QString &arguments);
bool playCardAction(QObject *object, const QString &arguments);
bool playMoveCard(QObject *object, const QString &arguments);
bool playDrawArrow(QObject *object, const QString &arguments);
bool playCreateToken(QObject *object, const QString &arguments);
bool playSelectItem(QObject *object, const QString &arguments);
bool playTypeText(QObject *object, const QString &arguments);
bool playExpectProperty(QObject *object, const QString &arguments, bool &error);
/// Triggers the context menu entry @p actionText after right-clicking @p card.
bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText);
static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName);
static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName);
static QAction *findActionInMenu(QMenu *menu, const QString &actionText);
static QMenu *visiblePopupMenu();
/// Viewport position of the item's centre, as the mouse events have to be sent.
static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item);
static void sendMouseEvent(QGraphicsView *view,
QEvent::Type type,
const QPoint &pos,
Qt::MouseButton button,
Qt::MouseButtons buttons);
};
#endif // PQ_COCKATRICE_EVENT_PLAYER_H

View file

@ -0,0 +1,102 @@
#include "recording.h"
#include "pqEventTypes.h"
#include <QFile>
#include <QTextStream>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent)
{
}
XMLEventObserver::~XMLEventObserver() = default;
void XMLEventObserver::setStream(QTextStream *stream)
{
if (xmlStream) {
xmlStream->writeEndElement();
xmlStream->writeEndDocument();
xmlStream.reset();
}
if (Stream) {
*Stream << xmlString;
}
xmlString.clear();
pqEventObserver::setStream(stream);
if (Stream) {
xmlStream.reset(new QXmlStreamWriter(&xmlString));
xmlStream->setAutoFormatting(true);
xmlStream->writeStartDocument();
xmlStream->writeStartElement(QStringLiteral("events"));
}
}
void XMLEventObserver::onRecordEvent(const QString &widget,
const QString &command,
const QString &arguments,
const int &eventType)
{
if (!xmlStream) {
return;
}
xmlStream->writeStartElement(QStringLiteral("event"));
xmlStream->writeAttribute(QStringLiteral("widget"), widget);
if (eventType == pqEventTypes::ACTION_EVENT) {
xmlStream->writeAttribute(QStringLiteral("command"), command);
} else { // pqEventTypes::CHECK_EVENT
xmlStream->writeAttribute(QStringLiteral("property"), command);
}
xmlStream->writeAttribute(QStringLiteral("arguments"), arguments);
xmlStream->writeEndElement();
}
XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent)
{
}
XMLEventSource::~XMLEventSource() = default;
void XMLEventSource::setContent(const QString &xmlFileName)
{
xmlStream.reset();
QFile xml(xmlFileName);
if (!xml.open(QIODevice::ReadOnly)) {
qCWarning(RecordingLog) << "Could not read test file" << xmlFileName;
return;
}
xmlStream.reset(new QXmlStreamReader(xml.readAll()));
}
int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType)
{
if (!xmlStream) {
return DONE;
}
while (!xmlStream->atEnd()) {
if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) {
continue;
}
const QXmlStreamAttributes attributes = xmlStream->attributes();
eventType = pqEventTypes::ACTION_EVENT;
widget = attributes.value(QStringLiteral("widget")).toString();
command = attributes.value(QStringLiteral("command")).toString();
if (command.isEmpty()) {
// A recorded check event carries a property instead of a command.
command = attributes.value(QStringLiteral("property")).toString();
}
arguments = attributes.value(QStringLiteral("arguments")).toString();
return SUCCESS;
}
if (xmlStream->hasError()) {
qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString();
}
return DONE;
}

View file

@ -0,0 +1,54 @@
#ifndef COCKATRICE_RECORDING_H
#define COCKATRICE_RECORDING_H
#include "pqEventObserver.h"
#include "pqEventSource.h"
#include <QLoggingCategory>
#include <QScopedPointer>
inline Q_LOGGING_CATEGORY(RecordingLog, "recording");
class QXmlStreamReader;
class QXmlStreamWriter;
/**
* @brief Writes a session recording as an XML test file.
*
* The events are buffered in memory and flushed to the stream when recording
* ends, because the stream is handed over once the file name is known.
*/
class XMLEventObserver : public pqEventObserver
{
QScopedPointer<QXmlStreamWriter> xmlStream;
QString xmlString;
public:
explicit XMLEventObserver(QObject *parent);
~XMLEventObserver() override;
protected:
void setStream(QTextStream *stream) override;
void onRecordEvent(const QString &widget,
const QString &command,
const QString &arguments,
const int &eventType) override;
};
/**
* @brief Plays the events of an XML test file back, one at a time.
*/
class XMLEventSource : public pqEventSource
{
QScopedPointer<QXmlStreamReader> xmlStream;
public:
explicit XMLEventSource(QObject *parent);
~XMLEventSource() override;
protected:
void setContent(const QString &xmlFileName) override;
int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override;
};
#endif // COCKATRICE_RECORDING_H

View file

@ -33,6 +33,7 @@ class QWidget;
DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
{
setObjectName("connectDialog");
previousHostButton = new QRadioButton(tr("Known Hosts"), this);
previousHosts = new QComboBox(this);
@ -85,6 +86,11 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
passwordLabel->setBuddy(passwordEdit);
passwordEdit->setEchoMode(QLineEdit::Password);
hostEdit->setObjectName("connectHostEdit");
portEdit->setObjectName("connectPortEdit");
playernameEdit->setObjectName("connectPlayernameEdit");
passwordEdit->setObjectName("connectPasswordEdit");
savePasswordCheckBox = new QCheckBox(tr("&Save password"));
autoConnectCheckBox = new QCheckBox(tr("A&uto connect"));

View file

@ -40,6 +40,7 @@ class QWidget;
DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr)
{
setObjectName("editTokensDialog");
nameLabel = new QLabel(tr("&Name:"));
nameEdit = new QLineEdit;
nameEdit->setMaxLength(MAX_NAME_LENGTH);

View file

@ -28,12 +28,16 @@ class QWidget;
*/
AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent)
{
setObjectName("dlgDeckTextEdit");
contentsEdit = new QPlainTextEdit;
contentsEdit->setObjectName("contentsEdit");
refreshButton = new QPushButton(tr("&Refresh"));
connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttonBox->setObjectName("buttonBox");
buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole);
connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject);

View file

@ -16,8 +16,11 @@ class QWidget;
DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
{
setObjectName("localGameDialog");
numberPlayersLabel = new QLabel(tr("Players:"), this);
numberPlayersEdit = new QSpinBox(this);
numberPlayersEdit->setObjectName("numberPlayersEdit");
numberPlayersEdit->setMinimum(1);
numberPlayersEdit->setMaximum(8);
numberPlayersEdit->setValue(1);
@ -28,10 +31,12 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
generalGrid->addWidget(numberPlayersLabel, 0, 0);
generalGrid->addWidget(numberPlayersEdit, 0, 1);
generalGroupBox = new QGroupBox(tr("General"), this);
generalGroupBox->setObjectName("generalGroupBox");
generalGroupBox->setLayout(generalGrid);
startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this);
startingLifeTotalEdit = new QSpinBox(this);
startingLifeTotalEdit->setObjectName("startingLifeTotalEdit");
startingLifeTotalEdit->setMinimum(-999999999);
startingLifeTotalEdit->setMaximum(999999999);
startingLifeTotalEdit->setValue(20);
@ -42,11 +47,14 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0);
gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1);
gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this);
gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox");
gameSetupOptionsGroupBox->setLayout(gameSetupGrid);
rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this);
rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox");
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttonBox->setObjectName("buttonBox");
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject);

View file

@ -48,6 +48,7 @@
WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
{
setObjectName("manageSetsWindow");
setOrderIsSorted = false;
// left toolbar
@ -221,6 +222,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
WndSets::~WndSets()
{
disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState);
}
void WndSets::closeEvent(QCloseEvent * /*ev*/)

View file

@ -73,6 +73,8 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
resize(calculateDialogSize(hostSize, available.size(), minimum));
setSizeGripEnabled(true);
setObjectName("settingsDialog");
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
settingsView = new SettingsView(this);
@ -82,6 +84,7 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
buttonBox->addStretch();
okButton = new QPushButton;
okButton->setDefault(true);
okButton->setObjectName("settingsOkButton");
connect(okButton, &QPushButton::clicked, this, &DlgSettings::close);
buttonBox->addWidget(okButton);

View file

@ -28,6 +28,7 @@
DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent)
{
setObjectName("tipOfTheDayDialog");
successfulInit = false;
QString xmlPath = "theme:tips/tips_of_the_day.xml";
tipDatabase = new TipsOfTheDay(xmlPath, this);

View file

@ -42,6 +42,7 @@ static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe
DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent)
{
setObjectName("updateDialog");
// Handle layout
statusLabel = new QLabel(this);

View file

@ -18,6 +18,7 @@ class QWidget;
DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent)
{
setObjectName("viewLogDialog");
logArea = new QPlainTextEdit;
logArea->setReadOnly(true);

View file

@ -119,6 +119,8 @@ QWidget *fromWidgetId(WidgetId id)
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
{
setObjectName("settingsView");
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
@ -135,6 +137,7 @@ void SettingsView::setupUi()
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
searchEdit->setObjectName("settingsSearchEdit");
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);

View file

@ -68,6 +68,7 @@
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home")))
{
setObjectName("homeWidget");
layout = new QGridLayout(this);
backgroundSourceCard = new CardInfoPictureArtCropWidget(this);
@ -287,6 +288,7 @@ void HomeWidget::updateButtonsToBackgroundColor()
QGroupBox *HomeWidget::createButtons()
{
QGroupBox *box = new QGroupBox(this);
box->setObjectName("homeButtonsBox");
box->setStyleSheet(R"(
QGroupBox {
font-size: 20px;
@ -311,27 +313,34 @@ QGroupBox *HomeWidget::createButtons()
boxLayout->addSpacing(25);
connectButton = new HomeStyledButton(QString(), gradientColors);
connectButton->setObjectName("homeConnectButton");
boxLayout->addWidget(connectButton);
visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors);
visualDeckEditorButton->setObjectName("homeCreateNewDeckButton");
connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor,
[this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); });
boxLayout->addWidget(visualDeckEditorButton);
visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors);
visualDeckStorageButton->setObjectName("homeBrowseDecksButton");
connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor,
[this] { tabSupervisor->actTabVisualDeckStorage(true); });
boxLayout->addWidget(visualDeckStorageButton);
visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors);
visualDatabaseDisplayButton->setObjectName("homeBrowseCardDatabaseButton");
connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor,
&TabSupervisor::addVisualDatabaseDisplayTab);
boxLayout->addWidget(visualDatabaseDisplayButton);
edhrecButton = new HomeStyledButton(QString(), gradientColors);
edhrecButton->setObjectName("homeBrowseEdhrecButton");
connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab);
boxLayout->addWidget(edhrecButton);
archidektButton = new HomeStyledButton(QString(), gradientColors);
archidektButton->setObjectName("homeBrowseArchidektButton");
connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab);
boxLayout->addWidget(archidektButton);
replayButton = new HomeStyledButton(QString(), gradientColors);
replayButton->setObjectName("homeViewReplaysButton");
connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); });
boxLayout->addWidget(replayButton);
if (qobject_cast<MainWindow *>(tabSupervisor->parentWidget())) {

View file

@ -99,6 +99,7 @@ class Message;
TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay)
: Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr)
{
setObjectName("TabGame");
// THIS CTOR IS USED ON REPLAY
game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(),
new SettingsCardPreferenceProvider(this));
@ -116,6 +117,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay)
addDockWidget(Qt::BottomDockWidgetArea, replayDock);
mainWidget = new QStackedWidget(this);
mainWidget->setObjectName("mainWidget");
mainWidget->addWidget(deckViewContainerWidget);
mainWidget->addWidget(gamePlayAreaWidget);
setCentralWidget(mainWidget);
@ -144,6 +146,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
const QMap<int, QString> &_roomGameTypes)
: Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager())
{
setObjectName("TabGame");
// THIS CTOR IS USED ON GAMES
game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes,
&SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this));
@ -160,6 +163,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock);
mainWidget = new QStackedWidget(this);
mainWidget->setObjectName("mainWidget");
mainWidget->addWidget(deckViewContainerWidget);
mainWidget->addWidget(gamePlayAreaWidget);
mainWidget->setContentsMargins(0, 0, 0, 0);
@ -515,9 +519,11 @@ void TabGame::refreshShortcuts()
}
if (aResetLayout) {
aResetLayout->setShortcuts(shortcuts.getShortcut("Player/aResetLayout"));
aResetLayout->setShortcutContext(Qt::WidgetWithChildrenShortcut);
}
if (aFocusChat) {
aFocusChat->setShortcuts(shortcuts.getShortcut("Player/aFocusChat"));
aFocusChat->setShortcutContext(Qt::WidgetWithChildrenShortcut);
}
}
@ -1118,6 +1124,7 @@ void TabGame::createMenuItems()
connect(aFocusChat, &QAction::triggered, sayEdit, qOverload<>(&LineEditCompleter::setFocus));
phasesMenu = new TearOffMenu(this);
phasesMenu->setObjectName("phasesMenu");
for (int i = 0; i < phasesToolbar->phaseCount(); ++i) {
auto *temp = new QAction(QString(), this);
@ -1128,12 +1135,16 @@ void TabGame::createMenuItems()
phasesMenu->addSeparator();
phasesMenu->addAction(aNextPhase);
aNextPhase->setObjectName("aNextPhase");
phasesMenu->addAction(aNextPhaseAction);
aNextPhaseAction->setObjectName("aNextPhaseAction");
gameMenu = new QMenu(this);
gameMenu->setObjectName("gameMenu");
playersSeparator = gameMenu->addSeparator();
gameMenu->addMenu(phasesMenu);
gameMenu->addAction(aNextTurn);
aNextTurn->setObjectName("aNextTurn");
gameMenu->addAction(aReverseTurn);
gameMenu->addSeparator();
gameMenu->addAction(aRemoveLocalArrows);

View file

@ -6,6 +6,7 @@ class TabSupervisor;
TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client)
{
setObjectName("TabHome");
homeWidget = new HomeWidget(this, tabSupervisor);
setCentralWidget(homeWidget);
}

View file

@ -54,6 +54,7 @@
#include <QPainter>
#include <QRect>
#include <QSharedPointer>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QStyle>
#include <QStyleOption>
@ -157,6 +158,15 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setObjectName("tabSupervisor");
// QTabWidget keeps tab pages in an internal stacked widget. Give it a stable
// name so the GUI tests can address the pages by a readable path
// (tabSupervisor/stackedWidget/<TabName>).
if (auto *stackedWidget = findChild<QStackedWidget *>()) {
stackedWidget->setObjectName("stackedWidget");
}
setElideMode(Qt::ElideRight);
setMovable(true);
setIconSize(QSize(15, 15));
@ -462,6 +472,7 @@ void TabSupervisor::addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager
auto closeSide = static_cast<QTabBar::ButtonPosition>(
tabBar()->style()->styleHint(QStyle::SH_TabBar_CloseButtonPosition, nullptr, tabBar()));
auto *closeButton = new CloseButton(tab);
closeButton->setObjectName(QStringLiteral("tabCloseButton_%1").arg(tabIndex));
if (manager) {
// If managed, all close requests should go through the menu action
connect(closeButton, &CloseButton::clicked, this, [manager] { checkAndTrigger(manager, false); });

View file

@ -49,6 +49,14 @@
#include "intents/url_parser.h"
#include "latency_tracker.h"
#include "logger.h"
#ifdef WITH_GUI_TEST
#include "../game_graphics/pq_cockatrice_event_player.h"
#include "pqEventObserver.h"
#include "pqEventSource.h"
#include "pqEventTypes.h"
#include "pqTestUtility.h"
#include "recording.h"
#endif
#include "pixel_map_generator.h"
#include "version_string.h"
#include "widgets/dialogs/dlg_connect.h"
@ -306,6 +314,7 @@ void MainWindow::actAbout()
mb.setIconPixmap(
themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation));
mb.setTextInteractionFlags(Qt::TextBrowserInteraction);
mb.setObjectName("aboutDialog");
mb.exec();
}
@ -344,6 +353,24 @@ void MainWindow::actOpenSettingsFolder()
QtUtils::openPathInFileManager(dir);
}
#ifdef WITH_GUI_TEST
void MainWindow::actRecord()
{
QString filename = QFileDialog::getSaveFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)"));
if (!filename.isEmpty()) {
testUtility->recordTests(filename);
}
}
void MainWindow::actPlayRecording()
{
QString filename = QFileDialog::getOpenFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)"));
if (!filename.isEmpty()) {
testUtility->playTests(filename);
}
}
#endif
void MainWindow::retranslateUi()
{
connectionController->refreshWindowTitle();
@ -388,6 +415,10 @@ void MainWindow::retranslateUi()
aViewLog->setText(tr("View &Debug Log"));
aOpenSettingsFolder->setText(tr("Open Settings Folder"));
aFirstRunWizard->setText(tr("Re-run Onboarding Wizard..."));
#ifdef WITH_GUI_TEST
aRecord->setText(tr("&Record Test..."));
aPlayRecording->setText(tr("&Play Test..."));
#endif
aShow->setText(tr("Show/Hide"));
@ -397,24 +428,33 @@ void MainWindow::retranslateUi()
void MainWindow::createActions()
{
aConnect = new QAction(this);
aConnect->setObjectName("actionConnect");
connect(aConnect, &QAction::triggered, this, &MainWindow::actConnect);
aDisconnect = new QAction(this);
aDisconnect->setObjectName("actionDisconnect");
aDisconnect->setEnabled(false);
connect(aDisconnect, &QAction::triggered, this, &MainWindow::actDisconnect);
aSinglePlayer = new QAction(this);
aSinglePlayer->setObjectName("actionSinglePlayer");
connect(aSinglePlayer, &QAction::triggered, this, &MainWindow::actSinglePlayer);
aWatchReplay = new QAction(this);
aWatchReplay->setObjectName("actionWatchReplay");
connect(aWatchReplay, &QAction::triggered, this, &MainWindow::actWatchReplay);
aFullScreen = new QAction(this);
aFullScreen->setObjectName("actionFullScreen");
aFullScreen->setCheckable(true);
connect(aFullScreen, &QAction::toggled, this, &MainWindow::actFullScreen);
aRegister = new QAction(this);
aRegister->setObjectName("actionRegister");
connect(aRegister, &QAction::triggered, connectionController, &ConnectionController::registerToServer);
aForgotPassword = new QAction(this);
aForgotPassword->setObjectName("actionForgotPassword");
connect(aForgotPassword, &QAction::triggered, connectionController, &ConnectionController::forgotPasswordRequest);
aSettings = new QAction(this);
aSettings->setObjectName("actionSettings");
connect(aSettings, &QAction::triggered, this, &MainWindow::actSettings);
aExit = new QAction(this);
aExit->setObjectName("actionExit");
connect(aExit, &QAction::triggered, this, &MainWindow::actExit);
aManageSets = new QAction(QString(), this);
@ -451,6 +491,12 @@ void MainWindow::createActions()
connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder);
aFirstRunWizard = new QAction(this);
connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); });
#ifdef WITH_GUI_TEST
aRecord = new QAction(this);
connect(aRecord, &QAction::triggered, this, &MainWindow::actRecord);
aPlayRecording = new QAction(this);
connect(aPlayRecording, &QAction::triggered, this, &MainWindow::actPlayRecording);
#endif
aShow = new QAction(this);
connect(aShow, &QAction::triggered, this, &MainWindow::actShow);
@ -491,7 +537,9 @@ void MainWindow::createActions()
void MainWindow::createMenus()
{
menuBar()->setObjectName("menubar");
cockatriceMenu = menuBar()->addMenu(QString());
cockatriceMenu->setObjectName("cockatriceMenu");
cockatriceMenu->addAction(aConnect);
cockatriceMenu->addAction(aDisconnect);
cockatriceMenu->addAction(aRegister);
@ -507,6 +555,7 @@ void MainWindow::createMenus()
cockatriceMenu->addAction(aExit);
dbMenu = menuBar()->addMenu(QString());
dbMenu->setObjectName("dbMenu");
dbMenu->addAction(aManageSets);
dbMenu->addAction(aEditTokens);
dbMenu->addSeparator();
@ -517,8 +566,10 @@ void MainWindow::createMenus()
dbMenu->addAction(aReloadCardDatabase);
tabsMenu = menuBar()->addMenu(QString());
tabsMenu->setObjectName("tabsMenu");
helpMenu = menuBar()->addMenu(QString());
helpMenu->setObjectName("helpMenu");
helpMenu->addAction(aAbout);
helpMenu->addAction(aTips);
helpMenu->addSeparator();
@ -531,12 +582,19 @@ void MainWindow::createMenus()
helpMenu->addAction(aOpenSettingsFolder);
helpMenu->addSeparator();
helpMenu->addAction(aFirstRunWizard);
#ifdef WITH_GUI_TEST
helpMenu->addSeparator();
helpMenu->addAction(aRecord);
helpMenu->addAction(aPlayRecording);
#endif
}
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false),
: QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), noConnect(false),
cardUpdateProcess(nullptr), logviewDialog(nullptr)
{
setObjectName("MainWindow");
connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this,
&MainWindow::pixmapCacheSizeChanged);
pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize());
@ -622,6 +680,14 @@ MainWindow::MainWindow(QWidget *parent)
// run startup check async
QTimer::singleShot(0, this, &MainWindow::startupConfigCheck);
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
#ifdef WITH_GUI_TEST
testUtility = new pqTestUtility(this);
testUtility->setRecordWithDialog(true);
testUtility->addEventObserver("xml", new XMLEventObserver(this));
testUtility->addEventSource("xml", new XMLEventSource(this));
testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this));
#endif
}
void MainWindow::startupConfigCheck()
@ -776,6 +842,11 @@ void MainWindow::runFirstRunWizard(bool firstRun)
*/
void MainWindow::applyStartupDestination()
{
if (noConnect) {
qCInfo(WindowMainStartupAutoconnectLog) << "Startup destination suppressed (--no-connect)";
return;
}
// An explicit command-line connect takes precedence over the startup destination.
if (!connectTo.isEmpty()) {
return;
@ -1045,6 +1116,10 @@ void MainWindow::handleCockatriceLink(const QString &url)
void MainWindow::attemptStartupAutoConnect()
{
if (noConnect) {
qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)";
return;
}
if (startupAutoConnectAttempted || skipStartupAutoConnect) {
return;
}

View file

@ -48,6 +48,7 @@
class QAction;
class QToolButton;
class QWidget;
class pqTestUtility;
struct LocalGameOptions;
inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main");
@ -104,6 +105,11 @@ private slots:
void actUpdate();
void actViewLog();
void actOpenSettingsFolder();
#ifdef WITH_GUI_TEST
void actRecord();
void actPlayRecording();
#endif
void actShow();
void showWindowIfHidden();
void onUrlChainFinished(bool connected);
@ -185,6 +191,9 @@ private:
*aReloadCardDatabase;
QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar,
*aViewLog, *aOpenSettingsFolder;
#ifdef WITH_GUI_TEST
QAction *aRecord, *aPlayRecording;
#endif
TabSupervisor *tabSupervisor;
IntentUrlParser *urlParser;
@ -208,6 +217,7 @@ private:
bool skipStartupAutoConnect = false;
bool startupAutoConnectAttempted = false;
bool firstRunWizardActive = false;
bool noConnect = false; ///< --no-connect: skip every startup connection
QProcess *cardUpdateProcess;
QByteArray cardUpdateOutputBuffer;
QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr
@ -219,6 +229,9 @@ private:
GameReplay *replay;
DlgTipOfTheDay *tip;
QUrl connectTo;
#ifdef WITH_GUI_TEST
pqTestUtility *testUtility;
#endif
public:
explicit MainWindow(QWidget *parent = nullptr);
@ -226,6 +239,16 @@ public:
{
connectTo = QUrl(QString("cockatrice://%1").arg(url));
}
/**
* @brief Suppresses every startup connection: --connect, auto-connect and the
* configured startup destination. Used by the GUI test harness so a
* playback run never dials out.
*/
void setNoConnect(bool value)
{
noConnect = value;
}
// When set, the window's own startup connection (--connect or auto-connect
// on first activation) is skipped. Used for activation launches: the intent
// chain triggered by a cockatrice:// URL owns the connection, and letting
@ -266,6 +289,13 @@ public:
*/
void requestInstallerClose();
#ifdef WITH_GUI_TEST
pqTestUtility *getTestUtility() const
{
return testUtility;
}
#endif
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;

View file

@ -39,6 +39,10 @@
#include "single_instance_manager.h"
#include "version_string.h"
#ifdef WITH_GUI_TEST
#include "pqTestUtility.h"
#endif
#include <QApplication>
#include <QByteArray>
#include <QByteArrayView>
@ -62,10 +66,12 @@
#include <QString>
#include <QStringList>
#include <QStringLiteral>
#include <QThread>
#include <QTranslator>
#include <QUrl>
#include <QtGlobal>
#include <algorithm>
#include <cstdlib>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/rng/rng_sfmt.h>
#include <libcockatrice/settings/card_database_settings.h>
@ -343,9 +349,16 @@ int main(int argc, char *argv[])
parser.addOptions(
{{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"},
{{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")},
{"simulate-crash",
QCoreApplication::translate("main",
"Show the crash-report dialog (saves a mock file when no DSN is configured)")}});
{"simulate-crash", QCoreApplication::translate(
"main", "Show the crash-report dialog (saves a mock file when no DSN is configured)")},
{"no-connect", QCoreApplication::translate("main", "Suppress auto-connect on startup")}
#ifdef WITH_GUI_TEST
,
{{"t", "test"},
QCoreApplication::translate("main", "Play back a QtTesting XML test file and exit"),
"test-file.xml"}
#endif
});
parser.process(app);
@ -483,6 +496,9 @@ int main(int argc, char *argv[])
if (parser.isSet("connect")) {
ui.setConnectTo(parser.value("connect"));
}
if (parser.isSet("no-connect")) {
ui.setNoConnect(true);
}
qCInfo(MainLog) << "MainWindow constructor finished";
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
@ -523,6 +539,27 @@ int main(int argc, char *argv[])
handleActivation(file);
}
#ifdef WITH_GUI_TEST
if (parser.isSet("test")) {
QString testFile = parser.value("test");
qCInfo(MainLog) << "Playing test file:" << testFile;
QTimer::singleShot(0, &ui, [&ui, testFile]() {
bool success = ui.getTestUtility()->playTests(testFile);
qCInfo(MainLog) << "Test playback" << (success ? "succeeded" : "FAILED");
qApp->exit(success ? 0 : 1);
// An event that fails while a modal dialog is open aborts the dispatcher,
// but the dialog's nested event loop can then keep the application alive
// forever. Hard-exit after a grace period so a red test always terminates.
auto *watchdog = QThread::create([success] {
QThread::sleep(10);
::_Exit(success ? 0 : 1);
});
watchdog->start();
});
}
#endif
// Connect to future file/URL events from other instances
QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) {
for (const QString &file : files) {

1
external/qttesting vendored Submodule

@ -0,0 +1 @@
Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379