This commit is contained in:
Joey Yakimowich-Payne 2026-02-06 10:21:42 -07:00
commit 16fc02837a
2 changed files with 842 additions and 3 deletions

View file

@ -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);
}

View file

@ -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; }