diff --git a/Testing/README.md b/Testing/README.md index 2a03abd37..cb990ec29 100644 --- a/Testing/README.md +++ b/Testing/README.md @@ -50,3 +50,17 @@ A test is a `` list. Widgets are named by their path from the main windo | `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` | double click the card found in that zone (plays it from hand) | +| `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. diff --git a/Testing/card_actions_test.xml b/Testing/card_actions_test.xml new file mode 100644 index 000000000..8d6de4c2b --- /dev/null +++ b/Testing/card_actions_test.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/game_mechanics_test.xml b/Testing/game_mechanics_test.xml new file mode 100644 index 000000000..efef78707 --- /dev/null +++ b/Testing/game_mechanics_test.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/local_game_lifecycle_test.xml b/Testing/local_game_lifecycle_test.xml new file mode 100644 index 000000000..bdc964921 --- /dev/null +++ b/Testing/local_game_lifecycle_test.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 234c3ce5a..9b467721d 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -567,9 +567,9 @@ 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) + # 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() diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 3bef9e59e..fe63d7a6f 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -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); diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp index 90cbed9a1..75ede0ca8 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp @@ -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); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index cb59b3d7d..e6d94a035 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -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); diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp new file mode 100644 index 000000000..c6376ef75 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp @@ -0,0 +1,628 @@ +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer") + +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_A + (character.toUpper().unicode() - 'A')); + } + if (character.isDigit()) { + return static_cast(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) +{ + // A known command that fails is a handled-but-failed event: QtTesting logs + // "Unhandled event" when playEvent returns false, so report through `error` + // (the contract playExpectProperty already follows) and still return true. + const auto run = [&error](bool ok) { + if (!ok) { + error = true; + } + return true; + }; + + if (command == "clickCard") { + return run(playClickCard(object, arguments)); + } else if (command == "flipCard") { + return run(playFlipCard(object, arguments)); + } else if (command == "cardAction") { + return run(playCardAction(object, arguments)); + } else if (command == "moveCard") { + return run(playMoveCard(object, arguments)); + } else if (command == "drawArrow") { + return run(playDrawArrow(object, arguments)); + } else if (command == "createToken") { + return run(playCreateToken(object, arguments)); + } else if (command == "selectItem") { + return run(playSelectItem(object, arguments)); + } else if (command == "typeText") { + return run(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(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(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(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(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView"; + return false; + } + + auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()); + if (!target) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + const QPoint pos = viewportPoint(gv, target); + // A double click is the play gesture from hand: press/release, then the + // double-click event and its release, exactly as a recording produces it. + 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(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "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(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "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) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click"; + return false; + } + + QAction *action = findActionInMenu(menu, actionText); + if (!action) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << 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(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "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) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene"; + return false; + } + + auto *card = findCardInScene(scene, cardName, srcZoneName); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstZone = findZoneInScene(scene, dstZoneName); + if (!dstZone) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "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(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 4) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "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) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene"; + return false; + } + + auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName); + if (!srcCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName); + if (!dstCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "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(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "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(w); + if (tabGame) { + break; + } + w = w->parentWidget(); + } + if (!tabGame) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found"; + return false; + } + + AbstractGame *game = tabGame->getGame(); + if (!game) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game"; + return false; + } + + int activePlayerId = game->getGameState()->getActivePlayer(); + PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId); + if (!player) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "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()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select"; + return false; + } + + if (auto *tree = qobject_cast(object)) { + QTreeWidgetItemIterator item(tree); + while (*item) { + if ((*item)->text(0) == text) { + tree->setCurrentItem(*item); + qApp->processEvents(); + return true; + } + ++item; + } + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text; + return false; + } + + if (auto *view = qobject_cast(object)) { + const QModelIndex index = findModelIndexByText(view->model(), text); + if (!index.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text; + return false; + } + view->setCurrentIndex(index); + qApp->processEvents(); + return true; + } + + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "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(object); + if (!widget) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "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) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object"; + error = true; + return true; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "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()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty:" << object->objectName() << "has no property" << propertyName; + error = true; + return true; + } + + if (actual.toString() != expected) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is" + << actual.toString() << "but the test expected" << expected; + error = true; + return true; + } + + return true; +} diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.h b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h new file mode 100644 index 000000000..89d88af77 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h @@ -0,0 +1,91 @@ +#ifndef PQ_COCKATRICE_EVENT_PLAYER_H +#define PQ_COCKATRICE_EVENT_PLAYER_H + +#include "pqWidgetEventPlayer.h" + +#include + +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); + + /** @brief 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(); + + /** @brief 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 diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp index 2c0fd3f6f..c1d0676ca 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp index 858deb751..3ebb5c093 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9e724727f..cbff850a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -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 &_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); @@ -1118,6 +1122,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 +1133,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); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 13f2e43cd..79a802234 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -56,6 +56,7 @@ #include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" #ifdef WITH_GUI_TEST +#include "../game_graphics/pq_cockatrice_event_player.h" #include "pqEventObserver.h" #include "pqEventSource.h" #include "pqEventTypes.h" @@ -689,6 +690,7 @@ MainWindow::MainWindow(QWidget *parent) testUtility->setRecordWithDialog(true); testUtility->addEventObserver("xml", new XMLEventObserver(this)); testUtility->addEventSource("xml", new XMLEventSource(this)); + testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this)); #endif }