diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 38849b7..108e8b7 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -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 changeTimer = m_changeTimer; + m_client->SetChangeCallback([changeTimer] { + auto *app = QCoreApplication::instance(); + if (!app) { + return; + } + + QMetaObject::invokeMethod(app, + [changeTimer]() { + if (changeTimer) { + changeTimer->start(); + } + }, Qt::QueuedConnection); }); } @@ -636,6 +647,11 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); + if (m_scene && + m_scene->itemIndexMethod() != QGraphicsScene::BspTreeIndex) { + m_scene->setItemIndexMethod(QGraphicsScene::BspTreeIndex); + } + if (!m_graphReady && m_model->allNodeIds().size() > 0) { m_graphReady = true; Q_EMIT graphReady(); @@ -648,6 +664,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); } @@ -670,6 +693,45 @@ int GraphEditorWidget::linkCount() const { return count / 2; } +QAction *GraphEditorWidget::execMenuAction(QMenu &menu, + const QPoint &screenPos) { + return menu.exec(screenPos); +} + +QString GraphEditorWidget::promptTextInput(const QString &title, + const QString &label, + bool *ok) { + return QInputDialog::getText(this, + title, + label, + QLineEdit::Normal, + QString(), + ok); +} + +QString GraphEditorWidget::chooseSaveFilePath(const QString &title, + const QString &initialDir, + const QString &filter) { + return QFileDialog::getSaveFileName(this, + title, + initialDir, + filter); +} + +QString GraphEditorWidget::chooseOpenFilePath(const QString &title, + const QString &initialDir, + const QString &filter) { + return QFileDialog::getOpenFileName(this, + title, + initialDir, + filter); +} + +void GraphEditorWidget::showWarningDialog(const QString &title, + const QString &message) { + QMessageBox::warning(this, title, message); +} + void GraphEditorWidget::setDebugScreenshotDir(const QString &dir) { m_debugScreenshotDir = dir; QDir d(dir); @@ -812,7 +874,7 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, QAction *savePresetAction = menu.addAction(QStringLiteral("Save Preset...")); QAction *loadPresetAction = menu.addAction(QStringLiteral("Load Preset...")); - QAction *chosen = menu.exec(screenPos); + QAction *chosen = execMenuAction(menu, screenPos); if (chosen == createSink) { createVirtualNode(true, scenePos); } else if (chosen == createSource) { @@ -904,7 +966,7 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, QStringLiteral( "application/warppipe-virtual-graph")))); - QAction *chosen = menu.exec(screenPos); + QAction *chosen = execMenuAction(menu, screenPos); if (!chosen) { return; } @@ -931,9 +993,9 @@ void GraphEditorWidget::createVirtualNode(bool isSink, const QPointF &scenePos) { if (isSink) { bool ok = false; - QString name = QInputDialog::getText( - this, QStringLiteral("Create Virtual Sink"), - QStringLiteral("Node name:"), QLineEdit::Normal, QString(), &ok); + QString name = promptTextInput(QStringLiteral("Create Virtual Sink"), + QStringLiteral("Node name:"), + &ok); if (!ok || name.trimmed().isEmpty()) return; @@ -941,8 +1003,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink, m_model->setPendingPosition(nodeName, scenePos); auto result = m_client->CreateVirtualSink(nodeName); if (!result.status.ok()) { - QMessageBox::warning(this, QStringLiteral("Error"), - QString::fromStdString(result.status.message)); + showWarningDialog(QStringLiteral("Error"), + QString::fromStdString(result.status.message)); return; } m_model->refreshFromClient(); @@ -1016,8 +1078,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink, QString name = nameEdit->text().trimmed(); if (name.isEmpty()) { - QMessageBox::warning(this, QStringLiteral("Error"), - QStringLiteral("Name cannot be empty.")); + showWarningDialog(QStringLiteral("Error"), + QStringLiteral("Name cannot be empty.")); return; } @@ -1032,8 +1094,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink, auto result = m_client->CreateVirtualSource(nodeName, opts); if (!result.status.ok()) { - QMessageBox::warning(this, QStringLiteral("Error"), - QString::fromStdString(result.status.message)); + showWarningDialog(QStringLiteral("Error"), + QString::fromStdString(result.status.message)); return; } m_model->refreshFromClient(); @@ -1397,9 +1459,9 @@ void GraphEditorWidget::savePreset() { if (!dir.exists()) dir.mkpath("."); - QString path = QFileDialog::getSaveFileName( - this, QStringLiteral("Save Preset"), m_presetDir, - QStringLiteral("JSON files (*.json)")); + QString path = chooseSaveFilePath(QStringLiteral("Save Preset"), + m_presetDir, + QStringLiteral("JSON files (*.json)")); if (path.isEmpty()) return; if (!path.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive)) @@ -1410,15 +1472,15 @@ void GraphEditorWidget::savePreset() { mw->statusBar()->showMessage( QStringLiteral("Preset saved: ") + QFileInfo(path).fileName(), 4000); } else { - QMessageBox::warning(this, QStringLiteral("Error"), - QStringLiteral("Failed to save preset.")); + showWarningDialog(QStringLiteral("Error"), + QStringLiteral("Failed to save preset.")); } } void GraphEditorWidget::loadPreset() { - QString path = QFileDialog::getOpenFileName( - this, QStringLiteral("Load Preset"), m_presetDir, - QStringLiteral("JSON files (*.json)")); + QString path = chooseOpenFilePath(QStringLiteral("Load Preset"), + m_presetDir, + QStringLiteral("JSON files (*.json)")); if (path.isEmpty()) return; @@ -1427,8 +1489,8 @@ void GraphEditorWidget::loadPreset() { mw->statusBar()->showMessage( QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000); } else { - QMessageBox::warning(this, QStringLiteral("Error"), - QStringLiteral("Failed to load preset.")); + showWarningDialog(QStringLiteral("Error"), + QStringLiteral("Failed to load preset.")); } } diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 52dc491..73247a4 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -27,6 +27,8 @@ class QSplitter; class QTabWidget; class QSlider; class QTimer; +class QAction; +class QMenu; class DeleteVirtualNodeCommand; enum class ConnectionStyleType : uint8_t { @@ -59,6 +61,20 @@ private slots: void onContextMenuRequested(const QPoint &pos); void scheduleSaveLayout(); +protected: + virtual QAction *execMenuAction(QMenu &menu, const QPoint &screenPos); + virtual QString promptTextInput(const QString &title, + const QString &label, + bool *ok); + virtual QString chooseSaveFilePath(const QString &title, + const QString &initialDir, + const QString &filter); + virtual QString chooseOpenFilePath(const QString &title, + const QString &initialDir, + const QString &filter); + virtual void showWarningDialog(const QString &title, + const QString &message); + private: void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId, diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 2e4d022..03d1679 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -352,12 +352,11 @@ void WarpGraphModel::refreshFromClient() { return; } - Q_EMIT beginBatchUpdate(); m_refreshing = true; + bool sceneChanged = false; auto nodesResult = m_client->ListNodes(); if (!nodesResult.ok()) { m_refreshing = false; - Q_EMIT endBatchUpdate(); return; } @@ -512,6 +511,10 @@ void WarpGraphModel::refreshFromClient() { m_volumeStates[qtId] = {}; } + if (!sceneChanged) { + sceneChanged = true; + Q_EMIT beginBatchUpdate(); + } Q_EMIT nodeCreated(qtId); } @@ -526,6 +529,10 @@ void WarpGraphModel::refreshFromClient() { if (it == m_pwToQt.end()) { continue; } + if (!sceneChanged) { + sceneChanged = true; + Q_EMIT beginBatchUpdate(); + } QtNodes::NodeId qtId = it->second; auto nodeIt = m_nodes.find(qtId); if (nodeIt == m_nodes.end()) { @@ -581,6 +588,10 @@ void WarpGraphModel::refreshFromClient() { QtNodes::ConnectionId connId{outNodeIt->second, outPortIdx, inNodeIt->second, inPortIdx}; if (m_connections.find(connId) == m_connections.end()) { + if (!sceneChanged) { + sceneChanged = true; + Q_EMIT beginBatchUpdate(); + } m_connections.insert(connId); m_linkIdToConn.emplace(link.id.value, connId); Q_EMIT connectionCreated(connId); @@ -609,6 +620,10 @@ void WarpGraphModel::refreshFromClient() { { auto connIt = m_connections.find(connId); if (connIt != m_connections.end()) { + if (!sceneChanged) { + sceneChanged = true; + Q_EMIT beginBatchUpdate(); + } m_connections.erase(connIt); Q_EMIT connectionDeleted(connId); } @@ -696,7 +711,9 @@ void WarpGraphModel::refreshFromClient() { } m_refreshing = false; - Q_EMIT endBatchUpdate(); + if (sceneChanged) { + Q_EMIT endBatchUpdate(); + } } const WarpNodeData * diff --git a/gui/ZoomGraphicsView.h b/gui/ZoomGraphicsView.h index 53106df..abc7edd 100644 --- a/gui/ZoomGraphicsView.h +++ b/gui/ZoomGraphicsView.h @@ -18,7 +18,7 @@ public: explicit ZoomGraphicsView(QtNodes::BasicGraphicsScene *scene, QWidget *parent = nullptr) : QtNodes::GraphicsView(scene, parent) { - setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); + setViewportUpdateMode(QGraphicsView::FullViewportUpdate); setCacheMode(QGraphicsView::CacheNone); } diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 9e43a2c..93949c8 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -3,17 +3,45 @@ #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 +#include +#include + +#include #include #include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include #include @@ -87,6 +115,294 @@ 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 +makeConnectionGraphic(QtNodes::BasicGraphicsScene &scene, + QtNodes::ConnectionId connectionId, + const QPointF &out, + const QPointF &in) { + auto cgo = std::make_unique(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; +} + +int countColorPixels(const QImage &image, const QColor &color) { + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (image.pixelColor(x, y) == color) { + ++count; + } + } + } + return count; +} + +QImage renderWidgetImage(QWidget &widget, const QSize &size) { + widget.resize(size); + widget.show(); + QApplication::processEvents(); + QImage image(size, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + widget.render(&image); + return image; +} + +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); + } +}; + +class ScriptedGraphEditorWidget : public GraphEditorWidget { +public: + using GraphEditorWidget::GraphEditorWidget; + + void queueMenuSelection(const QString &text) { m_menuActionQueue.append(text); } + + void setInputDialogResponse(const QString &text, bool accepted) { + m_hasInputResponse = true; + m_inputTextResponse = text; + m_inputAcceptedResponse = accepted; + } + + void setSaveFilePathResponse(const QString &path) { + m_saveFilePathResponse = path; + } + + void setOpenFilePathResponse(const QString &path) { + m_openFilePathResponse = path; + } + + int warningCount() const { return static_cast(m_warnings.size()); } + +protected: + QAction *execMenuAction(QMenu &menu, const QPoint &) override { + if (m_menuActionQueue.isEmpty()) { + return nullptr; + } + QString wanted = m_menuActionQueue.takeFirst(); + return findActionInMenu(menu, wanted); + } + + QString promptTextInput(const QString &title, + const QString &label, + bool *ok) override { + if (!m_hasInputResponse) { + return GraphEditorWidget::promptTextInput(title, label, ok); + } + + m_hasInputResponse = false; + if (ok) { + *ok = m_inputAcceptedResponse; + } + return m_inputTextResponse; + } + + QString chooseSaveFilePath(const QString &title, + const QString &initialDir, + const QString &filter) override { + if (m_saveFilePathResponse.isNull()) { + return GraphEditorWidget::chooseSaveFilePath(title, initialDir, filter); + } + return m_saveFilePathResponse; + } + + QString chooseOpenFilePath(const QString &title, + const QString &initialDir, + const QString &filter) override { + if (m_openFilePathResponse.isNull()) { + return GraphEditorWidget::chooseOpenFilePath(title, initialDir, filter); + } + return m_openFilePathResponse; + } + + void showWarningDialog(const QString &title, + const QString &message) override { + m_warnings.append(title + QStringLiteral(":") + message); + } + +private: + QAction *findActionInMenu(QMenu &menu, const QString &text) { + for (QAction *action : menu.actions()) { + if (!action) { + continue; + } + if (action->text() == text) { + return action; + } + if (action->menu()) { + if (QAction *nested = findActionInMenu(*action->menu(), text)) { + return nested; + } + } + } + return nullptr; + } + + QStringList m_menuActionQueue; + bool m_hasInputResponse = false; + bool m_inputAcceptedResponse = false; + QString m_inputTextResponse; + QString m_saveFilePathResponse; + QString m_openFilePathResponse; + QStringList m_warnings; +}; + +bool triggerVisibleMenuAction(const QString &actionText) { + for (QWidget *widget : QApplication::topLevelWidgets()) { + auto *menu = qobject_cast(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(widget); + if (!dialog || !dialog->isVisible() || + !dialog->windowTitle().contains(QStringLiteral("Routing Rule"))) { + continue; + } + + auto edits = dialog->findChildren(); + if (!edits.isEmpty()) { + edits[0]->setText(appName); + } + + auto combos = dialog->findChildren(); + if (!combos.isEmpty()) { + int idx = combos[0]->findData(targetNodeName); + if (idx >= 0) { + combos[0]->setCurrentIndex(idx); + } + } + + auto *buttons = dialog->findChild(); + 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()) { + if (!label->text().contains(appLabelToken)) { + continue; + } + QWidget *cursor = label; + while (cursor && cursor != &root) { + for (auto *button : cursor->findChildren()) { + if (button->text() == QString(QChar(0x2715))) { + return button; + } + } + cursor = cursor->parentWidget(); + } + } + return nullptr; +} + +ZoomGraphicsView *findZoomView(QWidget &root) { + for (auto *view : root.findChildren()) { + if (auto *zoom = dynamic_cast(view)) { + return zoom; + } + } + return nullptr; +} + +QAction *findActionByText(const QList &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; +} + +QPoint nodeCenterInView(WarpGraphModel &model, + ZoomGraphicsView &view, + QtNodes::NodeId nodeId) { + QPointF nodePos = model.nodeData(nodeId, QtNodes::NodeRole::Position).toPointF(); + QSize nodeSize = model.nodeData(nodeId, QtNodes::NodeRole::Size).toSize(); + QPointF hitScenePos = nodePos + QPointF(nodeSize.width() / 2.0, + nodeSize.height() / 2.0); + return view.mapFromScene(hitScenePos); +} + } // namespace TEST_CASE("classifyNode identifies hardware sink") { @@ -534,6 +850,795 @@ 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(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(tc.client.get()); + auto *model = widget->findChild(); + REQUIRE(model != nullptr); + auto *view = findZoomView(*widget); + REQUIRE(view != nullptr); + auto *scene = dynamic_cast(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(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(tc.client.get()); + auto *view = findZoomView(*widget); + REQUIRE(view != nullptr); + auto *scene = dynamic_cast(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()) { + 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 onRefreshTimer restores BspTreeIndex when forced to NoIndex") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + GraphEditorWidget widget(tc.client.get()); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + auto *scene = dynamic_cast(view->scene()); + REQUIRE(scene != nullptr); + + scene->setItemIndexMethod(QGraphicsScene::NoIndex); + REQUIRE(scene->itemIndexMethod() == QGraphicsScene::NoIndex); + + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + REQUIRE(scene->itemIndexMethod() == QGraphicsScene::BspTreeIndex); +} + +TEST_CASE("GraphEditorWidget eventFilter consumes middle-click on viewport") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + GraphEditorWidget widget(tc.client.get()); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + REQUIRE(view->viewport() != nullptr); + + QPointF pos(20.0, 20.0); + QMouseEvent middlePress(QEvent::MouseButtonPress, + pos, + pos, + Qt::MiddleButton, + Qt::MiddleButton, + Qt::NoModifier); + REQUIRE(widget.eventFilter(view->viewport(), &middlePress)); + +} + +TEST_CASE("Scripted GraphEditorWidget canvas menu selects and deselects all") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(101990, "menu-select-node-101990", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(101991, 101990, "in_FL", true)).ok()); + + ScriptedGraphEditorWidget widget(tc.client.get()); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + auto *scene = dynamic_cast(view->scene()); + REQUIRE(scene != nullptr); + + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + widget.queueMenuSelection(QStringLiteral("Select All")); + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200))); + REQUIRE(invoked); + REQUIRE_FALSE(scene->selectedItems().isEmpty()); + + widget.queueMenuSelection(QStringLiteral("Deselect All")); + invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200))); + REQUIRE(invoked); + REQUIRE(scene->selectedItems().isEmpty()); +} + +TEST_CASE("Scripted GraphEditorWidget canvas menu creates virtual sink") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + const std::string nodeName = "scripted-sink-102000"; + + ScriptedGraphEditorWidget widget(tc.client.get()); + widget.setInputDialogResponse(QString::fromStdString(nodeName), true); + widget.queueMenuSelection(QStringLiteral("Create Virtual Sink")); + + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200))); + REQUIRE(invoked); + QApplication::processEvents(); + + if (!hasNodeNamed(tc.client.get(), nodeName)) { + SUCCEED("Virtual sink creation unavailable in this runtime"); + return; + } + + auto nodes = tc.client->ListNodes(); + REQUIRE(nodes.ok()); + for (const auto &node : nodes.value) { + if (node.name == nodeName) { + tc.client->RemoveNode(node.id); + } + } +} + +TEST_CASE("Scripted GraphEditorWidget node menu copy exports clipboard payload") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + const uint32_t pwId = 102020; + const std::string nodeName = "node-menu-copy-102020"; + REQUIRE(tc.client->Test_InsertNode( + MakeNode(pwId, nodeName, "Audio/Sink", {}, {}, true)).ok()); + + ScriptedGraphEditorWidget widget(tc.client.get()); + auto *model = widget.findChild(); + REQUIRE(model != nullptr); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + auto *scene = dynamic_cast(view->scene()); + REQUIRE(scene != nullptr); + + widget.show(); + QApplication::processEvents(); + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + QtNodes::NodeId qtId = model->qtNodeIdForPw(pwId); + REQUIRE(qtId != 0); + auto *nodeObj = scene->nodeGraphicsObject(qtId); + REQUIRE(nodeObj != nullptr); + scene->clearSelection(); + nodeObj->setSelected(true); + QApplication::processEvents(); + + widget.queueMenuSelection(QStringLiteral("Copy")); + QPoint hitPos = nodeCenterInView(*model, *view, qtId); + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos)); + REQUIRE(invoked); + + const QMimeData *mime = QGuiApplication::clipboard()->mimeData(); + REQUIRE(mime != nullptr); + REQUIRE(mime->hasFormat(QStringLiteral("application/warppipe-virtual-graph"))); + + QJsonObject copied = QJsonDocument::fromJson( + mime->data(QStringLiteral("application/warppipe-virtual-graph"))).object(); + bool foundNode = false; + for (const auto &entry : copied[QStringLiteral("nodes")].toArray()) { + if (entry.toObject()[QStringLiteral("name")].toString().toStdString() == + nodeName) { + foundNode = true; + break; + } + } + REQUIRE(foundNode); +} + +TEST_CASE("Scripted GraphEditorWidget node menu duplicate creates copied node") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + const uint32_t pwId = 102030; + const std::string nodeName = "node-menu-duplicate-102030"; + REQUIRE(tc.client->Test_InsertNode( + MakeNode(pwId, nodeName, "Audio/Sink", {}, {}, true)).ok()); + + ScriptedGraphEditorWidget widget(tc.client.get()); + auto *model = widget.findChild(); + REQUIRE(model != nullptr); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + auto *scene = dynamic_cast(view->scene()); + REQUIRE(scene != nullptr); + + tc.client->SetChangeCallback(nullptr); + widget.show(); + QApplication::processEvents(); + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + QtNodes::NodeId qtId = model->qtNodeIdForPw(pwId); + REQUIRE(qtId != 0); + auto *nodeObj = scene->nodeGraphicsObject(qtId); + REQUIRE(nodeObj != nullptr); + scene->clearSelection(); + nodeObj->setSelected(true); + QApplication::processEvents(); + + widget.queueMenuSelection(QStringLiteral("Duplicate")); + QPoint hitPos = nodeCenterInView(*model, *view, qtId); + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos)); + REQUIRE(invoked); + QApplication::processEvents(); + model->refreshFromClient(); + + if (model->findPwNodeIdByName(nodeName + " Copy") == 0) { + SUCCEED("Virtual duplicate unavailable in this runtime"); + return; + } + + auto nodes = tc.client->ListNodes(); + REQUIRE(nodes.ok()); + for (const auto &node : nodes.value) { + if (node.name.rfind(nodeName + " Copy", 0) == 0) { + tc.client->RemoveNode(node.id); + } + } +} + +TEST_CASE("Scripted GraphEditorWidget node menu paste creates node from clipboard") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + const uint32_t anchorId = 102040; + const std::string anchorName = "node-menu-paste-anchor-102040"; + const std::string payloadName = "node-menu-paste-payload-102040"; + REQUIRE(tc.client->Test_InsertNode( + MakeNode(anchorId, anchorName, "Audio/Sink", {}, {}, true)).ok()); + + ScriptedGraphEditorWidget widget(tc.client.get()); + auto *model = widget.findChild(); + REQUIRE(model != nullptr); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + + tc.client->SetChangeCallback(nullptr); + widget.show(); + QApplication::processEvents(); + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + QtNodes::NodeId qtId = model->qtNodeIdForPw(anchorId); + REQUIRE(qtId != 0); + + QJsonObject node; + node[QStringLiteral("name")] = QString::fromStdString(payloadName); + node[QStringLiteral("media_class")] = QStringLiteral("Audio/Sink"); + node[QStringLiteral("x")] = 32.0; + node[QStringLiteral("y")] = 48.0; + QJsonObject root; + root[QStringLiteral("nodes")] = QJsonArray{node}; + root[QStringLiteral("links")] = QJsonArray{}; + root[QStringLiteral("center_x")] = 32.0; + root[QStringLiteral("center_y")] = 48.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); + + widget.queueMenuSelection(QStringLiteral("Paste")); + QPoint hitPos = nodeCenterInView(*model, *view, qtId); + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos)); + REQUIRE(invoked); + QApplication::processEvents(); + model->refreshFromClient(); + + if (model->findPwNodeIdByName(payloadName + " Copy") == 0) { + SUCCEED("Virtual paste unavailable in this runtime"); + return; + } + + auto nodes = tc.client->ListNodes(); + REQUIRE(nodes.ok()); + for (const auto &n : nodes.value) { + if (n.name.rfind(payloadName + " Copy", 0) == 0) { + tc.client->RemoveNode(n.id); + } + } +} + +TEST_CASE("Scripted GraphEditorWidget node menu create-rule opens dialog and adds rule") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + const uint32_t appId = 102050; + const std::string targetName = "ctx-rule-target-102050"; + const QString appMatch = QStringLiteral("CtxMenuRule102050"); + + REQUIRE(tc.client->Test_InsertNode(MakeNode(appId, + "ctx-rule-app-node-102050", + "Stream/Output/Audio", + "ctx-app-source")).ok()); + REQUIRE(tc.client->Test_InsertNode( + MakeNode(102051, targetName, "Audio/Sink", {}, {}, true)).ok()); + + ScriptedGraphEditorWidget widget(tc.client.get()); + auto *model = widget.findChild(); + REQUIRE(model != nullptr); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + + widget.show(); + QApplication::processEvents(); + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + QtNodes::NodeId qtId = model->qtNodeIdForPw(appId); + REQUIRE(qtId != 0); + + bool accepted = false; + QTimer::singleShot(0, [&accepted, &appMatch, &targetName]() { + accepted = acceptRuleDialog(appMatch, QString::fromStdString(targetName)); + }); + + widget.queueMenuSelection(QStringLiteral("Create Rule...")); + QPoint hitPos = nodeCenterInView(*model, *view, qtId); + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos)); + REQUIRE(invoked); + QApplication::processEvents(); + + if (!accepted) { + SUCCEED("Rule dialog automation unavailable on this platform"); + return; + } + + auto rules = tc.client->ListRouteRules(); + REQUIRE(rules.ok()); + warppipe::RuleId created{}; + bool found = false; + for (const auto &rule : rules.value) { + if (rule.match.application_name == appMatch.toStdString() && + rule.target_node == targetName) { + created = rule.id; + found = true; + break; + } + } + REQUIRE(found); + REQUIRE(tc.client->RemoveRouteRule(created).ok()); +} + +TEST_CASE("Scripted GraphEditorWidget save and load preset use scripted file paths") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + QString base = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + + "/warppipe_scripted_preset"; + QString fullPath = base + ".json"; + QFile::remove(fullPath); + + ScriptedGraphEditorWidget widget(tc.client.get()); + widget.setSaveFilePathResponse(base); + widget.queueMenuSelection(QStringLiteral("Save Preset...")); + + bool invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200))); + REQUIRE(invoked); + QApplication::processEvents(); + + if (!QFile::exists(fullPath)) { + SUCCEED("Preset save unavailable in this runtime"); + return; + } + + const int warningsBefore = widget.warningCount(); + widget.setOpenFilePathResponse(fullPath); + widget.queueMenuSelection(QStringLiteral("Load Preset...")); + invoked = QMetaObject::invokeMethod( + &widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200))); + REQUIRE(invoked); + QApplication::processEvents(); + REQUIRE(widget.warningCount() == warningsBefore); + + QFile::remove(fullPath); +} + +TEST_CASE("GraphEditorWidget debug screenshot dir creates node-added capture") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + QString screenshotDir = + QStandardPaths::writableLocation(QStandardPaths::TempLocation) + + "/warppipe_debug_screens"; + QDir(screenshotDir).removeRecursively(); + + GraphEditorWidget widget(tc.client.get()); + widget.resize(640, 420); + widget.show(); + QApplication::processEvents(); + + widget.setDebugScreenshotDir(screenshotDir); + REQUIRE(QDir(screenshotDir).exists()); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(102010, "screenshot-node-102010", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(102011, 102010, "in_FL", true)).ok()); + + bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer"); + REQUIRE(refreshed); + QApplication::processEvents(); + + QStringList shots = QDir(screenshotDir).entryList( + QStringList() << "warppipe_*_node_added.png", QDir::Files); + REQUIRE_FALSE(shots.isEmpty()); + + QDir(screenshotDir).removeRecursively(); +} + +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(); + REQUIRE(model != nullptr); + auto *view = findZoomView(widget); + REQUIRE(view != nullptr); + auto *sidebar = widget.findChild(); + 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()) { + 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 +2205,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(); + REQUIRE(slider != nullptr); + slider->setValue(70); + REQUIRE(volume_signal_count >= 1); + + auto* mute_btn = widget.findChild(); + 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 cache") { + 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; } @@ -1200,6 +2563,68 @@ TEST_CASE("AudioLevelMeter resetPeakHold clears peak") { REQUIRE(meter.peakHold() == Catch::Approx(0.0f)); } +TEST_CASE("AudioLevelMeter reports expected size hints") { + ensureApp(); + AudioLevelMeter meter; + REQUIRE(meter.sizeHint() == QSize(40, 160)); + REQUIRE(meter.minimumSizeHint() == QSize(12, 40)); +} + +TEST_CASE("AudioLevelMeter paint at silence draws background only") { + ensureApp(); + AudioLevelMeter meter; + meter.resetPeakHold(); + meter.setLevel(0.0f); + + QImage image = renderWidgetImage(meter, QSize(20, 120)); + const QColor kBackground(24, 24, 28); + const QColor kGreen(76, 175, 80); + const QColor kYellow(255, 193, 7); + const QColor kRed(244, 67, 54); + const QColor kWhite(255, 255, 255); + + REQUIRE(countColorPixels(image, kBackground) > 0); + REQUIRE(countColorPixels(image, kGreen) == 0); + REQUIRE(countColorPixels(image, kYellow) == 0); + REQUIRE(countColorPixels(image, kRed) == 0); + REQUIRE(countColorPixels(image, kWhite) == 0); +} + +TEST_CASE("AudioLevelMeter paint at high level draws green yellow red and peak") { + ensureApp(); + AudioLevelMeter meter; + meter.setLevel(0.95f); + + QImage image = renderWidgetImage(meter, QSize(20, 120)); + const QColor kGreen(76, 175, 80); + const QColor kYellow(255, 193, 7); + const QColor kRed(244, 67, 54); + const QColor kWhite(255, 255, 255); + + REQUIRE(countColorPixels(image, kGreen) > 0); + REQUIRE(countColorPixels(image, kYellow) > 0); + REQUIRE(countColorPixels(image, kRed) > 0); + REQUIRE(countColorPixels(image, kWhite) > 0); +} + +TEST_CASE("AudioLevelMeter paint after drop keeps peak line without bar") { + ensureApp(); + AudioLevelMeter meter; + meter.setLevel(0.8f); + meter.setLevel(0.0f); + + QImage image = renderWidgetImage(meter, QSize(20, 120)); + const QColor kGreen(76, 175, 80); + const QColor kYellow(255, 193, 7); + const QColor kRed(244, 67, 54); + const QColor kWhite(255, 255, 255); + + REQUIRE(countColorPixels(image, kGreen) == 0); + REQUIRE(countColorPixels(image, kYellow) == 0); + REQUIRE(countColorPixels(image, kRed) == 0); + REQUIRE(countColorPixels(image, kWhite) > 0); +} + TEST_CASE("GraphEditorWidget has METERS tab") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } diff --git a/tests/warppipe_tests.cpp b/tests/warppipe_tests.cpp index 4b70857..f03c743 100644 --- a/tests/warppipe_tests.cpp +++ b/tests/warppipe_tests.cpp @@ -626,6 +626,60 @@ TEST_CASE("set default sink without metadata returns unavailable") { REQUIRE_FALSE(status.ok()); } +TEST_CASE("set default source without metadata returns unavailable") { + auto result = warppipe::Client::Create(DefaultOptions()); + if (!result.ok()) { + SUCCEED("PipeWire unavailable"); + return; + } + + auto status = result.value->SetDefaultSource(""); + REQUIRE_FALSE(status.ok()); +} + +TEST_CASE("GetVirtualNodeInfo returns details for created virtual sink") { + auto result = warppipe::Client::Create(DefaultOptions()); + if (!result.ok()) { + SUCCEED("PipeWire unavailable"); + return; + } + + warppipe::VirtualNodeOptions options; + options.display_name = "warppipe-test-info"; + options.group = "warppipe-test"; + options.format.rate = 48000; + options.format.channels = 2; + + auto sink = result.value->CreateVirtualSink("warppipe-info-sink", options); + if (!sink.ok()) { + if (sink.status.code == warppipe::StatusCode::kUnavailable) { + SUCCEED("PipeWire unavailable"); + return; + } + REQUIRE(sink.ok()); + } + + auto info = result.value->GetVirtualNodeInfo(sink.value.node); + REQUIRE(info.ok()); + REQUIRE(info.value.node.value == sink.value.node.value); + REQUIRE(info.value.name == sink.value.name); + REQUIRE_FALSE(info.value.is_source); + + REQUIRE(result.value->RemoveNode(sink.value.node).ok()); +} + +TEST_CASE("GetVirtualNodeInfo missing node returns not found") { + auto result = warppipe::Client::Create(DefaultOptions()); + if (!result.ok()) { + SUCCEED("PipeWire unavailable"); + return; + } + + auto info = result.value->GetVirtualNodeInfo(warppipe::NodeId{999999}); + REQUIRE_FALSE(info.ok()); + REQUIRE(info.status.code == warppipe::StatusCode::kNotFound); +} + TEST_CASE("NodeInfo captures application properties") { auto result = warppipe::Client::Create(DefaultOptions()); if (!result.ok()) {