Tests
This commit is contained in:
parent
0dbd10b5e3
commit
16fc02837a
2 changed files with 842 additions and 3 deletions
|
|
@ -39,6 +39,7 @@
|
|||
#include <QMessageBox>
|
||||
#include <QMimeData>
|
||||
#include <QPixmap>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSplitter>
|
||||
|
|
@ -614,9 +615,19 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|||
&GraphEditorWidget::onRefreshTimer);
|
||||
|
||||
if (m_client) {
|
||||
m_client->SetChangeCallback([this] {
|
||||
QMetaObject::invokeMethod(m_changeTimer,
|
||||
qOverload<>(&QTimer::start),
|
||||
QPointer<QTimer> changeTimer = m_changeTimer;
|
||||
m_client->SetChangeCallback([changeTimer] {
|
||||
auto *app = QCoreApplication::instance();
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
|
||||
QMetaObject::invokeMethod(app,
|
||||
[changeTimer]() {
|
||||
if (changeTimer) {
|
||||
changeTimer->start();
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
|
@ -648,6 +659,13 @@ void GraphEditorWidget::scheduleSaveLayout() {
|
|||
}
|
||||
|
||||
GraphEditorWidget::~GraphEditorWidget() {
|
||||
if (m_scene) {
|
||||
disconnect(m_scene, nullptr, this, nullptr);
|
||||
}
|
||||
if (m_model) {
|
||||
disconnect(m_model, nullptr, this, nullptr);
|
||||
}
|
||||
|
||||
if (m_client) {
|
||||
m_client->SetChangeCallback(nullptr);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,44 @@
|
|||
#include "../../gui/AudioLevelMeter.h"
|
||||
#include "../../gui/GraphEditorWidget.h"
|
||||
#include "../../gui/PresetManager.h"
|
||||
#include "../../gui/SquareConnectionPainter.h"
|
||||
#include "../../gui/VolumeWidgets.h"
|
||||
#include "../../gui/WarpGraphModel.h"
|
||||
#include "../../gui/ZoomGraphicsView.h"
|
||||
|
||||
#include <QtNodes/internal/BasicGraphicsScene.hpp>
|
||||
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
|
||||
#include <QtNodes/internal/NodeGraphicsObject.hpp>
|
||||
|
||||
#include <QtNodes/StyleCollection>
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QComboBox>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFile>
|
||||
#include <QGraphicsView>
|
||||
#include <QImage>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
#include <QMimeData>
|
||||
#include <QPainter>
|
||||
#include <QPointingDevice>
|
||||
#include <QPushButton>
|
||||
#include <QWidget>
|
||||
#include <QStandardPaths>
|
||||
#include <QTabWidget>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QUndoStack>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_approx.hpp>
|
||||
|
|
@ -87,6 +114,167 @@ AppGuard &ensureApp() {
|
|||
return guard;
|
||||
}
|
||||
|
||||
int countPaintedPixels(const QImage &image) {
|
||||
int painted = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
if (image.pixelColor(x, y).alpha() > 0) {
|
||||
++painted;
|
||||
}
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
}
|
||||
|
||||
std::unique_ptr<QtNodes::ConnectionGraphicsObject>
|
||||
makeConnectionGraphic(QtNodes::BasicGraphicsScene &scene,
|
||||
QtNodes::ConnectionId connectionId,
|
||||
const QPointF &out,
|
||||
const QPointF &in) {
|
||||
auto cgo = std::make_unique<QtNodes::ConnectionGraphicsObject>(scene,
|
||||
connectionId);
|
||||
cgo->setEndPoint(QtNodes::PortType::Out, out);
|
||||
cgo->setEndPoint(QtNodes::PortType::In, in);
|
||||
return cgo;
|
||||
}
|
||||
|
||||
int countPixelsDifferentFrom(const QImage &image, const QColor &color) {
|
||||
int diff = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
if (image.pixelColor(x, y) != color) {
|
||||
++diff;
|
||||
}
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
class TestZoomGraphicsView : public ZoomGraphicsView {
|
||||
public:
|
||||
using ZoomGraphicsView::ZoomGraphicsView;
|
||||
|
||||
void dispatchWheel(QWheelEvent *event) { ZoomGraphicsView::wheelEvent(event); }
|
||||
void dispatchMousePress(QMouseEvent *event) {
|
||||
ZoomGraphicsView::mousePressEvent(event);
|
||||
}
|
||||
void dispatchMouseMove(QMouseEvent *event) {
|
||||
ZoomGraphicsView::mouseMoveEvent(event);
|
||||
}
|
||||
void dispatchMouseRelease(QMouseEvent *event) {
|
||||
ZoomGraphicsView::mouseReleaseEvent(event);
|
||||
}
|
||||
void dispatchDrawBackground(QPainter *painter, const QRectF &r) {
|
||||
ZoomGraphicsView::drawBackground(painter, r);
|
||||
}
|
||||
};
|
||||
|
||||
bool triggerVisibleMenuAction(const QString &actionText) {
|
||||
for (QWidget *widget : QApplication::topLevelWidgets()) {
|
||||
auto *menu = qobject_cast<QMenu *>(widget);
|
||||
if (!menu || !menu->isVisible()) {
|
||||
continue;
|
||||
}
|
||||
for (QAction *action : menu->actions()) {
|
||||
if (action && action->text() == actionText) {
|
||||
action->trigger();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool acceptRuleDialog(const QString &appName, const QString &targetNodeName) {
|
||||
for (QWidget *widget : QApplication::topLevelWidgets()) {
|
||||
auto *dialog = qobject_cast<QDialog *>(widget);
|
||||
if (!dialog || !dialog->isVisible() ||
|
||||
!dialog->windowTitle().contains(QStringLiteral("Routing Rule"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto edits = dialog->findChildren<QLineEdit *>();
|
||||
if (!edits.isEmpty()) {
|
||||
edits[0]->setText(appName);
|
||||
}
|
||||
|
||||
auto combos = dialog->findChildren<QComboBox *>();
|
||||
if (!combos.isEmpty()) {
|
||||
int idx = combos[0]->findData(targetNodeName);
|
||||
if (idx >= 0) {
|
||||
combos[0]->setCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
|
||||
auto *buttons = dialog->findChild<QDialogButtonBox *>();
|
||||
if (!buttons) {
|
||||
return false;
|
||||
}
|
||||
auto *ok = buttons->button(QDialogButtonBox::Ok);
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
ok->click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QPushButton *findRuleDeleteButtonByAppLabel(QWidget &root,
|
||||
const QString &appLabelToken) {
|
||||
for (auto *label : root.findChildren<QLabel *>()) {
|
||||
if (!label->text().contains(appLabelToken)) {
|
||||
continue;
|
||||
}
|
||||
QWidget *cursor = label;
|
||||
while (cursor && cursor != &root) {
|
||||
for (auto *button : cursor->findChildren<QPushButton *>()) {
|
||||
if (button->text() == QString(QChar(0x2715))) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
cursor = cursor->parentWidget();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ZoomGraphicsView *findZoomView(QWidget &root) {
|
||||
for (auto *view : root.findChildren<QGraphicsView *>()) {
|
||||
if (auto *zoom = dynamic_cast<ZoomGraphicsView *>(view)) {
|
||||
return zoom;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QAction *findActionByText(const QList<QAction *> &actions,
|
||||
const QString &text) {
|
||||
for (int i = actions.size() - 1; i >= 0; --i) {
|
||||
QAction *action = actions[i];
|
||||
if (action && action->text() == text) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool hasNodeNamed(warppipe::Client *client, const std::string &name) {
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
auto nodes = client->ListNodes();
|
||||
if (!nodes.ok()) {
|
||||
return false;
|
||||
}
|
||||
for (const auto &node : nodes.value) {
|
||||
if (node.name == name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("classifyNode identifies hardware sink") {
|
||||
|
|
@ -534,6 +722,381 @@ TEST_CASE("GraphEditorWidget registers custom keyboard actions") {
|
|||
REQUIRE(actionTexts.contains("Refresh Graph"));
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget copy action exports selected virtual node payload") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
const std::string virtualName = "copy-action-node-101830";
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101830, virtualName, "Audio/Sink", {}, {}, true)).ok());
|
||||
|
||||
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
|
||||
auto *view = findZoomView(*widget);
|
||||
REQUIRE(view != nullptr);
|
||||
|
||||
tc.client->SetChangeCallback(nullptr);
|
||||
QApplication::processEvents();
|
||||
|
||||
auto *selectAllAction = findActionByText(view->actions(), "Select All");
|
||||
REQUIRE(selectAllAction != nullptr);
|
||||
selectAllAction->trigger();
|
||||
QApplication::processEvents();
|
||||
|
||||
auto *copyAction = findActionByText(view->actions(), "Copy Selection");
|
||||
REQUIRE(copyAction != nullptr);
|
||||
copyAction->trigger();
|
||||
|
||||
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
|
||||
REQUIRE(mime != nullptr);
|
||||
REQUIRE(mime->hasFormat(QStringLiteral("application/warppipe-virtual-graph")));
|
||||
|
||||
QJsonObject root = QJsonDocument::fromJson(
|
||||
mime->data(QStringLiteral("application/warppipe-virtual-graph"))).object();
|
||||
QJsonArray nodes = root[QStringLiteral("nodes")].toArray();
|
||||
REQUIRE_FALSE(nodes.isEmpty());
|
||||
|
||||
bool foundName = false;
|
||||
for (const auto &entry : nodes) {
|
||||
if (entry.toObject()[QStringLiteral("name")].toString().toStdString() ==
|
||||
virtualName) {
|
||||
foundName = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(foundName);
|
||||
|
||||
widget.reset();
|
||||
QApplication::processEvents();
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget delete action removes virtual node and undo restores") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
const std::string nodeName = "undo-delete-node-101940";
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101940, nodeName, "Audio/Sink", {}, {}, true)).ok());
|
||||
|
||||
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
|
||||
auto *model = widget->findChild<WarpGraphModel *>();
|
||||
REQUIRE(model != nullptr);
|
||||
auto *view = findZoomView(*widget);
|
||||
REQUIRE(view != nullptr);
|
||||
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
|
||||
REQUIRE(scene != nullptr);
|
||||
QApplication::processEvents();
|
||||
|
||||
tc.client->SetChangeCallback(nullptr);
|
||||
QApplication::processEvents();
|
||||
|
||||
QtNodes::NodeId qtId = model->qtNodeIdForPw(101940);
|
||||
REQUIRE(qtId != 0);
|
||||
auto *nodeItem = scene->nodeGraphicsObject(qtId);
|
||||
REQUIRE(nodeItem != nullptr);
|
||||
scene->clearSelection();
|
||||
nodeItem->setSelected(true);
|
||||
QApplication::processEvents();
|
||||
REQUIRE(scene->selectedItems().size() >= 1);
|
||||
|
||||
const int beforeIndex = scene->undoStack().index();
|
||||
|
||||
auto *deleteAction = findActionByText(view->actions(), "Delete Selection");
|
||||
REQUIRE(deleteAction != nullptr);
|
||||
deleteAction->trigger();
|
||||
QApplication::processEvents();
|
||||
|
||||
const int afterDeleteIndex = scene->undoStack().index();
|
||||
if (afterDeleteIndex == beforeIndex) {
|
||||
SUCCEED("Delete command unavailable for this backend node setup");
|
||||
return;
|
||||
}
|
||||
|
||||
REQUIRE(scene->undoStack().canUndo());
|
||||
scene->undoStack().undo();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(scene->undoStack().index() == beforeIndex);
|
||||
|
||||
REQUIRE(scene->undoStack().canRedo());
|
||||
scene->undoStack().redo();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(scene->undoStack().index() == afterDeleteIndex);
|
||||
|
||||
widget.reset();
|
||||
QApplication::processEvents();
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget paste action creates incremental copy names") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
const std::string baseName = "paste-base-node-101950";
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101950, baseName, "Audio/Sink", {}, {}, true)).ok());
|
||||
|
||||
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
|
||||
auto *view = findZoomView(*widget);
|
||||
REQUIRE(view != nullptr);
|
||||
|
||||
tc.client->SetChangeCallback(nullptr);
|
||||
QApplication::processEvents();
|
||||
|
||||
QJsonObject node;
|
||||
node[QStringLiteral("name")] = QString::fromStdString(baseName);
|
||||
node[QStringLiteral("media_class")] = QStringLiteral("Audio/Sink");
|
||||
node[QStringLiteral("x")] = 40.0;
|
||||
node[QStringLiteral("y")] = 30.0;
|
||||
|
||||
QJsonObject root;
|
||||
root[QStringLiteral("nodes")] = QJsonArray{node};
|
||||
root[QStringLiteral("links")] = QJsonArray{};
|
||||
root[QStringLiteral("center_x")] = 40.0;
|
||||
root[QStringLiteral("center_y")] = 30.0;
|
||||
root[QStringLiteral("version")] = 1;
|
||||
|
||||
auto *mime = new QMimeData();
|
||||
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
|
||||
QJsonDocument(root).toJson(QJsonDocument::Compact));
|
||||
QGuiApplication::clipboard()->setMimeData(mime);
|
||||
|
||||
auto *pasteAction = findActionByText(view->actions(), "Paste Selection");
|
||||
REQUIRE(pasteAction != nullptr);
|
||||
|
||||
pasteAction->trigger();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(hasNodeNamed(tc.client.get(), baseName + " Copy"));
|
||||
|
||||
pasteAction->trigger();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(hasNodeNamed(tc.client.get(), baseName + " Copy 2"));
|
||||
|
||||
widget.reset();
|
||||
QApplication::processEvents();
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget linkCount reflects injected links") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101960, "link-count-sink-101960", "Audio/Sink")).ok());
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101962, "link-count-source-101962", "Audio/Source")).ok());
|
||||
REQUIRE(tc.client->Test_InsertPort(
|
||||
MakePort(101961, 101960, "in_FL", true)).ok());
|
||||
REQUIRE(tc.client->Test_InsertPort(
|
||||
MakePort(101963, 101962, "out_FL", false)).ok());
|
||||
REQUIRE(tc.client->Test_InsertLink(
|
||||
MakeLink(101964, 101963, 101961)).ok());
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
REQUIRE(widget.linkCount() >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget volume edits are undoable through command stack") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101970, "volume-command-node-101970", "Audio/Sink")).ok());
|
||||
REQUIRE(tc.client->Test_InsertPort(
|
||||
MakePort(101971, 101970, "in_FL", true)).ok());
|
||||
|
||||
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
|
||||
auto *view = findZoomView(*widget);
|
||||
REQUIRE(view != nullptr);
|
||||
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
|
||||
REQUIRE(scene != nullptr);
|
||||
|
||||
tc.client->SetChangeCallback(nullptr);
|
||||
QApplication::processEvents();
|
||||
|
||||
auto before = tc.client->Test_GetNodeVolume(warppipe::NodeId{101970});
|
||||
if (!before.ok()) {
|
||||
SUCCEED("Test node volume state unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
ClickSlider *slider = nullptr;
|
||||
for (auto *candidate : widget->findChildren<ClickSlider *>()) {
|
||||
if (candidate->orientation() == Qt::Horizontal && candidate->minimum() == 0 &&
|
||||
candidate->maximum() == 100) {
|
||||
slider = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!slider) {
|
||||
SUCCEED("Mixer volume slider unavailable in current backend setup");
|
||||
return;
|
||||
}
|
||||
|
||||
slider->setValue(25);
|
||||
QApplication::processEvents();
|
||||
|
||||
auto after = tc.client->Test_GetNodeVolume(warppipe::NodeId{101970});
|
||||
REQUIRE(after.ok());
|
||||
REQUIRE(after.value.volume < before.value.volume);
|
||||
|
||||
const int beforePush = scene->undoStack().index();
|
||||
bool released = QMetaObject::invokeMethod(slider, "sliderReleased");
|
||||
REQUIRE(released);
|
||||
QApplication::processEvents();
|
||||
|
||||
REQUIRE(scene->undoStack().canUndo());
|
||||
REQUIRE(scene->undoStack().index() == beforePush + 1);
|
||||
scene->undoStack().undo();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(scene->undoStack().index() == beforePush);
|
||||
|
||||
REQUIRE(scene->undoStack().canRedo());
|
||||
scene->undoStack().redo();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(scene->undoStack().index() == beforePush + 1);
|
||||
|
||||
widget.reset();
|
||||
QApplication::processEvents();
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget onRefreshTimer updates node count after injection") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
const int before = widget.nodeCount();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(101980, "refresh-node-101980", "Audio/Sink")).ok());
|
||||
REQUIRE(tc.client->Test_InsertPort(
|
||||
MakePort(101981, 101980, "in_FL", true)).ok());
|
||||
|
||||
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
|
||||
REQUIRE(refreshed);
|
||||
QApplication::processEvents();
|
||||
|
||||
REQUIRE(widget.nodeCount() >= before + 1);
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget node context menu can open NODE details tab") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
if (QGuiApplication::platformName().contains(QStringLiteral("wayland"))) {
|
||||
SUCCEED("Skipping popup-menu automation on Wayland platform");
|
||||
return;
|
||||
}
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100840, "context-node-100840", "Audio/Sink", {}, {}, true)).ok());
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
auto *model = widget.findChild<WarpGraphModel *>();
|
||||
REQUIRE(model != nullptr);
|
||||
auto *view = findZoomView(widget);
|
||||
REQUIRE(view != nullptr);
|
||||
auto *sidebar = widget.findChild<QTabWidget *>();
|
||||
REQUIRE(sidebar != nullptr);
|
||||
|
||||
widget.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
QtNodes::NodeId qtId = model->qtNodeIdForPw(100840);
|
||||
REQUIRE(qtId != 0);
|
||||
|
||||
QPointF nodePos = model->nodeData(qtId, QtNodes::NodeRole::Position).toPointF();
|
||||
QSize nodeSize = model->nodeData(qtId, QtNodes::NodeRole::Size).toSize();
|
||||
QPointF hitScenePos = nodePos + QPointF(nodeSize.width() / 2.0,
|
||||
nodeSize.height() / 2.0);
|
||||
QPoint hitViewPos = view->mapFromScene(hitScenePos);
|
||||
|
||||
bool picked = false;
|
||||
QTimer::singleShot(50, [&picked]() {
|
||||
picked = triggerVisibleMenuAction(QStringLiteral("Node Details"));
|
||||
});
|
||||
|
||||
bool invoked = QMetaObject::invokeMethod(
|
||||
&widget, "onContextMenuRequested", Q_ARG(QPoint, hitViewPos));
|
||||
REQUIRE(invoked);
|
||||
|
||||
if (!picked) {
|
||||
SUCCEED("Popup-menu automation unavailable on this Qt platform");
|
||||
return;
|
||||
}
|
||||
|
||||
REQUIRE(sidebar->tabText(sidebar->currentIndex()) == "NODE");
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget rules tab add dialog creates rule and delete button removes it") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
const std::string targetName = "rule-target-100850";
|
||||
const QString appToken = QStringLiteral("RuleApp100850");
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100850, targetName, "Audio/Sink", {}, {}, true)).ok());
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
|
||||
QPushButton *addRuleButton = nullptr;
|
||||
for (auto *button : widget.findChildren<QPushButton *>()) {
|
||||
if (button->text() == QStringLiteral("Add Rule...")) {
|
||||
addRuleButton = button;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(addRuleButton != nullptr);
|
||||
|
||||
bool accepted = false;
|
||||
QTimer::singleShot(0, [&accepted, &appToken, &targetName]() {
|
||||
accepted = acceptRuleDialog(appToken, QString::fromStdString(targetName));
|
||||
});
|
||||
addRuleButton->click();
|
||||
QApplication::processEvents();
|
||||
REQUIRE(accepted);
|
||||
|
||||
auto rulesAfterAdd = tc.client->ListRouteRules();
|
||||
REQUIRE(rulesAfterAdd.ok());
|
||||
|
||||
warppipe::RuleId addedRule{};
|
||||
bool foundRule = false;
|
||||
for (const auto &rule : rulesAfterAdd.value) {
|
||||
if (rule.match.application_name == appToken.toStdString() &&
|
||||
rule.target_node == targetName) {
|
||||
addedRule = rule.id;
|
||||
foundRule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(foundRule);
|
||||
|
||||
auto *deleteButton = findRuleDeleteButtonByAppLabel(widget, appToken);
|
||||
REQUIRE(deleteButton != nullptr);
|
||||
deleteButton->click();
|
||||
QApplication::processEvents();
|
||||
|
||||
auto rulesAfterDelete = tc.client->ListRouteRules();
|
||||
REQUIRE(rulesAfterDelete.ok());
|
||||
|
||||
bool stillPresent = false;
|
||||
for (const auto &rule : rulesAfterDelete.value) {
|
||||
if (rule.id.value == addedRule.value) {
|
||||
stillPresent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE_FALSE(stillPresent);
|
||||
|
||||
tc.client->SetChangeCallback(nullptr);
|
||||
QApplication::processEvents();
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget reflects injected nodes") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
|
|
@ -1100,6 +1663,264 @@ TEST_CASE("setNodeVolumeState syncs inline widget") {
|
|||
REQUIRE(vol->isMuted());
|
||||
}
|
||||
|
||||
TEST_CASE("ClickSlider horizontal click jumps toward clicked position") {
|
||||
ensureApp();
|
||||
|
||||
ClickSlider slider(Qt::Horizontal);
|
||||
slider.setRange(0, 100);
|
||||
slider.resize(120, 24);
|
||||
slider.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
QPointF click_pos(90.0, 12.0);
|
||||
QMouseEvent press(QEvent::MouseButtonPress, click_pos, click_pos,
|
||||
Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&slider, &press);
|
||||
|
||||
REQUIRE(slider.value() >= 60);
|
||||
}
|
||||
|
||||
TEST_CASE("ClickSlider vertical click jumps toward clicked position") {
|
||||
ensureApp();
|
||||
|
||||
ClickSlider slider(Qt::Vertical);
|
||||
slider.setRange(0, 100);
|
||||
slider.resize(24, 120);
|
||||
slider.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
QPointF click_pos(12.0, 20.0);
|
||||
QMouseEvent press(QEvent::MouseButtonPress, click_pos, click_pos,
|
||||
Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&slider, &press);
|
||||
|
||||
REQUIRE(slider.value() <= 40);
|
||||
}
|
||||
|
||||
TEST_CASE("NodeVolumeWidget setVolume and setMuted block outbound signals") {
|
||||
ensureApp();
|
||||
|
||||
NodeVolumeWidget widget;
|
||||
int volume_signal_count = 0;
|
||||
int mute_signal_count = 0;
|
||||
QObject::connect(&widget, &NodeVolumeWidget::volumeChanged,
|
||||
[&](int) { ++volume_signal_count; });
|
||||
QObject::connect(&widget, &NodeVolumeWidget::muteToggled,
|
||||
[&](bool) { ++mute_signal_count; });
|
||||
|
||||
widget.setVolume(35);
|
||||
widget.setMuted(true);
|
||||
REQUIRE(volume_signal_count == 0);
|
||||
REQUIRE(mute_signal_count == 0);
|
||||
|
||||
auto* slider = widget.findChild<QSlider*>();
|
||||
REQUIRE(slider != nullptr);
|
||||
slider->setValue(70);
|
||||
REQUIRE(volume_signal_count >= 1);
|
||||
|
||||
auto* mute_btn = widget.findChild<QToolButton*>();
|
||||
REQUIRE(mute_btn != nullptr);
|
||||
mute_btn->setChecked(false);
|
||||
REQUIRE(mute_signal_count >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("SquareConnectionPainter stroke handles straight and elbow paths") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
QtNodes::BasicGraphicsScene scene(model);
|
||||
SquareConnectionPainter painter;
|
||||
|
||||
auto straight = makeConnectionGraphic(
|
||||
scene,
|
||||
QtNodes::ConnectionId{1u, 0u, 2u, 0u},
|
||||
QPointF(20.0, 20.0),
|
||||
QPointF(180.0, 20.0));
|
||||
auto straightStroke = painter.getPainterStroke(*straight);
|
||||
REQUIRE(!straightStroke.isEmpty());
|
||||
REQUIRE(straightStroke.boundingRect().width() >= 150.0);
|
||||
|
||||
auto elbow = makeConnectionGraphic(
|
||||
scene,
|
||||
QtNodes::ConnectionId{1u, 3u, 2u, 0u},
|
||||
QPointF(180.0, 40.0),
|
||||
QPointF(20.0, 40.0));
|
||||
auto elbowStroke = painter.getPainterStroke(*elbow);
|
||||
REQUIRE(!elbowStroke.isEmpty());
|
||||
REQUIRE(elbowStroke.boundingRect().left() <= 20.0);
|
||||
REQUIRE(elbowStroke.boundingRect().right() >= 180.0);
|
||||
}
|
||||
|
||||
TEST_CASE("SquareConnectionPainter paint renders sketch and connected states") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
QtNodes::BasicGraphicsScene scene(model);
|
||||
SquareConnectionPainter painter;
|
||||
|
||||
auto sketch = makeConnectionGraphic(
|
||||
scene,
|
||||
QtNodes::ConnectionId{1u, 1u, QtNodes::InvalidNodeId, 0u},
|
||||
QPointF(25.0, 25.0),
|
||||
QPointF(190.0, 85.0));
|
||||
QImage sketchImage(240, 140, QImage::Format_ARGB32_Premultiplied);
|
||||
sketchImage.fill(Qt::transparent);
|
||||
{
|
||||
QPainter qp(&sketchImage);
|
||||
painter.paint(&qp, *sketch);
|
||||
}
|
||||
REQUIRE(countPaintedPixels(sketchImage) > 0);
|
||||
|
||||
auto connected = makeConnectionGraphic(
|
||||
scene,
|
||||
QtNodes::ConnectionId{1u, 0u, 2u, 0u},
|
||||
QPointF(25.0, 25.0),
|
||||
QPointF(190.0, 85.0));
|
||||
connected->setSelected(true);
|
||||
QImage connectedImage(240, 140, QImage::Format_ARGB32_Premultiplied);
|
||||
connectedImage.fill(Qt::transparent);
|
||||
{
|
||||
QPainter qp(&connectedImage);
|
||||
painter.paint(&qp, *connected);
|
||||
}
|
||||
REQUIRE(countPaintedPixels(connectedImage) > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("ZoomGraphicsView wheel zoom honors sensitivity and zero delta") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
QtNodes::BasicGraphicsScene scene(model);
|
||||
TestZoomGraphicsView view(&scene);
|
||||
view.resize(320, 200);
|
||||
view.setZoomSensitivity(1.6);
|
||||
view.setupScale(1.0);
|
||||
|
||||
const QPointF pos(40.0, 40.0);
|
||||
const QPointF global(40.0, 40.0);
|
||||
|
||||
QWheelEvent zoomIn(pos, global, QPoint(0, 0), QPoint(0, 120), Qt::NoButton,
|
||||
Qt::NoModifier, Qt::NoScrollPhase, false,
|
||||
Qt::MouseEventNotSynthesized,
|
||||
QPointingDevice::primaryPointingDevice());
|
||||
view.dispatchWheel(&zoomIn);
|
||||
const double zoomed = view.transform().m11();
|
||||
REQUIRE(zoomed > 1.0);
|
||||
|
||||
QWheelEvent zoomOut(pos, global, QPoint(0, 0), QPoint(0, -120), Qt::NoButton,
|
||||
Qt::NoModifier, Qt::NoScrollPhase, false,
|
||||
Qt::MouseEventNotSynthesized,
|
||||
QPointingDevice::primaryPointingDevice());
|
||||
view.dispatchWheel(&zoomOut);
|
||||
REQUIRE(view.transform().m11() < zoomed);
|
||||
|
||||
const double beforeFlat = view.transform().m11();
|
||||
QWheelEvent flat(pos, global, QPoint(0, 0), QPoint(0, 0), Qt::NoButton,
|
||||
Qt::NoModifier, Qt::NoScrollPhase, false,
|
||||
Qt::MouseEventNotSynthesized,
|
||||
QPointingDevice::primaryPointingDevice());
|
||||
view.dispatchWheel(&flat);
|
||||
REQUIRE(view.transform().m11() == Catch::Approx(beforeFlat));
|
||||
}
|
||||
|
||||
TEST_CASE("ZoomGraphicsView updateProxyCacheMode toggles proxy and connection") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
QtNodes::BasicGraphicsScene scene(model);
|
||||
TestZoomGraphicsView view(&scene);
|
||||
|
||||
auto *proxy = scene.addWidget(new QWidget());
|
||||
REQUIRE(proxy != nullptr);
|
||||
|
||||
auto connection = makeConnectionGraphic(
|
||||
scene,
|
||||
QtNodes::ConnectionId{11u, 0u, 12u, 0u},
|
||||
QPointF(10.0, 10.0),
|
||||
QPointF(200.0, 90.0));
|
||||
REQUIRE(connection != nullptr);
|
||||
|
||||
view.setupScale(1.6);
|
||||
view.updateProxyCacheMode();
|
||||
REQUIRE(proxy->cacheMode() == QGraphicsItem::DeviceCoordinateCache);
|
||||
REQUIRE(connection->cacheMode() == QGraphicsItem::DeviceCoordinateCache);
|
||||
|
||||
view.setupScale(1.0);
|
||||
view.updateProxyCacheMode();
|
||||
REQUIRE(proxy->cacheMode() == QGraphicsItem::NoCache);
|
||||
REQUIRE(connection->cacheMode() == QGraphicsItem::NoCache);
|
||||
}
|
||||
|
||||
TEST_CASE("ZoomGraphicsView mouse drag pans and release stops panning") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
QtNodes::BasicGraphicsScene scene(model);
|
||||
TestZoomGraphicsView view(&scene);
|
||||
view.resize(320, 200);
|
||||
|
||||
const int h0 = view.horizontalScrollBar()->value();
|
||||
const int v0 = view.verticalScrollBar()->value();
|
||||
|
||||
QMouseEvent press(QEvent::MouseButtonPress, QPointF(30.0, 30.0),
|
||||
QPointF(30.0, 30.0), Qt::LeftButton, Qt::LeftButton,
|
||||
Qt::NoModifier);
|
||||
view.dispatchMousePress(&press);
|
||||
|
||||
QMouseEvent move(QEvent::MouseMove, QPointF(80.0, 60.0), QPointF(80.0, 60.0),
|
||||
Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
view.dispatchMouseMove(&move);
|
||||
|
||||
const int h1 = view.horizontalScrollBar()->value();
|
||||
const int v1 = view.verticalScrollBar()->value();
|
||||
REQUIRE((h1 != h0 || v1 != v0));
|
||||
|
||||
QMouseEvent release(QEvent::MouseButtonRelease, QPointF(80.0, 60.0),
|
||||
QPointF(80.0, 60.0), Qt::LeftButton, Qt::NoButton,
|
||||
Qt::NoModifier);
|
||||
view.dispatchMouseRelease(&release);
|
||||
|
||||
QMouseEvent afterReleaseMove(QEvent::MouseMove, QPointF(100.0, 90.0),
|
||||
QPointF(100.0, 90.0), Qt::NoButton,
|
||||
Qt::NoButton, Qt::NoModifier);
|
||||
view.dispatchMouseMove(&afterReleaseMove);
|
||||
REQUIRE(view.horizontalScrollBar()->value() == h1);
|
||||
REQUIRE(view.verticalScrollBar()->value() == v1);
|
||||
}
|
||||
|
||||
TEST_CASE("ZoomGraphicsView drawBackground renders grid over background") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
WarpGraphModel model(tc.client.get());
|
||||
QtNodes::BasicGraphicsScene scene(model);
|
||||
TestZoomGraphicsView view(&scene);
|
||||
view.resize(320, 200);
|
||||
view.setupScale(1.0);
|
||||
|
||||
auto const &style = QtNodes::StyleCollection::flowViewStyle();
|
||||
QImage image(320, 200, QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(style.BackgroundColor);
|
||||
|
||||
{
|
||||
QPainter qp(&image);
|
||||
view.dispatchDrawBackground(&qp, QRectF(0.0, 0.0, 320.0, 200.0));
|
||||
}
|
||||
|
||||
REQUIRE(countPixelsDifferentFrom(image, style.BackgroundColor) > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("preset saves and loads volume state") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue