965 lines
30 KiB
C++
965 lines
30 KiB
C++
#include <warppipe/warppipe.hpp>
|
|
|
|
#include "../../gui/GraphEditorWidget.h"
|
|
#include "../../gui/PresetManager.h"
|
|
#include "../../gui/WarpGraphModel.h"
|
|
|
|
#include <QAction>
|
|
#include <QApplication>
|
|
#include <QFile>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QStandardPaths>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/catch_approx.hpp>
|
|
|
|
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);
|
|
}
|