#include #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 #include namespace { warppipe::ConnectionOptions TestOptions() { warppipe::ConnectionOptions opts; opts.threading = warppipe::ThreadingMode::kThreadLoop; opts.autoconnect = true; opts.application_name = "warppipe-gui-tests"; return opts; } struct TestClient { std::unique_ptr client; bool available() const { return client != nullptr; } static TestClient Create() { auto result = warppipe::Client::Create(TestOptions()); if (!result.ok()) return {nullptr}; return {std::move(result.value)}; } }; warppipe::NodeInfo MakeNode(uint32_t id, const std::string &name, const std::string &media_class, const std::string &app_name = {}, const std::string &desc = {}, bool is_virtual = false) { warppipe::NodeInfo n; n.id = warppipe::NodeId{id}; n.name = name; n.media_class = media_class; n.application_name = app_name; n.description = desc; n.is_virtual = is_virtual; return n; } warppipe::PortInfo MakePort(uint32_t id, uint32_t node_id, const std::string &name, bool is_input) { warppipe::PortInfo p; p.id = warppipe::PortId{id}; p.node = warppipe::NodeId{node_id}; p.name = name; p.is_input = is_input; return p; } warppipe::Link MakeLink(uint32_t id, uint32_t out_port, uint32_t in_port) { warppipe::Link l; l.id = warppipe::LinkId{id}; l.output_port = warppipe::PortId{out_port}; l.input_port = warppipe::PortId{in_port}; return l; } int g_argc = 0; char *g_argv[] = {nullptr}; struct AppGuard { QApplication app{g_argc, g_argv}; }; AppGuard &ensureApp() { static AppGuard guard; 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") { auto n = MakeNode(1, "alsa_output", "Audio/Sink"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink); } TEST_CASE("classifyNode identifies hardware source") { auto n = MakeNode(2, "alsa_input", "Audio/Source"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSource); } TEST_CASE("classifyNode identifies virtual sink") { auto n = MakeNode(3, "gaming-sink", "Audio/Sink", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink); } TEST_CASE("classifyNode identifies virtual source") { auto n = MakeNode(4, "my-mic", "Audio/Source", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSource); } TEST_CASE("classifyNode identifies application stream output") { auto n = MakeNode(5, "Firefox", "Stream/Output/Audio"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kApplication); } TEST_CASE("classifyNode identifies application stream input") { auto n = MakeNode(6, "Discord", "Stream/Input/Audio"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kApplication); } TEST_CASE("classifyNode returns unknown for unrecognized media class") { auto n = MakeNode(7, "midi-bridge", "Midi/Bridge"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kUnknown); } TEST_CASE("classifyNode virtual sink without warppipe in name") { auto n = MakeNode(10, "Sink", "Audio/Sink", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink); } TEST_CASE("classifyNode virtual source without warppipe in name") { auto n = MakeNode(11, "Mic", "Audio/Source", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSource); } TEST_CASE("classifyNode non-virtual sink with warppipe in name") { auto n = MakeNode(12, "warppipe-hw", "Audio/Sink", {}, {}, false); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink); } TEST_CASE("classifyNode virtual duplex treated as virtual sink") { auto n = MakeNode(13, "my-duplex", "Audio/Duplex", {}, {}, true); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink); } TEST_CASE("classifyNode duplex treated as sink") { auto n = MakeNode(8, "alsa_duplex", "Audio/Duplex"); REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink); } TEST_CASE("refreshFromClient populates nodes from client") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100001, "test-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100002, 100001, "FL", true)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100003, 100001, "FR", true)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100001); REQUIRE(qtId != 0); REQUIRE(model.nodeExists(qtId)); auto caption = model.nodeData(qtId, QtNodes::NodeRole::Caption).toString(); REQUIRE(caption == "test-sink"); auto inCount = model.nodeData(qtId, QtNodes::NodeRole::InPortCount).toUInt(); REQUIRE(inCount == 2); auto outCount = model.nodeData(qtId, QtNodes::NodeRole::OutPortCount).toUInt(); REQUIRE(outCount == 0); } TEST_CASE("caption prefers description over name") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100010, "alsa_output.pci-0000_00_1f.3", "Audio/Sink", "", "Speakers")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100010); auto caption = model.nodeData(qtId, QtNodes::NodeRole::Caption).toString(); REQUIRE(caption == "Speakers"); } TEST_CASE("caption uses application_name for streams") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100020, "firefox-output", "Stream/Output/Audio", "Firefox")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100020); auto caption = model.nodeData(qtId, QtNodes::NodeRole::Caption).toString(); REQUIRE(caption == "Firefox"); } TEST_CASE("port data returns correct captions and types") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100030, "port-test", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100031, 100030, "playback_FL", true)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100032, 100030, "playback_FR", true)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100033, 100030, "monitor_FL", false)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100030); auto inCaption = model.portData(qtId, QtNodes::PortType::In, 0, QtNodes::PortRole::Caption).toString(); REQUIRE((inCaption == "playback_FL" || inCaption == "playback_FR")); auto outCaption = model.portData(qtId, QtNodes::PortType::Out, 0, QtNodes::PortRole::Caption).toString(); REQUIRE(outCaption == "monitor_FL"); auto dataType = model.portData(qtId, QtNodes::PortType::In, 0, QtNodes::PortRole::DataType).toString(); REQUIRE(dataType == "audio"); } TEST_CASE("node style varies by type") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100040, "hw-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100041, "my-vsink", "Audio/Sink", {}, {}, true)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100042, "app-stream", "Stream/Output/Audio", "App")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto hwStyle = model.nodeData(model.qtNodeIdForPw(100040), QtNodes::NodeRole::Style); auto vStyle = model.nodeData(model.qtNodeIdForPw(100041), QtNodes::NodeRole::Style); auto appStyle = model.nodeData(model.qtNodeIdForPw(100042), QtNodes::NodeRole::Style); REQUIRE(hwStyle.isValid()); REQUIRE(vStyle.isValid()); REQUIRE(appStyle.isValid()); REQUIRE(hwStyle != vStyle); REQUIRE(hwStyle != appStyle); REQUIRE(vStyle != appStyle); } TEST_CASE("ghost nodes tracked after disappearance") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100050, "ephemeral-app", "Stream/Output/Audio", "App")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100050); REQUIRE(qtId != 0); REQUIRE_FALSE(model.isGhost(qtId)); REQUIRE(tc.client->Test_RemoveGlobal(100050).ok()); model.refreshFromClient(); REQUIRE(model.nodeExists(qtId)); REQUIRE(model.isGhost(qtId)); } TEST_CASE("ghost node reappears and loses ghost status") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100060, "ephemeral-2", "Stream/Output/Audio", "App")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100060); REQUIRE(tc.client->Test_RemoveGlobal(100060).ok()); model.refreshFromClient(); REQUIRE(model.isGhost(qtId)); REQUIRE(tc.client->Test_InsertNode( MakeNode(100061, "ephemeral-2", "Stream/Output/Audio", "App")).ok()); model.refreshFromClient(); REQUIRE(model.nodeExists(qtId)); REQUIRE_FALSE(model.isGhost(qtId)); } TEST_CASE("non-application nodes removed on disappearance") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100070, "hw-gone", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100070); REQUIRE(qtId != 0); REQUIRE(tc.client->Test_RemoveGlobal(100070).ok()); model.refreshFromClient(); REQUIRE_FALSE(model.nodeExists(qtId)); } TEST_CASE("links from client appear as connections") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100100, "src-node", "Audio/Source")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100101, 100100, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100102, "sink-node", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100103, 100102, "in_FL", true)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100104, 100101, 100103)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto srcQt = model.qtNodeIdForPw(100100); auto sinkQt = model.qtNodeIdForPw(100102); REQUIRE(srcQt != 0); REQUIRE(sinkQt != 0); auto conns = model.allConnectionIds(srcQt); REQUIRE(conns.size() == 1); QtNodes::ConnectionId connId = *conns.begin(); REQUIRE(connId.outNodeId == srcQt); REQUIRE(connId.inNodeId == sinkQt); REQUIRE(model.connectionExists(connId)); } TEST_CASE("connectionPossible rejects invalid port indices") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100110, "conn-test", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100111, 100110, "in_FL", true)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100110); QtNodes::ConnectionId bad{qtId, 0, qtId, 99}; REQUIRE_FALSE(model.connectionPossible(bad)); } TEST_CASE("connectionPossible rejects nonexistent nodes") { ensureApp(); WarpGraphModel model(nullptr); QtNodes::ConnectionId bad{999, 0, 998, 0}; REQUIRE_FALSE(model.connectionPossible(bad)); } TEST_CASE("deleteConnection removes from model") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100120, "del-src", "Audio/Source")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100121, 100120, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100122, "del-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100123, 100122, "in_FL", true)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100124, 100121, 100123)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto srcQt = model.qtNodeIdForPw(100120); auto conns = model.allConnectionIds(srcQt); REQUIRE(conns.size() == 1); QtNodes::ConnectionId connId = *conns.begin(); REQUIRE(model.deleteConnection(connId)); REQUIRE_FALSE(model.connectionExists(connId)); } TEST_CASE("node deletion with connections does not crash") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100130, "crash-src", "Audio/Source")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100131, 100130, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100132, "crash-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100133, 100132, "in_FL", true)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100134, 100131, 100133)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto srcQt = model.qtNodeIdForPw(100130); REQUIRE(model.deleteNode(srcQt)); REQUIRE_FALSE(model.nodeExists(srcQt)); } TEST_CASE("saved positions restored for new nodes") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); WarpGraphModel model(tc.client.get()); model.setPendingPosition("pending-node", QPointF(100, 200)); REQUIRE(tc.client->Test_InsertNode( MakeNode(100140, "pending-node", "Audio/Sink")).ok()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100140); auto pos = model.nodeData(qtId, QtNodes::NodeRole::Position).toPointF(); REQUIRE(pos.x() == 100.0); REQUIRE(pos.y() == 200.0); } TEST_CASE("volume meter streams are filtered") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); auto n = MakeNode(100150, "", "Stream/Output/Audio"); REQUIRE(tc.client->Test_InsertNode(n).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100150); REQUIRE(qtId == 0); } TEST_CASE("findPwNodeIdByName returns correct id") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100200, "find-me-node", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100201, "other-node", "Audio/Source")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); REQUIRE(model.findPwNodeIdByName("find-me-node") == 100200); REQUIRE(model.findPwNodeIdByName("other-node") == 100201); REQUIRE(model.findPwNodeIdByName("nonexistent") == 0); } TEST_CASE("GraphEditorWidget registers custom keyboard actions") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); GraphEditorWidget widget(tc.client.get()); QStringList actionTexts; for (auto *action : widget.findChildren()) { if (!action->text().isEmpty()) { actionTexts.append(action->text()); } } REQUIRE(actionTexts.contains("Delete Selection")); REQUIRE(actionTexts.contains("Copy Selection")); REQUIRE(actionTexts.contains("Paste Selection")); REQUIRE(actionTexts.contains("Duplicate Selection")); REQUIRE(actionTexts.contains("Auto-Arrange")); REQUIRE(actionTexts.contains("Select All")); REQUIRE(actionTexts.contains("Deselect All")); REQUIRE(actionTexts.contains("Zoom Fit All")); REQUIRE(actionTexts.contains("Zoom Fit Selected")); 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; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100210, "warppipe-widget-test", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100211, 100210, "FL", true)).ok()); GraphEditorWidget widget(tc.client.get()); REQUIRE(widget.nodeCount() >= 1); } TEST_CASE("findPwNodeIdByName returns 0 for ghost nodes without pw mapping") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100220, "ghost-lookup", "Stream/Output/Audio", "App")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220); REQUIRE(tc.client->Test_RemoveGlobal(100220).ok()); model.refreshFromClient(); REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220); } TEST_CASE("saveLayout stores and loadLayout restores view state") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100300, "view-state-node", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); WarpGraphModel::ViewState vs; vs.scale = 1.5; vs.centerX = 123.4; vs.centerY = 567.8; vs.valid = true; QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_viewstate.json"; model.saveLayout(path, vs); WarpGraphModel model2(tc.client.get()); bool loaded = model2.loadLayout(path); REQUIRE(loaded); auto restored = model2.savedViewState(); REQUIRE(restored.valid); REQUIRE(restored.scale == Catch::Approx(1.5)); REQUIRE(restored.centerX == Catch::Approx(123.4)); REQUIRE(restored.centerY == Catch::Approx(567.8)); QFile::remove(path); } TEST_CASE("saveLayout without view state omits view key") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100310, "no-view-node", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_noview.json"; model.saveLayout(path); WarpGraphModel model2(tc.client.get()); model2.loadLayout(path); auto restored = model2.savedViewState(); REQUIRE_FALSE(restored.valid); QFile::remove(path); } TEST_CASE("ghost nodes persist in layout JSON") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100320, "ghost-persist-app", "Stream/Output/Audio", "TestApp")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100321, 100320, "output_FL", false)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100322, 100320, "input_FL", true)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); REQUIRE_FALSE(model.isGhost(model.qtNodeIdForPw(100320))); REQUIRE(tc.client->Test_RemoveGlobal(100320).ok()); model.refreshFromClient(); auto ghostQt = model.findPwNodeIdByName("ghost-persist-app"); REQUIRE(ghostQt == 100320); QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_ghosts.json"; model.saveLayout(path); WarpGraphModel model2(tc.client.get()); model2.loadLayout(path); auto ids = model2.allNodeIds(); bool foundGhost = false; for (auto id : ids) { const WarpNodeData *d = model2.warpNodeData(id); if (d && d->info.name == "ghost-persist-app") { foundGhost = true; REQUIRE(model2.isGhost(id)); REQUIRE(d->info.application_name == "TestApp"); REQUIRE(d->inputPorts.size() == 1); REQUIRE(d->outputPorts.size() == 1); REQUIRE(d->inputPorts[0].name == "input_FL"); REQUIRE(d->outputPorts[0].name == "output_FL"); break; } } REQUIRE(foundGhost); QFile::remove(path); } TEST_CASE("layout version 1 files still load") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_v1.json"; QFile file(path); REQUIRE(file.open(QIODevice::WriteOnly)); file.write(R"({"version":1,"nodes":[{"name":"legacy-node","x":10,"y":20}]})"); file.close(); WarpGraphModel model(tc.client.get()); REQUIRE(model.loadLayout(path)); auto vs = model.savedViewState(); REQUIRE_FALSE(vs.valid); QFile::remove(path); } TEST_CASE("ghost connections preserved when node becomes ghost") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100400, "gc-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100401, 100400, "in_FL", true)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100402, "gc-app", "Stream/Output/Audio", "GCApp")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100403, 100402, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100404, 100403, 100401)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto sinkQt = model.qtNodeIdForPw(100400); auto appQt = model.qtNodeIdForPw(100402); REQUIRE(model.allConnectionIds(appQt).size() == 1); REQUIRE(tc.client->Test_RemoveGlobal(100402).ok()); REQUIRE(tc.client->Test_RemoveGlobal(100404).ok()); model.refreshFromClient(); REQUIRE(model.isGhost(appQt)); REQUIRE(model.ghostConnectionExists( QtNodes::ConnectionId{appQt, 0, sinkQt, 0})); } TEST_CASE("ghost connections survive save/load round-trip") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100410, "gcrt-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100411, 100410, "in_FL", true)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100412, "gcrt-app", "Stream/Output/Audio", "GCRTApp")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100413, 100412, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100414, 100413, 100411)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); REQUIRE(tc.client->Test_RemoveGlobal(100412).ok()); REQUIRE(tc.client->Test_RemoveGlobal(100414).ok()); model.refreshFromClient(); auto appQt = model.qtNodeIdForPw(100412); REQUIRE(appQt == 0); QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_ghostconns.json"; model.saveLayout(path); WarpGraphModel model2(tc.client.get()); model2.loadLayout(path); model2.refreshFromClient(); QtNodes::NodeId sinkQt2 = 0; QtNodes::NodeId appQt2 = 0; for (auto id : model2.allNodeIds()) { const WarpNodeData *d = model2.warpNodeData(id); if (d && d->info.name == "gcrt-sink") sinkQt2 = id; if (d && d->info.name == "gcrt-app") appQt2 = id; } REQUIRE(sinkQt2 != 0); REQUIRE(appQt2 != 0); REQUIRE(model2.isGhost(appQt2)); auto conns = model2.allGhostConnectionIds(appQt2); REQUIRE(conns.size() == 1); auto conn = *conns.begin(); REQUIRE(conn.outNodeId == appQt2); REQUIRE(conn.inNodeId == sinkQt2); QFile::remove(path); } TEST_CASE("ghost connections cleaned when ghost un-ghosts") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100420, "gcug-sink", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100421, 100420, "in_FL", true)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100422, "gcug-app", "Stream/Output/Audio", "GCUGApp")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100423, 100422, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100424, 100423, 100421)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto appQt = model.qtNodeIdForPw(100422); auto sinkQt = model.qtNodeIdForPw(100420); REQUIRE(tc.client->Test_RemoveGlobal(100422).ok()); REQUIRE(tc.client->Test_RemoveGlobal(100424).ok()); model.refreshFromClient(); REQUIRE(model.isGhost(appQt)); REQUIRE(tc.client->Test_InsertNode( MakeNode(100425, "gcug-app", "Stream/Output/Audio", "GCUGApp")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100426, 100425, "out_FL", false)).ok()); model.refreshFromClient(); REQUIRE_FALSE(model.isGhost(appQt)); auto conns = model.allConnectionIds(appQt); bool hasOldGhostConn = false; for (const auto &c : conns) { if (c.outNodeId == appQt && c.inNodeId == sinkQt) hasOldGhostConn = true; } REQUIRE_FALSE(hasOldGhostConn); } TEST_CASE("clearSavedPositions resets model positions") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100340, "clear-pos-node", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto id = model.qtNodeIdForPw(100340); REQUIRE(id != 0); auto posBefore = model.nodeData(id, QtNodes::NodeRole::Position).toPointF(); model.clearSavedPositions(); model.autoArrange(); auto posAfter = model.nodeData(id, QtNodes::NodeRole::Position).toPointF(); REQUIRE(posAfter != QPointF(0, 0)); } TEST_CASE("preset save/load round-trip preserves virtual devices and layout") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100500, "preset-vsink", "Audio/Sink", {}, {}, true)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100501, 100500, "FL", true)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100502, 100500, "FR", true)).ok()); REQUIRE(tc.client->Test_InsertNode( MakeNode(100503, "preset-src", "Audio/Source")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100504, 100503, "out_FL", false)).ok()); REQUIRE(tc.client->Test_InsertLink( MakeLink(100505, 100504, 100501)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); model.setNodeData(model.qtNodeIdForPw(100500), QtNodes::NodeRole::Position, QPointF(300, 400)); QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_preset.json"; REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model)); QFile file(path); REQUIRE(file.open(QIODevice::ReadOnly)); QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); file.close(); REQUIRE(doc.isObject()); QJsonObject root = doc.object(); REQUIRE(root["version"].toInt() == 1); REQUIRE(root["virtual_devices"].toArray().size() >= 1); REQUIRE(root["routing"].toArray().size() >= 1); REQUIRE(root["layout"].toArray().size() >= 2); bool foundVsink = false; for (const auto &val : root["virtual_devices"].toArray()) { if (val.toObject()["name"].toString() == "preset-vsink") { foundVsink = true; REQUIRE(val.toObject()["channels"].toInt() == 2); } } REQUIRE(foundVsink); bool foundRoute = false; for (const auto &val : root["routing"].toArray()) { QJsonObject route = val.toObject(); if (route["out_node"].toString() == "preset-src" && route["in_node"].toString() == "preset-vsink") { foundRoute = true; } } REQUIRE(foundRoute); bool foundLayout = false; for (const auto &val : root["layout"].toArray()) { QJsonObject obj = val.toObject(); if (obj["name"].toString() == "preset-vsink") { foundLayout = true; REQUIRE(obj["x"].toDouble() == Catch::Approx(300.0)); REQUIRE(obj["y"].toDouble() == Catch::Approx(400.0)); } } REQUIRE(foundLayout); QFile::remove(path); } TEST_CASE("splitter sizes persist in layout JSON") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100510, "splitter-node", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); WarpGraphModel::ViewState vs; vs.scale = 1.0; vs.centerX = 0.0; vs.centerY = 0.0; vs.splitterGraph = 900; vs.splitterSidebar = 400; vs.valid = true; QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_splitter.json"; model.saveLayout(path, vs); WarpGraphModel model2(tc.client.get()); model2.loadLayout(path); auto restored = model2.savedViewState(); REQUIRE(restored.valid); REQUIRE(restored.splitterGraph == 900); REQUIRE(restored.splitterSidebar == 400); QFile::remove(path); } TEST_CASE("model volume state defaults to 1.0 and unmuted") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100600, "vol-default", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100600); REQUIRE(qtId != 0); auto state = model.nodeVolumeState(qtId); REQUIRE(state.volume == Catch::Approx(1.0f)); REQUIRE_FALSE(state.mute); } TEST_CASE("setNodeVolumeState updates model and calls test helper") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100610, "vol-set", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100610); REQUIRE(qtId != 0); WarpGraphModel::NodeVolumeState ns; ns.volume = 0.5f; ns.mute = true; model.setNodeVolumeState(qtId, ns); auto state = model.nodeVolumeState(qtId); REQUIRE(state.volume == Catch::Approx(0.5f)); REQUIRE(state.mute); auto apiState = tc.client->Test_GetNodeVolume(warppipe::NodeId{100610}); REQUIRE(apiState.ok()); REQUIRE(apiState.value.volume == Catch::Approx(0.5f)); REQUIRE(apiState.value.mute); } TEST_CASE("nodeVolumeChanged signal emitted on state change") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100620, "vol-signal", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100620); REQUIRE(qtId != 0); bool signalFired = false; QObject::connect(&model, &WarpGraphModel::nodeVolumeChanged, [&](QtNodes::NodeId id, WarpGraphModel::NodeVolumeState prev, WarpGraphModel::NodeVolumeState cur) { if (id == qtId) { signalFired = true; REQUIRE(prev.volume == Catch::Approx(1.0f)); REQUIRE(cur.volume == Catch::Approx(0.3f)); REQUIRE(cur.mute); } }); WarpGraphModel::NodeVolumeState ns; ns.volume = 0.3f; ns.mute = true; model.setNodeVolumeState(qtId, ns); REQUIRE(signalFired); } TEST_CASE("volume widget created for new nodes") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100630, "vol-widget", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100630); REQUIRE(qtId != 0); auto widget = model.nodeData(qtId, QtNodes::NodeRole::Widget); REQUIRE(widget.isValid()); auto *w = widget.value(); REQUIRE(w != nullptr); auto *vol = qobject_cast(w); REQUIRE(vol != nullptr); REQUIRE(vol->volume() == 100); REQUIRE_FALSE(vol->isMuted()); } TEST_CASE("setNodeVolumeState syncs inline widget") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100640, "vol-sync", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100640); auto *w = model.nodeData(qtId, QtNodes::NodeRole::Widget).value(); auto *vol = qobject_cast(w); REQUIRE(vol != nullptr); WarpGraphModel::NodeVolumeState ns; ns.volume = 0.7f; ns.mute = true; model.setNodeVolumeState(qtId, ns); // Cubic scaling: slider = cbrt(0.7) * 100 ≈ 89 REQUIRE(vol->volume() == static_cast(std::round(std::cbrt(0.7f) * 100.0f))); 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; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100650, "vol-preset", "Audio/Sink", {}, {}, true)).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100651, 100650, "FL", true)).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100650); WarpGraphModel::NodeVolumeState ns; ns.volume = 0.6f; ns.mute = true; model.setNodeVolumeState(qtId, ns); QString path = QStandardPaths::writableLocation( QStandardPaths::TempLocation) + "/warppipe_test_vol_preset.json"; REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model)); QFile file(path); REQUIRE(file.open(QIODevice::ReadOnly)); QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); file.close(); QJsonObject root = doc.object(); REQUIRE(root.contains("volumes")); QJsonArray volArr = root["volumes"].toArray(); bool found = false; for (const auto &val : volArr) { QJsonObject obj = val.toObject(); if (obj["name"].toString() == "vol-preset") { found = true; REQUIRE(obj["volume"].toDouble() == Catch::Approx(0.6)); REQUIRE(obj["mute"].toBool()); } } REQUIRE(found); tc.client->Test_SetNodeVolume(warppipe::NodeId{100650}, 1.0f, false); WarpGraphModel model2(tc.client.get()); model2.refreshFromClient(); auto qtId2 = model2.qtNodeIdForPw(100650); auto stateBefore = model2.nodeVolumeState(qtId2); REQUIRE(stateBefore.volume == Catch::Approx(1.0f)); REQUIRE(PresetManager::loadPreset(path, tc.client.get(), &model2)); auto stateAfter = model2.nodeVolumeState(qtId2); REQUIRE(stateAfter.volume == Catch::Approx(0.6f)); REQUIRE(stateAfter.mute); QFile::remove(path); } TEST_CASE("AudioLevelMeter setLevel clamps to 0-1") { ensureApp(); AudioLevelMeter meter; meter.setLevel(0.5f); REQUIRE(meter.level() == Catch::Approx(0.5f)); meter.setLevel(-0.5f); REQUIRE(meter.level() == Catch::Approx(0.0f)); meter.setLevel(1.5f); REQUIRE(meter.level() == Catch::Approx(1.0f)); } TEST_CASE("AudioLevelMeter peak hold tracks maximum") { ensureApp(); AudioLevelMeter meter; meter.setLevel(0.8f); REQUIRE(meter.peakHold() == Catch::Approx(0.8f)); meter.setLevel(0.3f); REQUIRE(meter.peakHold() == Catch::Approx(0.8f)); meter.setLevel(0.9f); REQUIRE(meter.peakHold() == Catch::Approx(0.9f)); } TEST_CASE("AudioLevelMeter peak decays after hold period") { ensureApp(); AudioLevelMeter meter; meter.setLevel(0.5f); REQUIRE(meter.peakHold() == Catch::Approx(0.5f)); for (int i = 0; i < 7; ++i) meter.setLevel(0.0f); REQUIRE(meter.peakHold() < 0.5f); REQUIRE(meter.peakHold() > 0.0f); } TEST_CASE("AudioLevelMeter resetPeakHold clears peak") { ensureApp(); AudioLevelMeter meter; meter.setLevel(0.7f); REQUIRE(meter.peakHold() == Catch::Approx(0.7f)); meter.resetPeakHold(); 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; } ensureApp(); GraphEditorWidget widget(tc.client.get()); auto *sidebar = widget.findChild(); REQUIRE(sidebar != nullptr); bool found = false; for (int i = 0; i < sidebar->count(); ++i) { if (sidebar->tabText(i) == "METERS") { found = true; break; } } REQUIRE(found); } TEST_CASE("node meter rows created for injected nodes") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100700, "meter-node", "Audio/Sink")).ok()); REQUIRE(tc.client->Test_InsertPort( MakePort(100701, 100700, "FL", true)).ok()); GraphEditorWidget widget(tc.client.get()); auto meters = widget.findChildren(); REQUIRE(meters.size() >= 3); } TEST_CASE("volume state cleaned up on node deletion") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); REQUIRE(tc.client->Test_InsertNode( MakeNode(100660, "vol-del", "Audio/Sink")).ok()); WarpGraphModel model(tc.client.get()); model.refreshFromClient(); auto qtId = model.qtNodeIdForPw(100660); WarpGraphModel::NodeVolumeState ns; ns.volume = 0.4f; model.setNodeVolumeState(qtId, ns); REQUIRE(tc.client->Test_RemoveGlobal(100660).ok()); model.refreshFromClient(); REQUIRE_FALSE(model.nodeExists(qtId)); auto state = model.nodeVolumeState(qtId); REQUIRE(state.volume == Catch::Approx(1.0f)); REQUIRE_FALSE(state.mute); } TEST_CASE("GraphEditorWidget has RULES tab") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); GraphEditorWidget widget(tc.client.get()); auto *sidebar = widget.findChild(); REQUIRE(sidebar != nullptr); bool found = false; for (int i = 0; i < sidebar->count(); ++i) { if (sidebar->tabText(i) == "RULES") { found = true; break; } } REQUIRE(found); } TEST_CASE("SetChangeCallback fires on node insert") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } std::atomic count{0}; tc.client->SetChangeCallback([&count]() { ++count; }); REQUIRE(tc.client->Test_InsertNode( MakeNode(100800, "cb-test-node", "Audio/Sink")).ok()); REQUIRE(count.load() >= 1); } TEST_CASE("SetChangeCallback fires on node remove") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } REQUIRE(tc.client->Test_InsertNode( MakeNode(100810, "cb-remove-node", "Audio/Sink")).ok()); std::atomic count{0}; tc.client->SetChangeCallback([&count]() { ++count; }); REQUIRE(tc.client->Test_RemoveGlobal(100810).ok()); REQUIRE(count.load() >= 1); } TEST_CASE("SetChangeCallback can be cleared") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } std::atomic count{0}; tc.client->SetChangeCallback([&count]() { ++count; }); tc.client->SetChangeCallback(nullptr); REQUIRE(tc.client->Test_InsertNode( MakeNode(100820, "cb-clear-node", "Audio/Sink")).ok()); REQUIRE(count.load() == 0); } TEST_CASE("sidebar tab order is METERS MIXER PRESETS RULES") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } ensureApp(); GraphEditorWidget widget(tc.client.get()); auto *sidebar = widget.findChild(); REQUIRE(sidebar != nullptr); REQUIRE(sidebar->count() >= 4); REQUIRE(sidebar->tabText(0) == "METERS"); REQUIRE(sidebar->tabText(1) == "MIXER"); REQUIRE(sidebar->tabText(2) == "PRESETS"); REQUIRE(sidebar->tabText(3) == "RULES"); }