diff --git a/Testing/README.md b/Testing/README.md index d052fb3c5..91c4e2a80 100644 --- a/Testing/README.md +++ b/Testing/README.md @@ -66,14 +66,34 @@ A test is an `` list. Widgets are named by their path from the main wind | `screenshot` | `path` | saves an image of the addressed widget; `$VAR` expands from the environment | | `dumpTree` | `path` | writes the addressed widget's subtree with the paths tests address | | `resize` | `WIDTHxHEIGHT` | resizes the addressed widget's window | +| `selectRow` | `row[/row…][,column]` | selects a row of an item view through its selection model | +| `clickRow` | `row[/row…][,column]` | left-clicks a cell, so the view's own handlers run | +| `doubleClickRow` | `row[/row…][,column]` | double-clicks a cell | 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. -The capture commands are `pqWidgetTestEventPlayer` -(`cockatrice/src/interface/pq_widget_test_event_player.h`). +The capture and row commands are `pqWidgetTestEventPlayer` +(`cockatrice/src/interface/pq_widget_test_event_player.h`). Rows are model rows of the +addressed view, so a filtered or sorted model counts the rows it shows. A tree addresses a +row by the path of rows leading to it: `2/0` is the first child of the third top-level +row. A click opens a collapsed path first. + +## Assertions + +`expectProperty` is an action. It fails playback unless a widget property matches. +QtTesting's own check commands assert model state instead, and they only run when the +event carries `type="check"`. Without it the event is an action, and a check command is +rejected as unknown: + +```xml + +``` + +`modelRowCount` compares `model()->rowCount()`. `modelItemData` compares the data of one +cell, addressed as `row.column,text` (`modelItemData "0.0,Lightning Bolt"`). ## Screenshots @@ -121,3 +141,9 @@ Record the tree in the same playback that clicks, and copy paths from that dump: ./Testing/run_test.sh Testing/screenshot_test.xml less build/screenshots/widget-tree.txt ``` + +### Selecting a row + +QtTesting's own `currentChanged` takes `row|column`. `0:0` reaches `OldGetIndex`, which +calls `.at(1)` on a one-element list and aborts the process. Select through `selectRow` +(or click through `clickRow`) instead. Never use `currentChanged`. diff --git a/Testing/deck_editor_double_click_test.xml b/Testing/deck_editor_double_click_test.xml new file mode 100644 index 000000000..c8da837d6 --- /dev/null +++ b/Testing/deck_editor_double_click_test.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + diff --git a/Testing/item_view_test.xml b/Testing/item_view_test.xml new file mode 100644 index 000000000..6a7c31ccd --- /dev/null +++ b/Testing/item_view_test.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + diff --git a/cockatrice/src/interface/pq_widget_test_event_player.cpp b/cockatrice/src/interface/pq_widget_test_event_player.cpp index 60e19541a..2d9659276 100644 --- a/cockatrice/src/interface/pq_widget_test_event_player.cpp +++ b/cockatrice/src/interface/pq_widget_test_event_player.cpp @@ -1,12 +1,17 @@ #include "pq_widget_test_event_player.h" +#include +#include #include #include #include #include +#include #include +#include #include #include +#include #include #include @@ -111,6 +116,15 @@ bool pqWidgetTestEventPlayer::playEvent(QObject *object, const QString &command, if (command == "resize") { return run(playResize(object, arguments)); } + if (command == "selectRow") { + return run(playSelectRow(object, arguments)); + } + if (command == "clickRow") { + return run(playClickRow(object, arguments, false)); + } + if (command == "doubleClickRow") { + return run(playClickRow(object, arguments, true)); + } return false; } @@ -199,6 +213,141 @@ bool pqWidgetTestEventPlayer::playResize(QObject *object, const QString &argumen return true; } +bool pqWidgetTestEventPlayer::playSelectRow(QObject *object, const QString &arguments) +{ + QAbstractItemView *view = itemViewFor(object); + if (!view) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "selectRow: target is not an item view"; + return false; + } + + QModelIndex index; + if (!resolveCell(view, arguments, index)) { + return false; + } + + view->setCurrentIndex(index); + // QtTesting's `currentChanged` crashes on a one-element row list, so select + // through the selection model. + view->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + view->scrollTo(index); + return true; +} + +bool pqWidgetTestEventPlayer::playClickRow(QObject *object, const QString &arguments, bool doubleClick) +{ + QAbstractItemView *view = itemViewFor(object); + if (!view) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "clickRow: target is not an item view"; + return false; + } + + QModelIndex index; + if (!resolveCell(view, arguments, index)) { + return false; + } + + return clickCell(view, index, doubleClick); +} + +QAbstractItemView *pqWidgetTestEventPlayer::itemViewFor(QObject *object) +{ + if (auto *view = qobject_cast(object)) { + return view; + } + // A recording names the viewport of an item view; the view is its parent. + return qobject_cast(object ? object->parent() : nullptr); +} + +bool pqWidgetTestEventPlayer::resolveCell(QAbstractItemView *view, const QString &arguments, QModelIndex &index) +{ + QAbstractItemModel *model = view->model(); + if (!model) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "item view has no model:" << view; + return false; + } + + const QStringList parts = arguments.split(QLatin1Char(',')); + if (parts.isEmpty() || parts.size() > 2) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) + << "cell must be 'row[/row...]' or 'row[/row...],column', got:" << arguments; + return false; + } + + int column = 0; + if (parts.size() == 2) { + bool okColumn = false; + column = parts.at(1).trimmed().toInt(&okColumn); + if (!okColumn || column < 0) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "invalid column in" << arguments; + return false; + } + } + + // "2/0" is the first child of the third top-level row. A tree addresses a + // row by the path of rows leading to it. + const QStringList rows = parts.at(0).split(QLatin1Char('/')); + index = QModelIndex(); + for (int depth = 0; depth < rows.size(); ++depth) { + bool okRow = false; + const int row = rows.at(depth).trimmed().toInt(&okRow); + const int columnCount = model->columnCount(index); + if (!okRow || row < 0 || row >= model->rowCount(index)) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "invalid row" << rows.at(depth) << "in" << arguments + << "- the parent has" << model->rowCount(index) << "rows"; + return false; + } + if (column >= columnCount) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) + << "invalid column" << column << "in" << arguments << "- the row has" << columnCount << "columns"; + return false; + } + + index = model->index(row, depth == rows.size() - 1 ? column : 0, index); + if (!index.isValid()) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "no model index for" << arguments; + return false; + } + } + return true; +} + +bool pqWidgetTestEventPlayer::clickCell(QAbstractItemView *view, const QModelIndex &index, bool doubleClick) +{ + // A collapsed tree has no rect for a child, so open the path to it first. + if (auto *tree = qobject_cast(view); tree && index.parent().isValid()) { + tree->expand(index.parent()); + } + view->scrollTo(index); + // scrollTo only requests the scroll, so the visual rect is stale until the + // view has laid out again. + QApplication::processEvents(); + + const QRect rect = view->visualRect(index); + if (!rect.isValid() || rect.isEmpty()) { + qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "cell" << index << "is not visible in" << view; + return false; + } + + QWidget *viewport = view->viewport(); + const QPoint pos = rect.center(); + const auto send = [viewport, &pos](QEvent::Type type) { + QMouseEvent event(type, QPointF(pos), QPointF(viewport->mapToGlobal(pos)), Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(viewport, &event); + }; + + // A double click is press/release, then the double-click event and its + // release, as a recording produces it. + send(QEvent::MouseButtonPress); + send(QEvent::MouseButtonRelease); + if (doubleClick) { + send(QEvent::MouseButtonDblClick); + send(QEvent::MouseButtonRelease); + } + return true; +} + bool pqWidgetTestEventPlayer::resolveOutputPath(const QString &command, const QString &path, QString &resolved) { if (path.isEmpty()) { diff --git a/cockatrice/src/interface/pq_widget_test_event_player.h b/cockatrice/src/interface/pq_widget_test_event_player.h index 758c2df6b..10931db6c 100644 --- a/cockatrice/src/interface/pq_widget_test_event_player.h +++ b/cockatrice/src/interface/pq_widget_test_event_player.h @@ -3,12 +3,17 @@ #include "pqWidgetEventPlayer.h" +class QAbstractItemView; +class QModelIndex; + /** * Widget-level commands for the UI tests. * * pqCockatriceEventPlayer handles cards and zones inside the game scene. This * player covers the rest of the client: capturing a widget as an image, - * dumping the paths QtTesting addresses widgets by, and sizing the window. + * dumping the paths QtTesting addresses widgets by, sizing the window, and + * driving item views, whose selection QtTesting's own `currentChanged` command + * cannot handle. * * Commands: * screenshot "" @@ -20,6 +25,17 @@ * addresses it by, for writing a test * resize "WIDTHxHEIGHT" * resizes the addressed widget's top-level window + * selectRow "row[/row...][,column]" + * selects that row of an item view, the whole row, without a mouse event + * clickRow "row[/row...][,column]" + * left-clicks the centre of that cell, so the view's own handlers run + * doubleClickRow "row[/row...][,column]" + * double-clicks it, for rows that act on double click + * + * A tree addresses a row by the path of rows leading to it: "2/0" is the first + * child of the third top-level row. A click opens a collapsed path first. + * Rows are model rows of the addressed view, so a filtered or sorted model + * counts the rows it shows. */ class pqWidgetTestEventPlayer : public pqWidgetEventPlayer { @@ -37,7 +53,15 @@ private: bool playScreenshot(QObject *object, const QString &arguments); bool playDumpTree(QObject *object, const QString &arguments); bool playResize(QObject *object, const QString &arguments); + bool playSelectRow(QObject *object, const QString &arguments); + bool playClickRow(QObject *object, const QString &arguments, bool doubleClick); + /** @brief The item view the event is addressed at, following a viewport to its view. */ + static QAbstractItemView *itemViewFor(QObject *object); + /** @brief Resolves "row[,column]" to a valid model index of @p view. */ + static bool resolveCell(QAbstractItemView *view, const QString &arguments, QModelIndex &index); + /** @brief Sends the mouse events a real click on @p index produces. */ + static bool clickCell(QAbstractItemView *view, const QModelIndex &index, bool doubleClick); /** @brief Expands $NAME / ${NAME} and creates the file's parent directory. */ static bool resolveOutputPath(const QString &command, const QString &path, QString &resolved); }; diff --git a/cockatrice/src/interface/recording.cpp b/cockatrice/src/interface/recording.cpp index 2b11255f9..9da01b3c8 100644 --- a/cockatrice/src/interface/recording.cpp +++ b/cockatrice/src/interface/recording.cpp @@ -85,6 +85,13 @@ int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arg const QXmlStreamAttributes attributes = xmlStream->attributes(); eventType = pqEventTypes::ACTION_EVENT; + // QtTesting separates what a user does (an action) from what the UI must + // show (a check), and its check players are only reached with a check + // event. Without this attribute every event is an action, so the check + // commands (`modelRowCount`, `modelItemData`) cannot be used from a test. + if (attributes.value(QStringLiteral("type")) == QLatin1String("check")) { + eventType = pqEventTypes::CHECK_EVENT; + } widget = attributes.value(QStringLiteral("widget")).toString(); command = attributes.value(QStringLiteral("command")).toString(); if (command.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 5df8db7cc..ebaeb0028 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -326,8 +326,10 @@ void DeckEditorDeckDockWidget::createDeckDock() // Create widgets for both layouts to make splitter work correctly auto *topWidget = new QWidget; + topWidget->setObjectName("deckInfoPane"); topWidget->setLayout(upperLayout); auto *bottomWidget = new QWidget; + bottomWidget->setObjectName("deckListPane"); bottomWidget->setLayout(lowerLayout); auto *split = new QSplitter;