warp-pipe/tests/gui/warppipe_gui_tests.cpp

1331 lines
41 KiB
C++

#include <warppipe/warppipe.hpp>
#include "../../gui/AudioLevelMeter.h"
#include "../../gui/GraphEditorWidget.h"
#include "../../gui/PresetManager.h"
#include "../../gui/VolumeWidgets.h"
#include "../../gui/WarpGraphModel.h"
#include <QAction>
#include <QApplication>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <QTabWidget>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <cmath>
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<warppipe::Client> 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<QAction *>()) {
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);
}
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<QWidget *>();
REQUIRE(w != nullptr);
auto *vol = qobject_cast<NodeVolumeWidget *>(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<QWidget *>();
auto *vol = qobject_cast<NodeVolumeWidget *>(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<int>(std::round(std::cbrt(0.7f) * 100.0f)));
REQUIRE(vol->isMuted());
}
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("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<QTabWidget *>();
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<AudioLevelMeter *>();
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<QTabWidget *>();
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<int> 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<int> 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<int> 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<QTabWidget *>();
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");
}