#include #include "../../gui/GraphEditorWidget.h" #include "../../gui/PresetManager.h" #include "../../gui/WarpGraphModel.h" #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; } } // 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 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.connectionExists( 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.allConnectionIds(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); }