diff --git a/.gitmodules b/.gitmodules index 732fec7ca..e69c8e686 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index d9acabc5e..97e7a862e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/Testing/README.md b/Testing/README.md new file mode 100644 index 000000000..2a03abd37 --- /dev/null +++ b/Testing/README.md @@ -0,0 +1,52 @@ +# 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 `` 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 | diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml new file mode 100644 index 000000000..437c4bd23 --- /dev/null +++ b/Testing/deck_editor_open_test.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml new file mode 100644 index 000000000..0b9f88fa1 --- /dev/null +++ b/Testing/deck_editor_search_test.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Testing/dialog_lifecycle_test.xml b/Testing/dialog_lifecycle_test.xml new file mode 100644 index 000000000..00510b802 --- /dev/null +++ b/Testing/dialog_lifecycle_test.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml new file mode 100644 index 000000000..5fc7a971a --- /dev/null +++ b/Testing/integration_test.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/menu_open_close_test.xml b/Testing/menu_open_close_test.xml new file mode 100644 index 000000000..d894b47b5 --- /dev/null +++ b/Testing/menu_open_close_test.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Testing/nonmodal_manage_sets_test.xml b/Testing/nonmodal_manage_sets_test.xml new file mode 100644 index 000000000..5fe2bfc0c --- /dev/null +++ b/Testing/nonmodal_manage_sets_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_tip_test.xml b/Testing/nonmodal_tip_test.xml new file mode 100644 index 000000000..0f4ffc825 --- /dev/null +++ b/Testing/nonmodal_tip_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_view_log_test.xml b/Testing/nonmodal_view_log_test.xml new file mode 100644 index 000000000..3369f1fd5 --- /dev/null +++ b/Testing/nonmodal_view_log_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/run_test.sh b/Testing/run_test.sh new file mode 100755 index 000000000..75c09bf5a --- /dev/null +++ b/Testing/run_test.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Run QtTesting XML test files against the Cockatrice build. +# +# Usage: +# ./Testing/run_test.sh [--visual] [--all | ...] +# +# 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 | ...]" >&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 diff --git a/Testing/settings_dialog_test.xml b/Testing/settings_dialog_test.xml new file mode 100644 index 000000000..2dd9dda0a --- /dev/null +++ b/Testing/settings_dialog_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/settings_search_test.xml b/Testing/settings_search_test.xml new file mode 100644 index 000000000..ba60251f0 --- /dev/null +++ b/Testing/settings_search_test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Testing/smoke_test.xml b/Testing/smoke_test.xml new file mode 100644 index 000000000..a31edbdd4 --- /dev/null +++ b/Testing/smoke_test.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ce7ad5712..4ab926be2 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -562,6 +562,14 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) +if(WITH_GUI_TEST) + # recording.cpp translates between XML test files and the QtTesting event pipeline; + # it needs the QtTesting headers, so it only exists with the harness. + target_sources(cockatrice PRIVATE 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}") diff --git a/cockatrice/resources/config/qtlogging.ini b/cockatrice/resources/config/qtlogging.ini index 7ac0d9ca4..e4b845af9 100644 --- a/cockatrice/resources/config/qtlogging.ini +++ b/cockatrice/resources/config/qtlogging.ini @@ -10,6 +10,7 @@ # or set = false to disable logging #main = true +#recording = true #qt_translator = true #window_main.* = true #release_channel = true diff --git a/cockatrice/src/interface/recording.cpp b/cockatrice/src/interface/recording.cpp new file mode 100644 index 000000000..2b11255f9 --- /dev/null +++ b/cockatrice/src/interface/recording.cpp @@ -0,0 +1,102 @@ +#include "recording.h" + +#include "pqEventTypes.h" + +#include +#include +#include +#include + +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; +} diff --git a/cockatrice/src/interface/recording.h b/cockatrice/src/interface/recording.h new file mode 100644 index 000000000..a5d813ea9 --- /dev/null +++ b/cockatrice/src/interface/recording.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_RECORDING_H +#define COCKATRICE_RECORDING_H + +#include "pqEventObserver.h" +#include "pqEventSource.h" + +#include +#include + +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 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 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 diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 1f567989e..e4411ec1d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -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")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..3845029f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index dbfe38e26..caca5da92 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -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*/) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ab870a2c8..3602e67c6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp index 511ff1c7d..c1df0c963 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index ca55c7b41..11dcd7e27 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index d55e800a1..e75cf352a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -18,6 +18,7 @@ class QWidget; DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { + setObjectName("viewLogDialog"); logArea = new QPlainTextEdit; logArea->setReadOnly(true); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index d22a8d0b3..96b429a61 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 5905b2c40..5edf607be 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -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(tabSupervisor->parentWidget())) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index af9994fdd..3ad16aa91 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -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); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3316232a4..bb4361967 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -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/). + if (auto *stackedWidget = findChild()) { + 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( 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); }); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index f8ab5d3f6..5b58ea72c 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -49,6 +49,13 @@ #include "intents/url_parser.h" #include "latency_tracker.h" #include "logger.h" +#ifdef WITH_GUI_TEST +#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 +313,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 +352,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 +414,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 +427,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 +490,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 +536,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 +554,7 @@ void MainWindow::createMenus() cockatriceMenu->addAction(aExit); dbMenu = menuBar()->addMenu(QString()); + dbMenu->setObjectName("dbMenu"); dbMenu->addAction(aManageSets); dbMenu->addAction(aEditTokens); dbMenu->addSeparator(); @@ -517,8 +565,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 +581,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 +679,13 @@ 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)); +#endif } void MainWindow::startupConfigCheck() @@ -776,6 +840,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 +1114,10 @@ void MainWindow::handleCockatriceLink(const QString &url) void MainWindow::attemptStartupAutoConnect() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)"; + return; + } if (startupAutoConnectAttempted || skipStartupAutoConnect) { return; } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 9e342e4b0..48d093a38 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -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; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829a8ac42..73d1386fb 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -39,6 +39,10 @@ #include "single_instance_manager.h" #include "version_string.h" +#ifdef WITH_GUI_TEST +#include "pqTestUtility.h" +#endif + #include #include #include @@ -62,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -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) { diff --git a/external/qttesting b/external/qttesting new file mode 160000 index 000000000..c7bb5d372 --- /dev/null +++ b/external/qttesting @@ -0,0 +1 @@ +Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379