mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-09 11:33:25 -08:00
[Tests] Capture screenshots and dump the widget tree from a playback (#7818)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 45 release (push) Blocked by required conditions
Build Desktop / Fedora 45 debug (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 45 release (push) Blocked by required conditions
Build Desktop / Fedora 45 debug (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
pqWidgetTestEventPlayer adds the widget-level commands the UI tests were
missing:
- screenshot grabs the addressed widget and saves it, creating the parent
directory and expanding $NAME / ${NAME} from the environment so the runner
decides where captures land
- dumpTree writes the widget paths the tests address, for finding the path of a
widget while writing a test
- resize sets the top-level window size, since the layout owns the children
The runner sets COCKATRICE_SCREENSHOT_DIR to build/screenshots, which is
ignored, so captures do not show up as untracked files.
Testing/screenshot_test.xml captures the main window and the settings dialog,
and Testing/README.md documents the commands, the pause/process_events split,
path drift and the capture workflow.
This commit is contained in:
parent
3d24597134
commit
3b379618c3
7 changed files with 377 additions and 14 deletions
|
|
@ -599,9 +599,12 @@ target_link_libraries(
|
|||
cockatrice_target_enable_crash_reporting(cockatrice)
|
||||
|
||||
if(WITH_GUI_TEST)
|
||||
# These two translate between XML test files and the QtTesting event pipeline;
|
||||
# they need the QtTesting headers, so they only exist with the harness.
|
||||
target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp)
|
||||
# QtTesting integration and the Cockatrice-specific playback commands. 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/pq_widget_test_event_player.cpp
|
||||
src/interface/recording.cpp
|
||||
)
|
||||
target_link_libraries(cockatrice PRIVATE QtTesting)
|
||||
target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST)
|
||||
endif()
|
||||
|
|
|
|||
223
cockatrice/src/interface/pq_widget_test_event_player.cpp
Normal file
223
cockatrice/src/interface/pq_widget_test_event_player.cpp
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
#include "pq_widget_test_event_player.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QLoggingCategory>
|
||||
#include <QPixmap>
|
||||
#include <QTextStream>
|
||||
#include <QWidget>
|
||||
#include <pqObjectNaming.h>
|
||||
|
||||
Q_LOGGING_CATEGORY(PQ_WIDGET_TEST_EVENT_PLAYER, "cockatrice.guiTest.widgetEventPlayer")
|
||||
|
||||
namespace
|
||||
{
|
||||
void dumpSubtree(QObject *object, QTextStream &out, int depth)
|
||||
{
|
||||
if (auto *widget = qobject_cast<QWidget *>(object)) {
|
||||
const QString path = pqObjectNaming::GetName(*widget);
|
||||
if (!path.isEmpty()) {
|
||||
out << QString(depth * 2, ' ') << path << " [" << widget->metaObject()->className() << "]";
|
||||
if (!widget->objectName().isEmpty()) {
|
||||
out << " objectName=" << widget->objectName();
|
||||
}
|
||||
out << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
// children() returns a snapshot, so the recursion below cannot invalidate it.
|
||||
const QObjectList children = object->children();
|
||||
for (QObject *child : children) {
|
||||
dumpSubtree(child, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands $NAME and ${NAME} from the environment.
|
||||
*
|
||||
* A test file is static, so this lets the runner send screenshots to a scratch
|
||||
* directory instead of hardcoding a path that only exists on one machine. An
|
||||
* unset variable is an error, not an empty string: writing to the working
|
||||
* directory would look like a pass.
|
||||
*/
|
||||
bool expandEnvironment(const QString &command, const QString &path, QString &expanded)
|
||||
{
|
||||
expanded.clear();
|
||||
for (int i = 0; i < path.size(); ++i) {
|
||||
if (path.at(i) != QLatin1Char('$')) {
|
||||
expanded += path.at(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
int nameStart = i + 1;
|
||||
int nameEnd = nameStart;
|
||||
if (nameStart < path.size() && path.at(nameStart) == QLatin1Char('{')) {
|
||||
nameStart++;
|
||||
nameEnd = path.indexOf(QLatin1Char('}'), nameStart);
|
||||
if (nameEnd < 0) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": unterminated ${ in path" << path;
|
||||
return false;
|
||||
}
|
||||
i = nameEnd;
|
||||
} else {
|
||||
while (nameEnd < path.size() &&
|
||||
(path.at(nameEnd).isLetterOrNumber() || path.at(nameEnd) == QLatin1Char('_'))) {
|
||||
nameEnd++;
|
||||
}
|
||||
i = nameEnd - 1;
|
||||
}
|
||||
|
||||
const QString name = path.mid(nameStart, nameEnd - nameStart);
|
||||
if (name.isEmpty()) {
|
||||
expanded += QLatin1Char('$');
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString value = qEnvironmentVariable(name.toUtf8().constData());
|
||||
if (value.isEmpty()) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": environment variable" << name << "is not set";
|
||||
return false;
|
||||
}
|
||||
expanded += value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
pqWidgetTestEventPlayer::pqWidgetTestEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
|
||||
{
|
||||
}
|
||||
|
||||
bool pqWidgetTestEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
|
||||
{
|
||||
// A command we know that fails is still a handled event: QtTesting logs
|
||||
// "Unhandled event" when playEvent returns false, so set `error` and return
|
||||
// true, as pqCockatriceEventPlayer does.
|
||||
const auto run = [&error](bool ok) {
|
||||
if (!ok) {
|
||||
error = true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (command == "screenshot") {
|
||||
return run(playScreenshot(object, arguments));
|
||||
}
|
||||
if (command == "dumpTree") {
|
||||
return run(playDumpTree(object, arguments));
|
||||
}
|
||||
if (command == "resize") {
|
||||
return run(playResize(object, arguments));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool pqWidgetTestEventPlayer::playScreenshot(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *widget = qobject_cast<QWidget *>(object);
|
||||
if (!widget) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "screenshot: target is not a QWidget";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString path;
|
||||
if (!resolveOutputPath(QStringLiteral("screenshot"), arguments.trimmed(), path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// repaint() is synchronous, even on the offscreen platform, so the grab
|
||||
// contains what the previous events drew.
|
||||
widget->repaint();
|
||||
const QPixmap pixmap = widget->grab();
|
||||
if (pixmap.isNull()) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "screenshot: grab returned a null pixmap for" << widget;
|
||||
return false;
|
||||
}
|
||||
if (!pixmap.save(path)) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER)
|
||||
<< "screenshot: could not save" << path << "- an image format is chosen from the file extension";
|
||||
return false;
|
||||
}
|
||||
|
||||
qCInfo(PQ_WIDGET_TEST_EVENT_PLAYER) << "screenshot saved to" << path;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqWidgetTestEventPlayer::playDumpTree(QObject *object, const QString &arguments)
|
||||
{
|
||||
if (!qobject_cast<QWidget *>(object)) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "dumpTree: target is not a QWidget";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString path;
|
||||
if (!resolveOutputPath(QStringLiteral("dumpTree"), arguments.trimmed(), path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "dumpTree: could not open" << path << file.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
QTextStream out(&file);
|
||||
dumpSubtree(object, out, 0);
|
||||
|
||||
qCInfo(PQ_WIDGET_TEST_EVENT_PLAYER) << "widget tree dumped to" << path;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqWidgetTestEventPlayer::playResize(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *widget = qobject_cast<QWidget *>(object);
|
||||
if (!widget) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "resize: target is not a QWidget";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList parts = arguments.split(QLatin1Char('x'));
|
||||
if (parts.size() != 2) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "resize: arguments must be 'WIDTHxHEIGHT', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool okWidth = false;
|
||||
bool okHeight = false;
|
||||
const int width = parts.at(0).trimmed().toInt(&okWidth);
|
||||
const int height = parts.at(1).trimmed().toInt(&okHeight);
|
||||
if (!okWidth || !okHeight || width <= 0 || height <= 0) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "resize: invalid size" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resize the top-level window: the layout owns the children.
|
||||
widget->window()->resize(width, height);
|
||||
QApplication::processEvents();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqWidgetTestEventPlayer::resolveOutputPath(const QString &command, const QString &path, QString &resolved)
|
||||
{
|
||||
if (path.isEmpty()) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": output path is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString expanded;
|
||||
if (!expandEnvironment(command, path, expanded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QFileInfo info(expanded);
|
||||
const QString directory = info.absolutePath();
|
||||
if (!QDir().mkpath(directory)) {
|
||||
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": could not create directory" << directory;
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved = info.absoluteFilePath();
|
||||
return true;
|
||||
}
|
||||
45
cockatrice/src/interface/pq_widget_test_event_player.h
Normal file
45
cockatrice/src/interface/pq_widget_test_event_player.h
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#ifndef PQ_WIDGET_TEST_EVENT_PLAYER_H
|
||||
#define PQ_WIDGET_TEST_EVENT_PLAYER_H
|
||||
|
||||
#include "pqWidgetEventPlayer.h"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Commands:
|
||||
* screenshot "<path>"
|
||||
* grabs the addressed widget and saves it as an image. Parent directories
|
||||
* are created, and $NAME / ${NAME} in the path expand from the
|
||||
* environment, so the runner decides where captures land
|
||||
* dumpTree "<path>"
|
||||
* writes the addressed widget's subtree with the paths QtTesting
|
||||
* addresses it by, for writing a test
|
||||
* resize "WIDTHxHEIGHT"
|
||||
* resizes the addressed widget's top-level window
|
||||
*/
|
||||
class pqWidgetTestEventPlayer : public pqWidgetEventPlayer
|
||||
{
|
||||
Q_OBJECT
|
||||
typedef pqWidgetEventPlayer Superclass;
|
||||
|
||||
public:
|
||||
explicit pqWidgetTestEventPlayer(QObject *parent = nullptr);
|
||||
|
||||
using Superclass::playEvent;
|
||||
|
||||
bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override;
|
||||
|
||||
private:
|
||||
bool playScreenshot(QObject *object, const QString &arguments);
|
||||
bool playDumpTree(QObject *object, const QString &arguments);
|
||||
bool playResize(QObject *object, const QString &arguments);
|
||||
|
||||
/** @brief Expands $NAME / ${NAME} and creates the file's parent directory. */
|
||||
static bool resolveOutputPath(const QString &command, const QString &path, QString &resolved);
|
||||
};
|
||||
|
||||
#endif // PQ_WIDGET_TEST_EVENT_PLAYER_H
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
#include "pqEventSource.h"
|
||||
#include "pqEventTypes.h"
|
||||
#include "pqTestUtility.h"
|
||||
#include "pq_widget_test_event_player.h"
|
||||
#include "recording.h"
|
||||
#endif
|
||||
|
||||
|
|
@ -692,6 +693,7 @@ MainWindow::MainWindow(QWidget *parent)
|
|||
testUtility->addEventObserver("xml", new XMLEventObserver(this));
|
||||
testUtility->addEventSource("xml", new XMLEventSource(this));
|
||||
testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this));
|
||||
testUtility->eventPlayer()->addWidgetEventPlayer(new pqWidgetTestEventPlayer(this));
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue