[Tests] Drive item views from a playback (#7819)

QtTesting's own `currentChanged` command aborts on a one-element row list, so
pqWidgetTestEventPlayer drives views itself:

- selectRow selects a row through the selection model, the whole row, without a
  mouse event
- clickRow left-clicks the centre of a cell, so the view's own handlers run
- doubleClickRow double-clicks it, for views whose double click acts

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), and a click opens a collapsed path first.
Rows are model rows, so a filtered or sorted model counts the rows it shows.

Asserting what a command did needs QtTesting's check players, which the XML
source never reached: it marked every event an action, so `modelRowCount` was
rejected as unknown. An event now carries `type="check"` to be played as a
check.

Testing/item_view_test.xml drives the grouped settings navigation through
selectRow and clickRow and asserts the page stack followed.
Testing/deck_editor_double_click_test.xml proves the difference between the two
clicks: a single click on a card database row only selects it, a double click
adds the card to the deck.
This commit is contained in:
BruebachL 2026-11-10 09:12:10 +01:00 • committed by GitHub
parent f089414f1c
commit 0498ea8907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 279 additions and 3 deletions

View file

@ -66,14 +66,34 @@ A test is an `<events>` 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 | | `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 | | `dumpTree` | `path` | writes the addressed widget's subtree with the paths tests address |
| `resize` | `WIDTHxHEIGHT` | resizes the addressed widget's window | | `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` The card, item and property commands are `pqCockatriceEventPlayer`
(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name (`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 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. viewport position, so a test does not depend on the window size or screen resolution.
The capture commands are `pqWidgetTestEventPlayer` The capture and row commands are `pqWidgetTestEventPlayer`
(`cockatrice/src/interface/pq_widget_test_event_player.h`). (`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
<event widget="MainWindow/.../deckView" command="modelRowCount" arguments="1" type="check" />
```
`modelRowCount` compares `model()->rowCount()`. `modelItemData` compares the data of one
cell, addressed as `row.column,text` (`modelItemData "0.0,Lightning Bolt"`).
## Screenshots ## 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 ./Testing/run_test.sh Testing/screenshot_test.xml
less build/screenshots/widget-tree.txt 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`.

View file

@ -0,0 +1,34 @@
<events>
<!--
A single click and a double click differ, and only a double click acts.
The card database view adds the card under the mouse to the deck on
double click (CardDatabaseView::actDoubleClick); a single click only
moves the current row and updates the preview. Asserting the deck's row
count after each proves the MouseButtonDblClick event really reaches the
view, which a page-index assertion in the settings tree cannot show.
The visual deck editor shows its cards in a picture grid, which is not an
item view, so the addressable list is the one in its card database dock.
Which card lands in the deck does not matter: the test asserts that a
click adds nothing and a double click adds one.
Assertions carry type="check": QtTesting only reaches its check players
with a check event.
-->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeActionsScrollArea/qt_scrollarea_viewport/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow" command="process_events" arguments="1500" />
<!-- A fresh deck starts empty -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/deckDock/deckDockContents/deckSplitter/deckListPane/deckView" command="modelRowCount" arguments="0" type="check" />
<!-- A single click only selects the card -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/databaseView" command="clickRow" arguments="0" />
<event widget="MainWindow" command="process_events" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/deckDock/deckDockContents/deckSplitter/deckListPane/deckView" command="modelRowCount" arguments="0" type="check" />
<!-- A double click adds the card -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/databaseView" command="doubleClickRow" arguments="0" />
<event widget="MainWindow" command="process_events" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/deckDock/deckDockContents/deckSplitter/deckListPane/deckView" command="modelRowCount" arguments="1" type="check" />
</events>

View file

@ -0,0 +1,34 @@
<events>
<!--
Item-view driving through a playback.
QtTesting's own `currentChanged` command aborts on a one-element row
list, so rows are selected through the view's selection model or clicked
with real mouse events instead.
The settings navigation tree is grouped, so a page is addressed as
"group/page": the groups are Application, Cards &amp; Data, Chat &amp;
Sound and Advanced, and the page stack follows the page order
General(0) ... Shortcuts(7). Selecting a group header does nothing — the
headers are not selectable.
Double click is covered by deck_editor_double_click_test.xml, where it
has an effect a single click does not have.
-->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,0" />
<!-- Chat &amp; Sound group, second page: Sound -->
<event widget="MainWindow/settingsDialog/settingsView/navTree" command="selectRow" arguments="2/1" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,6" />
<!-- A click goes through the view rather than the selection model: Deck Editor -->
<event widget="MainWindow/settingsDialog/settingsView/navTree" command="clickRow" arguments="1/0" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,3" />
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
</events>

View file

@ -1,12 +1,17 @@
#include "pq_widget_test_event_player.h" #include "pq_widget_test_event_player.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QApplication> #include <QApplication>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QItemSelectionModel>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QMouseEvent>
#include <QPixmap> #include <QPixmap>
#include <QTextStream> #include <QTextStream>
#include <QTreeView>
#include <QWidget> #include <QWidget>
#include <pqObjectNaming.h> #include <pqObjectNaming.h>
@ -111,6 +116,15 @@ bool pqWidgetTestEventPlayer::playEvent(QObject *object, const QString &command,
if (command == "resize") { if (command == "resize") {
return run(playResize(object, arguments)); 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; return false;
} }
@ -199,6 +213,141 @@ bool pqWidgetTestEventPlayer::playResize(QObject *object, const QString &argumen
return true; 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<QAbstractItemView *>(object)) {
return view;
}
// A recording names the viewport of an item view; the view is its parent.
return qobject_cast<QAbstractItemView *>(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<QTreeView *>(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) bool pqWidgetTestEventPlayer::resolveOutputPath(const QString &command, const QString &path, QString &resolved)
{ {
if (path.isEmpty()) { if (path.isEmpty()) {

View file

@ -3,12 +3,17 @@
#include "pqWidgetEventPlayer.h" #include "pqWidgetEventPlayer.h"
class QAbstractItemView;
class QModelIndex;
/** /**
* Widget-level commands for the UI tests. * Widget-level commands for the UI tests.
* *
* pqCockatriceEventPlayer handles cards and zones inside the game scene. This * pqCockatriceEventPlayer handles cards and zones inside the game scene. This
* player covers the rest of the client: capturing a widget as an image, * 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: * Commands:
* screenshot "<path>" * screenshot "<path>"
@ -20,6 +25,17 @@
* addresses it by, for writing a test * addresses it by, for writing a test
* resize "WIDTHxHEIGHT" * resize "WIDTHxHEIGHT"
* resizes the addressed widget's top-level window * 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 class pqWidgetTestEventPlayer : public pqWidgetEventPlayer
{ {
@ -37,7 +53,15 @@ private:
bool playScreenshot(QObject *object, const QString &arguments); bool playScreenshot(QObject *object, const QString &arguments);
bool playDumpTree(QObject *object, const QString &arguments); bool playDumpTree(QObject *object, const QString &arguments);
bool playResize(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. */ /** @brief Expands $NAME / ${NAME} and creates the file's parent directory. */
static bool resolveOutputPath(const QString &command, const QString &path, QString &resolved); static bool resolveOutputPath(const QString &command, const QString &path, QString &resolved);
}; };

View file

@ -85,6 +85,13 @@ int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arg
const QXmlStreamAttributes attributes = xmlStream->attributes(); const QXmlStreamAttributes attributes = xmlStream->attributes();
eventType = pqEventTypes::ACTION_EVENT; 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(); widget = attributes.value(QStringLiteral("widget")).toString();
command = attributes.value(QStringLiteral("command")).toString(); command = attributes.value(QStringLiteral("command")).toString();
if (command.isEmpty()) { if (command.isEmpty()) {

View file

@ -326,8 +326,10 @@ void DeckEditorDeckDockWidget::createDeckDock()
// Create widgets for both layouts to make splitter work correctly // Create widgets for both layouts to make splitter work correctly
auto *topWidget = new QWidget; auto *topWidget = new QWidget;
topWidget->setObjectName("deckInfoPane");
topWidget->setLayout(upperLayout); topWidget->setLayout(upperLayout);
auto *bottomWidget = new QWidget; auto *bottomWidget = new QWidget;
bottomWidget->setObjectName("deckListPane");
bottomWidget->setLayout(lowerLayout); bottomWidget->setLayout(lowerLayout);
auto *split = new QSplitter; auto *split = new QSplitter;