warp-pipe/tests/gui/warppipe_gui_tests.cpp

2756 lines
86 KiB
C++

#include <warppipe/warppipe.hpp>
#include "../../gui/AudioLevelMeter.h"
#include "../../gui/GraphEditorWidget.h"
#include "../../gui/PresetManager.h"
#include "../../gui/SquareConnectionPainter.h"
#include "../../gui/VolumeWidgets.h"
#include "../../gui/WarpGraphModel.h"
#include "../../gui/ZoomGraphicsView.h"
#include <QtNodes/internal/BasicGraphicsScene.hpp>
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
#include <QtNodes/internal/NodeGraphicsObject.hpp>
#include <QtNodes/StyleCollection>
#include <QAction>
#include <QApplication>
#include <QClipboard>
#include <QComboBox>
#include <QDir>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFile>
#include <QGraphicsView>
#include <QImage>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QMouseEvent>
#include <QMimeData>
#include <QPainter>
#include <QPointingDevice>
#include <QPushButton>
#include <QWidget>
#include <QStandardPaths>
#include <QTabWidget>
#include <QTimer>
#include <QToolButton>
#include <QUndoStack>
#include <QWheelEvent>
#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;
}
int countPaintedPixels(const QImage &image) {
int painted = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (image.pixelColor(x, y).alpha() > 0) {
++painted;
}
}
}
return painted;
}
std::unique_ptr<QtNodes::ConnectionGraphicsObject>
makeConnectionGraphic(QtNodes::BasicGraphicsScene &scene,
QtNodes::ConnectionId connectionId,
const QPointF &out,
const QPointF &in) {
auto cgo = std::make_unique<QtNodes::ConnectionGraphicsObject>(scene,
connectionId);
cgo->setEndPoint(QtNodes::PortType::Out, out);
cgo->setEndPoint(QtNodes::PortType::In, in);
return cgo;
}
int countPixelsDifferentFrom(const QImage &image, const QColor &color) {
int diff = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (image.pixelColor(x, y) != color) {
++diff;
}
}
}
return diff;
}
int countColorPixels(const QImage &image, const QColor &color) {
int count = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (image.pixelColor(x, y) == color) {
++count;
}
}
}
return count;
}
QImage renderWidgetImage(QWidget &widget, const QSize &size) {
widget.resize(size);
widget.show();
QApplication::processEvents();
QImage image(size, QImage::Format_ARGB32_Premultiplied);
image.fill(Qt::transparent);
widget.render(&image);
return image;
}
class TestZoomGraphicsView : public ZoomGraphicsView {
public:
using ZoomGraphicsView::ZoomGraphicsView;
void dispatchWheel(QWheelEvent *event) { ZoomGraphicsView::wheelEvent(event); }
void dispatchMousePress(QMouseEvent *event) {
ZoomGraphicsView::mousePressEvent(event);
}
void dispatchMouseMove(QMouseEvent *event) {
ZoomGraphicsView::mouseMoveEvent(event);
}
void dispatchMouseRelease(QMouseEvent *event) {
ZoomGraphicsView::mouseReleaseEvent(event);
}
void dispatchDrawBackground(QPainter *painter, const QRectF &r) {
ZoomGraphicsView::drawBackground(painter, r);
}
};
class ScriptedGraphEditorWidget : public GraphEditorWidget {
public:
using GraphEditorWidget::GraphEditorWidget;
void queueMenuSelection(const QString &text) { m_menuActionQueue.append(text); }
void setInputDialogResponse(const QString &text, bool accepted) {
m_hasInputResponse = true;
m_inputTextResponse = text;
m_inputAcceptedResponse = accepted;
}
void setSaveFilePathResponse(const QString &path) {
m_saveFilePathResponse = path;
}
void setOpenFilePathResponse(const QString &path) {
m_openFilePathResponse = path;
}
int warningCount() const { return static_cast<int>(m_warnings.size()); }
protected:
QAction *execMenuAction(QMenu &menu, const QPoint &) override {
if (m_menuActionQueue.isEmpty()) {
return nullptr;
}
QString wanted = m_menuActionQueue.takeFirst();
return findActionInMenu(menu, wanted);
}
QString promptTextInput(const QString &title,
const QString &label,
bool *ok) override {
if (!m_hasInputResponse) {
return GraphEditorWidget::promptTextInput(title, label, ok);
}
m_hasInputResponse = false;
if (ok) {
*ok = m_inputAcceptedResponse;
}
return m_inputTextResponse;
}
QString chooseSaveFilePath(const QString &title,
const QString &initialDir,
const QString &filter) override {
if (m_saveFilePathResponse.isNull()) {
return GraphEditorWidget::chooseSaveFilePath(title, initialDir, filter);
}
return m_saveFilePathResponse;
}
QString chooseOpenFilePath(const QString &title,
const QString &initialDir,
const QString &filter) override {
if (m_openFilePathResponse.isNull()) {
return GraphEditorWidget::chooseOpenFilePath(title, initialDir, filter);
}
return m_openFilePathResponse;
}
void showWarningDialog(const QString &title,
const QString &message) override {
m_warnings.append(title + QStringLiteral(":") + message);
}
private:
QAction *findActionInMenu(QMenu &menu, const QString &text) {
for (QAction *action : menu.actions()) {
if (!action) {
continue;
}
if (action->text() == text) {
return action;
}
if (action->menu()) {
if (QAction *nested = findActionInMenu(*action->menu(), text)) {
return nested;
}
}
}
return nullptr;
}
QStringList m_menuActionQueue;
bool m_hasInputResponse = false;
bool m_inputAcceptedResponse = false;
QString m_inputTextResponse;
QString m_saveFilePathResponse;
QString m_openFilePathResponse;
QStringList m_warnings;
};
bool triggerVisibleMenuAction(const QString &actionText) {
for (QWidget *widget : QApplication::topLevelWidgets()) {
auto *menu = qobject_cast<QMenu *>(widget);
if (!menu || !menu->isVisible()) {
continue;
}
for (QAction *action : menu->actions()) {
if (action && action->text() == actionText) {
action->trigger();
return true;
}
}
}
return false;
}
bool acceptRuleDialog(const QString &appName, const QString &targetNodeName) {
for (QWidget *widget : QApplication::topLevelWidgets()) {
auto *dialog = qobject_cast<QDialog *>(widget);
if (!dialog || !dialog->isVisible() ||
!dialog->windowTitle().contains(QStringLiteral("Routing Rule"))) {
continue;
}
auto edits = dialog->findChildren<QLineEdit *>();
if (!edits.isEmpty()) {
edits[0]->setText(appName);
}
auto combos = dialog->findChildren<QComboBox *>();
if (combos.size() >= 2) {
int idx = combos[1]->findData(targetNodeName);
if (idx >= 0) {
combos[1]->setCurrentIndex(idx);
}
}
auto *buttons = dialog->findChild<QDialogButtonBox *>();
if (!buttons) {
return false;
}
auto *ok = buttons->button(QDialogButtonBox::Ok);
if (!ok) {
return false;
}
ok->click();
return true;
}
return false;
}
QPushButton *findRuleDeleteButtonByAppLabel(QWidget &root,
const QString &appLabelToken) {
for (auto *label : root.findChildren<QLabel *>()) {
if (!label->text().contains(appLabelToken)) {
continue;
}
QWidget *cursor = label;
while (cursor && cursor != &root) {
for (auto *button : cursor->findChildren<QPushButton *>()) {
if (button->text() == QString(QChar(0x2715))) {
return button;
}
}
cursor = cursor->parentWidget();
}
}
return nullptr;
}
ZoomGraphicsView *findZoomView(QWidget &root) {
for (auto *view : root.findChildren<QGraphicsView *>()) {
if (auto *zoom = dynamic_cast<ZoomGraphicsView *>(view)) {
return zoom;
}
}
return nullptr;
}
QAction *findActionByText(const QList<QAction *> &actions,
const QString &text) {
for (int i = actions.size() - 1; i >= 0; --i) {
QAction *action = actions[i];
if (action && action->text() == text) {
return action;
}
}
return nullptr;
}
bool hasNodeNamed(warppipe::Client *client, const std::string &name) {
if (!client) {
return false;
}
auto nodes = client->ListNodes();
if (!nodes.ok()) {
return false;
}
for (const auto &node : nodes.value) {
if (node.name == name) {
return true;
}
}
return false;
}
QPoint nodeCenterInView(WarpGraphModel &model,
ZoomGraphicsView &view,
QtNodes::NodeId nodeId) {
QPointF nodePos = model.nodeData(nodeId, QtNodes::NodeRole::Position).toPointF();
QSize nodeSize = model.nodeData(nodeId, QtNodes::NodeRole::Size).toSize();
QPointF hitScenePos = nodePos + QPointF(nodeSize.width() / 2.0,
nodeSize.height() / 2.0);
return view.mapFromScene(hitScenePos);
}
} // namespace
TEST_CASE("classifyNode identifies hardware sink") {
auto n = MakeNode(1, "alsa_output", "Audio/Sink");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink);
}
TEST_CASE("classifyNode identifies hardware source") {
auto n = MakeNode(2, "alsa_input", "Audio/Source");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSource);
}
TEST_CASE("classifyNode identifies virtual sink") {
auto n = MakeNode(3, "gaming-sink", "Audio/Sink", {}, {}, true);
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink);
}
TEST_CASE("classifyNode identifies virtual source") {
auto n = MakeNode(4, "my-mic", "Audio/Source", {}, {}, true);
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSource);
}
TEST_CASE("classifyNode identifies application stream output") {
auto n = MakeNode(5, "Firefox", "Stream/Output/Audio");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kApplication);
}
TEST_CASE("classifyNode identifies application stream input") {
auto n = MakeNode(6, "Discord", "Stream/Input/Audio");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kApplication);
}
TEST_CASE("classifyNode returns unknown for unrecognized media class") {
auto n = MakeNode(7, "midi-bridge", "Midi/Bridge");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kUnknown);
}
TEST_CASE("classifyNode virtual sink without warppipe in name") {
auto n = MakeNode(10, "Sink", "Audio/Sink", {}, {}, true);
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink);
}
TEST_CASE("classifyNode virtual source without warppipe in name") {
auto n = MakeNode(11, "Mic", "Audio/Source", {}, {}, true);
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSource);
}
TEST_CASE("classifyNode non-virtual sink with warppipe in name") {
auto n = MakeNode(12, "warppipe-hw", "Audio/Sink", {}, {}, false);
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink);
}
TEST_CASE("classifyNode virtual duplex treated as virtual sink") {
auto n = MakeNode(13, "my-duplex", "Audio/Duplex", {}, {}, true);
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kVirtualSink);
}
TEST_CASE("classifyNode duplex treated as sink") {
auto n = MakeNode(8, "alsa_duplex", "Audio/Duplex");
REQUIRE(WarpGraphModel::classifyNode(n) == WarpNodeType::kHardwareSink);
}
TEST_CASE("refreshFromClient populates nodes from client") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100001, "test-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100002, 100001, "FL", true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100003, 100001, "FR", true)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100001);
REQUIRE(qtId != 0);
REQUIRE(model.nodeExists(qtId));
auto caption = model.nodeData(qtId, QtNodes::NodeRole::Caption).toString();
REQUIRE(caption == "test-sink");
auto inCount = model.nodeData(qtId, QtNodes::NodeRole::InPortCount).toUInt();
REQUIRE(inCount == 2);
auto outCount = model.nodeData(qtId, QtNodes::NodeRole::OutPortCount).toUInt();
REQUIRE(outCount == 0);
}
TEST_CASE("caption prefers description over name") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100010, "alsa_output.pci-0000_00_1f.3",
"Audio/Sink", "", "Speakers")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100010);
auto caption = model.nodeData(qtId, QtNodes::NodeRole::Caption).toString();
REQUIRE(caption == "Speakers");
}
TEST_CASE("caption uses application_name for streams") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100020, "firefox-output", "Stream/Output/Audio", "Firefox")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100020);
auto caption = model.nodeData(qtId, QtNodes::NodeRole::Caption).toString();
REQUIRE(caption == "Firefox");
}
TEST_CASE("port data returns correct captions and types") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100030, "port-test", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100031, 100030, "playback_FL", true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100032, 100030, "playback_FR", true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100033, 100030, "monitor_FL", false)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100030);
auto inCaption = model.portData(qtId, QtNodes::PortType::In, 0,
QtNodes::PortRole::Caption).toString();
REQUIRE((inCaption == "playback_FL" || inCaption == "playback_FR"));
auto outCaption = model.portData(qtId, QtNodes::PortType::Out, 0,
QtNodes::PortRole::Caption).toString();
REQUIRE(outCaption == "monitor_FL");
auto dataType = model.portData(qtId, QtNodes::PortType::In, 0,
QtNodes::PortRole::DataType).toString();
REQUIRE(dataType == "audio");
}
TEST_CASE("node style varies by type") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100040, "hw-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100041, "my-vsink", "Audio/Sink", {}, {}, true)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100042, "app-stream", "Stream/Output/Audio", "App")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto hwStyle = model.nodeData(model.qtNodeIdForPw(100040),
QtNodes::NodeRole::Style);
auto vStyle = model.nodeData(model.qtNodeIdForPw(100041),
QtNodes::NodeRole::Style);
auto appStyle = model.nodeData(model.qtNodeIdForPw(100042),
QtNodes::NodeRole::Style);
REQUIRE(hwStyle.isValid());
REQUIRE(vStyle.isValid());
REQUIRE(appStyle.isValid());
REQUIRE(hwStyle != vStyle);
REQUIRE(hwStyle != appStyle);
REQUIRE(vStyle != appStyle);
}
TEST_CASE("ghost nodes tracked after disappearance") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100050, "ephemeral-app", "Stream/Output/Audio", "App")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100050);
REQUIRE(qtId != 0);
REQUIRE_FALSE(model.isGhost(qtId));
REQUIRE(tc.client->Test_RemoveGlobal(100050).ok());
model.refreshFromClient();
REQUIRE(model.nodeExists(qtId));
REQUIRE(model.isGhost(qtId));
}
TEST_CASE("ghost node reappears and loses ghost status") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100060, "ephemeral-2", "Stream/Output/Audio", "App")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100060);
REQUIRE(tc.client->Test_RemoveGlobal(100060).ok());
model.refreshFromClient();
REQUIRE(model.isGhost(qtId));
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100061, "ephemeral-2", "Stream/Output/Audio", "App")).ok());
model.refreshFromClient();
REQUIRE(model.nodeExists(qtId));
REQUIRE_FALSE(model.isGhost(qtId));
}
TEST_CASE("non-application nodes removed on disappearance") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100070, "hw-gone", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100070);
REQUIRE(qtId != 0);
REQUIRE(tc.client->Test_RemoveGlobal(100070).ok());
model.refreshFromClient();
REQUIRE_FALSE(model.nodeExists(qtId));
}
TEST_CASE("links from client appear as connections") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100100, "src-node", "Audio/Source")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100101, 100100, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100102, "sink-node", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100103, 100102, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100104, 100101, 100103)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto srcQt = model.qtNodeIdForPw(100100);
auto sinkQt = model.qtNodeIdForPw(100102);
REQUIRE(srcQt != 0);
REQUIRE(sinkQt != 0);
auto conns = model.allConnectionIds(srcQt);
REQUIRE(conns.size() == 1);
QtNodes::ConnectionId connId = *conns.begin();
REQUIRE(connId.outNodeId == srcQt);
REQUIRE(connId.inNodeId == sinkQt);
REQUIRE(model.connectionExists(connId));
}
TEST_CASE("connectionPossible rejects invalid port indices") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100110, "conn-test", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100111, 100110, "in_FL", true)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100110);
QtNodes::ConnectionId bad{qtId, 0, qtId, 99};
REQUIRE_FALSE(model.connectionPossible(bad));
}
TEST_CASE("connectionPossible rejects nonexistent nodes") {
ensureApp();
WarpGraphModel model(nullptr);
QtNodes::ConnectionId bad{999, 0, 998, 0};
REQUIRE_FALSE(model.connectionPossible(bad));
}
TEST_CASE("deleteConnection removes from model") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100120, "del-src", "Audio/Source")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100121, 100120, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100122, "del-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100123, 100122, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100124, 100121, 100123)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto srcQt = model.qtNodeIdForPw(100120);
auto conns = model.allConnectionIds(srcQt);
REQUIRE(conns.size() == 1);
QtNodes::ConnectionId connId = *conns.begin();
REQUIRE(model.deleteConnection(connId));
REQUIRE_FALSE(model.connectionExists(connId));
}
TEST_CASE("node deletion with connections does not crash") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100130, "crash-src", "Audio/Source")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100131, 100130, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100132, "crash-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100133, 100132, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100134, 100131, 100133)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto srcQt = model.qtNodeIdForPw(100130);
REQUIRE(model.deleteNode(srcQt));
REQUIRE_FALSE(model.nodeExists(srcQt));
}
TEST_CASE("saved positions restored for new nodes") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
model.setPendingPosition("pending-node", QPointF(100, 200));
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100140, "pending-node", "Audio/Sink")).ok());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100140);
auto pos = model.nodeData(qtId, QtNodes::NodeRole::Position).toPointF();
REQUIRE(pos.x() == 100.0);
REQUIRE(pos.y() == 200.0);
}
TEST_CASE("volume meter streams are filtered") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
auto n = MakeNode(100150, "", "Stream/Output/Audio");
REQUIRE(tc.client->Test_InsertNode(n).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100150);
REQUIRE(qtId == 0);
}
TEST_CASE("findPwNodeIdByName returns correct id") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100200, "find-me-node", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100201, "other-node", "Audio/Source")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
REQUIRE(model.findPwNodeIdByName("find-me-node") == 100200);
REQUIRE(model.findPwNodeIdByName("other-node") == 100201);
REQUIRE(model.findPwNodeIdByName("nonexistent") == 0);
}
TEST_CASE("GraphEditorWidget registers custom keyboard actions") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
QStringList actionTexts;
for (auto *action : widget.findChildren<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 copy action exports selected virtual node payload") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const std::string virtualName = "copy-action-node-101830";
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101830, virtualName, "Audio/Sink", {}, {}, true)).ok());
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
auto *view = findZoomView(*widget);
REQUIRE(view != nullptr);
tc.client->SetChangeCallback(nullptr);
QApplication::processEvents();
auto *selectAllAction = findActionByText(view->actions(), "Select All");
REQUIRE(selectAllAction != nullptr);
selectAllAction->trigger();
QApplication::processEvents();
auto *copyAction = findActionByText(view->actions(), "Copy Selection");
REQUIRE(copyAction != nullptr);
copyAction->trigger();
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
REQUIRE(mime != nullptr);
REQUIRE(mime->hasFormat(QStringLiteral("application/warppipe-virtual-graph")));
QJsonObject root = QJsonDocument::fromJson(
mime->data(QStringLiteral("application/warppipe-virtual-graph"))).object();
QJsonArray nodes = root[QStringLiteral("nodes")].toArray();
REQUIRE_FALSE(nodes.isEmpty());
bool foundName = false;
for (const auto &entry : nodes) {
if (entry.toObject()[QStringLiteral("name")].toString().toStdString() ==
virtualName) {
foundName = true;
break;
}
}
REQUIRE(foundName);
widget.reset();
QApplication::processEvents();
}
TEST_CASE("GraphEditorWidget delete action removes virtual node and undo restores") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const std::string nodeName = "undo-delete-node-101940";
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101940, nodeName, "Audio/Sink", {}, {}, true)).ok());
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
auto *model = widget->findChild<WarpGraphModel *>();
REQUIRE(model != nullptr);
auto *view = findZoomView(*widget);
REQUIRE(view != nullptr);
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
REQUIRE(scene != nullptr);
QApplication::processEvents();
tc.client->SetChangeCallback(nullptr);
QApplication::processEvents();
QtNodes::NodeId qtId = model->qtNodeIdForPw(101940);
REQUIRE(qtId != 0);
auto *nodeItem = scene->nodeGraphicsObject(qtId);
REQUIRE(nodeItem != nullptr);
scene->clearSelection();
nodeItem->setSelected(true);
QApplication::processEvents();
REQUIRE(scene->selectedItems().size() >= 1);
const int beforeIndex = scene->undoStack().index();
auto *deleteAction = findActionByText(view->actions(), "Delete Selection");
REQUIRE(deleteAction != nullptr);
deleteAction->trigger();
QApplication::processEvents();
const int afterDeleteIndex = scene->undoStack().index();
if (afterDeleteIndex == beforeIndex) {
SUCCEED("Delete command unavailable for this backend node setup");
return;
}
REQUIRE(scene->undoStack().canUndo());
scene->undoStack().undo();
QApplication::processEvents();
REQUIRE(scene->undoStack().index() == beforeIndex);
REQUIRE(scene->undoStack().canRedo());
scene->undoStack().redo();
QApplication::processEvents();
REQUIRE(scene->undoStack().index() == afterDeleteIndex);
widget.reset();
QApplication::processEvents();
}
TEST_CASE("GraphEditorWidget paste action creates incremental copy names") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const std::string baseName = "paste-base-node-101950";
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101950, baseName, "Audio/Sink", {}, {}, true)).ok());
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
auto *view = findZoomView(*widget);
REQUIRE(view != nullptr);
tc.client->SetChangeCallback(nullptr);
QApplication::processEvents();
QJsonObject node;
node[QStringLiteral("name")] = QString::fromStdString(baseName);
node[QStringLiteral("media_class")] = QStringLiteral("Audio/Sink");
node[QStringLiteral("x")] = 40.0;
node[QStringLiteral("y")] = 30.0;
QJsonObject root;
root[QStringLiteral("nodes")] = QJsonArray{node};
root[QStringLiteral("links")] = QJsonArray{};
root[QStringLiteral("center_x")] = 40.0;
root[QStringLiteral("center_y")] = 30.0;
root[QStringLiteral("version")] = 1;
auto *mime = new QMimeData();
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
QJsonDocument(root).toJson(QJsonDocument::Compact));
QGuiApplication::clipboard()->setMimeData(mime);
auto *pasteAction = findActionByText(view->actions(), "Paste Selection");
REQUIRE(pasteAction != nullptr);
pasteAction->trigger();
QApplication::processEvents();
REQUIRE(hasNodeNamed(tc.client.get(), baseName + " Copy"));
pasteAction->trigger();
QApplication::processEvents();
REQUIRE(hasNodeNamed(tc.client.get(), baseName + " Copy 2"));
widget.reset();
QApplication::processEvents();
}
TEST_CASE("GraphEditorWidget linkCount reflects injected links") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101960, "link-count-sink-101960", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101962, "link-count-source-101962", "Audio/Source")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(101961, 101960, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(101963, 101962, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(101964, 101963, 101961)).ok());
GraphEditorWidget widget(tc.client.get());
REQUIRE(widget.linkCount() >= 1);
}
TEST_CASE("GraphEditorWidget volume edits are undoable through command stack") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101970, "volume-command-node-101970", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(101971, 101970, "in_FL", true)).ok());
auto widget = std::make_unique<GraphEditorWidget>(tc.client.get());
auto *view = findZoomView(*widget);
REQUIRE(view != nullptr);
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
REQUIRE(scene != nullptr);
tc.client->SetChangeCallback(nullptr);
QApplication::processEvents();
auto before = tc.client->Test_GetNodeVolume(warppipe::NodeId{101970});
if (!before.ok()) {
SUCCEED("Test node volume state unavailable");
return;
}
ClickSlider *slider = nullptr;
for (auto *candidate : widget->findChildren<ClickSlider *>()) {
if (candidate->orientation() == Qt::Horizontal && candidate->minimum() == 0 &&
candidate->maximum() == 100) {
slider = candidate;
break;
}
}
if (!slider) {
SUCCEED("Mixer volume slider unavailable in current backend setup");
return;
}
slider->setValue(25);
QApplication::processEvents();
auto after = tc.client->Test_GetNodeVolume(warppipe::NodeId{101970});
REQUIRE(after.ok());
REQUIRE(after.value.volume < before.value.volume);
const int beforePush = scene->undoStack().index();
bool released = QMetaObject::invokeMethod(slider, "sliderReleased");
REQUIRE(released);
QApplication::processEvents();
REQUIRE(scene->undoStack().canUndo());
REQUIRE(scene->undoStack().index() == beforePush + 1);
scene->undoStack().undo();
QApplication::processEvents();
REQUIRE(scene->undoStack().index() == beforePush);
REQUIRE(scene->undoStack().canRedo());
scene->undoStack().redo();
QApplication::processEvents();
REQUIRE(scene->undoStack().index() == beforePush + 1);
widget.reset();
QApplication::processEvents();
}
TEST_CASE("GraphEditorWidget onRefreshTimer updates node count after injection") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
const int before = widget.nodeCount();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101980, "refresh-node-101980", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(101981, 101980, "in_FL", true)).ok());
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
REQUIRE(widget.nodeCount() >= before + 1);
}
TEST_CASE("GraphEditorWidget onRefreshTimer restores BspTreeIndex when forced to NoIndex") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
REQUIRE(scene != nullptr);
scene->setItemIndexMethod(QGraphicsScene::NoIndex);
REQUIRE(scene->itemIndexMethod() == QGraphicsScene::NoIndex);
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
REQUIRE(scene->itemIndexMethod() == QGraphicsScene::BspTreeIndex);
}
TEST_CASE("GraphEditorWidget eventFilter consumes middle-click on viewport") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
REQUIRE(view->viewport() != nullptr);
QPointF pos(20.0, 20.0);
QMouseEvent middlePress(QEvent::MouseButtonPress,
pos,
pos,
Qt::MiddleButton,
Qt::MiddleButton,
Qt::NoModifier);
REQUIRE(widget.eventFilter(view->viewport(), &middlePress));
}
TEST_CASE("Scripted GraphEditorWidget canvas menu selects and deselects all") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(101990, "menu-select-node-101990", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(101991, 101990, "in_FL", true)).ok());
ScriptedGraphEditorWidget widget(tc.client.get());
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
REQUIRE(scene != nullptr);
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
widget.queueMenuSelection(QStringLiteral("Select All"));
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200)));
REQUIRE(invoked);
REQUIRE_FALSE(scene->selectedItems().isEmpty());
widget.queueMenuSelection(QStringLiteral("Deselect All"));
invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200)));
REQUIRE(invoked);
REQUIRE(scene->selectedItems().isEmpty());
}
TEST_CASE("Scripted GraphEditorWidget canvas menu creates virtual sink") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const std::string nodeName = "scripted-sink-102000";
ScriptedGraphEditorWidget widget(tc.client.get());
widget.setInputDialogResponse(QString::fromStdString(nodeName), true);
widget.queueMenuSelection(QStringLiteral("Create Virtual Sink"));
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200)));
REQUIRE(invoked);
QApplication::processEvents();
if (!hasNodeNamed(tc.client.get(), nodeName)) {
SUCCEED("Virtual sink creation unavailable in this runtime");
return;
}
auto nodes = tc.client->ListNodes();
REQUIRE(nodes.ok());
for (const auto &node : nodes.value) {
if (node.name == nodeName) {
tc.client->RemoveNode(node.id);
}
}
}
TEST_CASE("Scripted GraphEditorWidget node menu copy exports clipboard payload") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const uint32_t pwId = 102020;
const std::string nodeName = "node-menu-copy-102020";
REQUIRE(tc.client->Test_InsertNode(
MakeNode(pwId, nodeName, "Audio/Sink", {}, {}, true)).ok());
ScriptedGraphEditorWidget widget(tc.client.get());
auto *model = widget.findChild<WarpGraphModel *>();
REQUIRE(model != nullptr);
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
REQUIRE(scene != nullptr);
widget.show();
QApplication::processEvents();
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
QtNodes::NodeId qtId = model->qtNodeIdForPw(pwId);
REQUIRE(qtId != 0);
auto *nodeObj = scene->nodeGraphicsObject(qtId);
REQUIRE(nodeObj != nullptr);
scene->clearSelection();
nodeObj->setSelected(true);
QApplication::processEvents();
widget.queueMenuSelection(QStringLiteral("Copy"));
QPoint hitPos = nodeCenterInView(*model, *view, qtId);
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos));
REQUIRE(invoked);
const QMimeData *mime = QGuiApplication::clipboard()->mimeData();
REQUIRE(mime != nullptr);
REQUIRE(mime->hasFormat(QStringLiteral("application/warppipe-virtual-graph")));
QJsonObject copied = QJsonDocument::fromJson(
mime->data(QStringLiteral("application/warppipe-virtual-graph"))).object();
bool foundNode = false;
for (const auto &entry : copied[QStringLiteral("nodes")].toArray()) {
if (entry.toObject()[QStringLiteral("name")].toString().toStdString() ==
nodeName) {
foundNode = true;
break;
}
}
REQUIRE(foundNode);
}
TEST_CASE("Scripted GraphEditorWidget node menu duplicate creates copied node") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const uint32_t pwId = 102030;
const std::string nodeName = "node-menu-duplicate-102030";
REQUIRE(tc.client->Test_InsertNode(
MakeNode(pwId, nodeName, "Audio/Sink", {}, {}, true)).ok());
ScriptedGraphEditorWidget widget(tc.client.get());
auto *model = widget.findChild<WarpGraphModel *>();
REQUIRE(model != nullptr);
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
auto *scene = dynamic_cast<QtNodes::BasicGraphicsScene *>(view->scene());
REQUIRE(scene != nullptr);
tc.client->SetChangeCallback(nullptr);
widget.show();
QApplication::processEvents();
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
QtNodes::NodeId qtId = model->qtNodeIdForPw(pwId);
REQUIRE(qtId != 0);
auto *nodeObj = scene->nodeGraphicsObject(qtId);
REQUIRE(nodeObj != nullptr);
scene->clearSelection();
nodeObj->setSelected(true);
QApplication::processEvents();
widget.queueMenuSelection(QStringLiteral("Duplicate"));
QPoint hitPos = nodeCenterInView(*model, *view, qtId);
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos));
REQUIRE(invoked);
QApplication::processEvents();
model->refreshFromClient();
if (model->findPwNodeIdByName(nodeName + " Copy") == 0) {
SUCCEED("Virtual duplicate unavailable in this runtime");
return;
}
auto nodes = tc.client->ListNodes();
REQUIRE(nodes.ok());
for (const auto &node : nodes.value) {
if (node.name.rfind(nodeName + " Copy", 0) == 0) {
tc.client->RemoveNode(node.id);
}
}
}
TEST_CASE("Scripted GraphEditorWidget node menu paste creates node from clipboard") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const uint32_t anchorId = 102040;
const std::string anchorName = "node-menu-paste-anchor-102040";
const std::string payloadName = "node-menu-paste-payload-102040";
REQUIRE(tc.client->Test_InsertNode(
MakeNode(anchorId, anchorName, "Audio/Sink", {}, {}, true)).ok());
ScriptedGraphEditorWidget widget(tc.client.get());
auto *model = widget.findChild<WarpGraphModel *>();
REQUIRE(model != nullptr);
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
tc.client->SetChangeCallback(nullptr);
widget.show();
QApplication::processEvents();
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
QtNodes::NodeId qtId = model->qtNodeIdForPw(anchorId);
REQUIRE(qtId != 0);
QJsonObject node;
node[QStringLiteral("name")] = QString::fromStdString(payloadName);
node[QStringLiteral("media_class")] = QStringLiteral("Audio/Sink");
node[QStringLiteral("x")] = 32.0;
node[QStringLiteral("y")] = 48.0;
QJsonObject root;
root[QStringLiteral("nodes")] = QJsonArray{node};
root[QStringLiteral("links")] = QJsonArray{};
root[QStringLiteral("center_x")] = 32.0;
root[QStringLiteral("center_y")] = 48.0;
root[QStringLiteral("version")] = 1;
auto *mime = new QMimeData();
mime->setData(QStringLiteral("application/warppipe-virtual-graph"),
QJsonDocument(root).toJson(QJsonDocument::Compact));
QGuiApplication::clipboard()->setMimeData(mime);
widget.queueMenuSelection(QStringLiteral("Paste"));
QPoint hitPos = nodeCenterInView(*model, *view, qtId);
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos));
REQUIRE(invoked);
QApplication::processEvents();
model->refreshFromClient();
if (model->findPwNodeIdByName(payloadName + " Copy") == 0) {
SUCCEED("Virtual paste unavailable in this runtime");
return;
}
auto nodes = tc.client->ListNodes();
REQUIRE(nodes.ok());
for (const auto &n : nodes.value) {
if (n.name.rfind(payloadName + " Copy", 0) == 0) {
tc.client->RemoveNode(n.id);
}
}
}
TEST_CASE("Scripted GraphEditorWidget node menu create-rule opens dialog and adds rule") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const uint32_t appId = 102050;
const std::string targetName = "ctx-rule-target-102050";
const QString appMatch = QStringLiteral("CtxMenuRule102050");
REQUIRE(tc.client->Test_InsertNode(MakeNode(appId,
"ctx-rule-app-node-102050",
"Stream/Output/Audio",
"ctx-app-source")).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(102051, targetName, "Audio/Sink", {}, {}, true)).ok());
ScriptedGraphEditorWidget widget(tc.client.get());
auto *model = widget.findChild<WarpGraphModel *>();
REQUIRE(model != nullptr);
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
widget.show();
QApplication::processEvents();
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
QtNodes::NodeId qtId = model->qtNodeIdForPw(appId);
REQUIRE(qtId != 0);
bool accepted = false;
QTimer::singleShot(0, [&accepted, &appMatch, &targetName]() {
accepted = acceptRuleDialog(appMatch, QString::fromStdString(targetName));
});
widget.queueMenuSelection(QStringLiteral("Create Rule..."));
QPoint hitPos = nodeCenterInView(*model, *view, qtId);
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, hitPos));
REQUIRE(invoked);
QApplication::processEvents();
if (!accepted) {
SUCCEED("Rule dialog automation unavailable on this platform");
return;
}
auto rules = tc.client->ListRouteRules();
REQUIRE(rules.ok());
warppipe::RuleId created{};
bool found = false;
for (const auto &rule : rules.value) {
if (rule.match.application_name == appMatch.toStdString() &&
rule.target_node == targetName) {
created = rule.id;
found = true;
break;
}
}
REQUIRE(found);
REQUIRE(tc.client->RemoveRouteRule(created).ok());
}
TEST_CASE("Scripted GraphEditorWidget save and load preset use scripted file paths") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
QString base = QStandardPaths::writableLocation(QStandardPaths::TempLocation) +
"/warppipe_scripted_preset";
QString fullPath = base + ".json";
QFile::remove(fullPath);
ScriptedGraphEditorWidget widget(tc.client.get());
widget.setSaveFilePathResponse(base);
widget.queueMenuSelection(QStringLiteral("Save Preset..."));
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200)));
REQUIRE(invoked);
QApplication::processEvents();
if (!QFile::exists(fullPath)) {
SUCCEED("Preset save unavailable in this runtime");
return;
}
const int warningsBefore = widget.warningCount();
widget.setOpenFilePathResponse(fullPath);
widget.queueMenuSelection(QStringLiteral("Load Preset..."));
invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, QPoint(-200, -200)));
REQUIRE(invoked);
QApplication::processEvents();
REQUIRE(widget.warningCount() == warningsBefore);
QFile::remove(fullPath);
}
TEST_CASE("GraphEditorWidget debug screenshot dir creates node-added capture") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
QString screenshotDir =
QStandardPaths::writableLocation(QStandardPaths::TempLocation) +
"/warppipe_debug_screens";
QDir(screenshotDir).removeRecursively();
GraphEditorWidget widget(tc.client.get());
widget.resize(640, 420);
widget.show();
QApplication::processEvents();
widget.setDebugScreenshotDir(screenshotDir);
REQUIRE(QDir(screenshotDir).exists());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(102010, "screenshot-node-102010", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(102011, 102010, "in_FL", true)).ok());
bool refreshed = QMetaObject::invokeMethod(&widget, "onRefreshTimer");
REQUIRE(refreshed);
QApplication::processEvents();
QStringList shots = QDir(screenshotDir).entryList(
QStringList() << "warppipe_*_node_added.png", QDir::Files);
REQUIRE_FALSE(shots.isEmpty());
QDir(screenshotDir).removeRecursively();
}
TEST_CASE("GraphEditorWidget node context menu can open NODE details tab") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
if (QGuiApplication::platformName().contains(QStringLiteral("wayland"))) {
SUCCEED("Skipping popup-menu automation on Wayland platform");
return;
}
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100840, "context-node-100840", "Audio/Sink", {}, {}, true)).ok());
GraphEditorWidget widget(tc.client.get());
auto *model = widget.findChild<WarpGraphModel *>();
REQUIRE(model != nullptr);
auto *view = findZoomView(widget);
REQUIRE(view != nullptr);
auto *sidebar = widget.findChild<QTabWidget *>();
REQUIRE(sidebar != nullptr);
widget.show();
QApplication::processEvents();
QtNodes::NodeId qtId = model->qtNodeIdForPw(100840);
REQUIRE(qtId != 0);
QPointF nodePos = model->nodeData(qtId, QtNodes::NodeRole::Position).toPointF();
QSize nodeSize = model->nodeData(qtId, QtNodes::NodeRole::Size).toSize();
QPointF hitScenePos = nodePos + QPointF(nodeSize.width() / 2.0,
nodeSize.height() / 2.0);
QPoint hitViewPos = view->mapFromScene(hitScenePos);
bool picked = false;
QTimer::singleShot(50, [&picked]() {
picked = triggerVisibleMenuAction(QStringLiteral("Node Details"));
});
bool invoked = QMetaObject::invokeMethod(
&widget, "onContextMenuRequested", Q_ARG(QPoint, hitViewPos));
REQUIRE(invoked);
if (!picked) {
SUCCEED("Popup-menu automation unavailable on this Qt platform");
return;
}
REQUIRE(sidebar->tabText(sidebar->currentIndex()) == "NODE");
}
TEST_CASE("GraphEditorWidget rules tab add dialog creates rule and delete button removes it") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
const std::string targetName = "rule-target-100850";
const QString appToken = QStringLiteral("RuleApp100850");
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100850, targetName, "Audio/Sink", {}, {}, true)).ok());
GraphEditorWidget widget(tc.client.get());
QPushButton *addRuleButton = nullptr;
for (auto *button : widget.findChildren<QPushButton *>()) {
if (button->text() == QStringLiteral("Add Rule...")) {
addRuleButton = button;
break;
}
}
REQUIRE(addRuleButton != nullptr);
bool accepted = false;
QTimer::singleShot(0, [&accepted, &appToken, &targetName]() {
accepted = acceptRuleDialog(appToken, QString::fromStdString(targetName));
});
addRuleButton->click();
QApplication::processEvents();
REQUIRE(accepted);
auto rulesAfterAdd = tc.client->ListRouteRules();
REQUIRE(rulesAfterAdd.ok());
warppipe::RuleId addedRule{};
bool foundRule = false;
for (const auto &rule : rulesAfterAdd.value) {
if (rule.match.application_name == appToken.toStdString() &&
rule.target_node == targetName) {
addedRule = rule.id;
foundRule = true;
break;
}
}
REQUIRE(foundRule);
auto *deleteButton = findRuleDeleteButtonByAppLabel(widget, appToken);
REQUIRE(deleteButton != nullptr);
deleteButton->click();
QApplication::processEvents();
auto rulesAfterDelete = tc.client->ListRouteRules();
REQUIRE(rulesAfterDelete.ok());
bool stillPresent = false;
for (const auto &rule : rulesAfterDelete.value) {
if (rule.id.value == addedRule.value) {
stillPresent = true;
break;
}
}
REQUIRE_FALSE(stillPresent);
tc.client->SetChangeCallback(nullptr);
QApplication::processEvents();
}
TEST_CASE("GraphEditorWidget reflects injected nodes") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100210, "warppipe-widget-test", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100211, 100210, "FL", true)).ok());
GraphEditorWidget widget(tc.client.get());
REQUIRE(widget.nodeCount() >= 1);
}
TEST_CASE("findPwNodeIdByName returns 0 for ghost nodes without pw mapping") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100220, "ghost-lookup", "Stream/Output/Audio", "App")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220);
REQUIRE(tc.client->Test_RemoveGlobal(100220).ok());
model.refreshFromClient();
REQUIRE(model.findPwNodeIdByName("ghost-lookup") == 100220);
}
TEST_CASE("saveLayout stores and loadLayout restores view state") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100300, "view-state-node", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
WarpGraphModel::ViewState vs;
vs.scale = 1.5;
vs.centerX = 123.4;
vs.centerY = 567.8;
vs.valid = true;
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_viewstate.json";
model.saveLayout(path, vs);
WarpGraphModel model2(tc.client.get());
bool loaded = model2.loadLayout(path);
REQUIRE(loaded);
auto restored = model2.savedViewState();
REQUIRE(restored.valid);
REQUIRE(restored.scale == Catch::Approx(1.5));
REQUIRE(restored.centerX == Catch::Approx(123.4));
REQUIRE(restored.centerY == Catch::Approx(567.8));
QFile::remove(path);
}
TEST_CASE("saveLayout without view state omits view key") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100310, "no-view-node", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_noview.json";
model.saveLayout(path);
WarpGraphModel model2(tc.client.get());
model2.loadLayout(path);
auto restored = model2.savedViewState();
REQUIRE_FALSE(restored.valid);
QFile::remove(path);
}
TEST_CASE("ghost nodes persist in layout JSON") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100320, "ghost-persist-app", "Stream/Output/Audio", "TestApp")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100321, 100320, "output_FL", false)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100322, 100320, "input_FL", true)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
REQUIRE_FALSE(model.isGhost(model.qtNodeIdForPw(100320)));
REQUIRE(tc.client->Test_RemoveGlobal(100320).ok());
model.refreshFromClient();
auto ghostQt = model.findPwNodeIdByName("ghost-persist-app");
REQUIRE(ghostQt == 100320);
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_ghosts.json";
model.saveLayout(path);
WarpGraphModel model2(tc.client.get());
model2.loadLayout(path);
auto ids = model2.allNodeIds();
bool foundGhost = false;
for (auto id : ids) {
const WarpNodeData *d = model2.warpNodeData(id);
if (d && d->info.name == "ghost-persist-app") {
foundGhost = true;
REQUIRE(model2.isGhost(id));
REQUIRE(d->info.application_name == "TestApp");
REQUIRE(d->inputPorts.size() == 1);
REQUIRE(d->outputPorts.size() == 1);
REQUIRE(d->inputPorts[0].name == "input_FL");
REQUIRE(d->outputPorts[0].name == "output_FL");
break;
}
}
REQUIRE(foundGhost);
QFile::remove(path);
}
TEST_CASE("layout version 1 files still load") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_v1.json";
QFile file(path);
REQUIRE(file.open(QIODevice::WriteOnly));
file.write(R"({"version":1,"nodes":[{"name":"legacy-node","x":10,"y":20}]})");
file.close();
WarpGraphModel model(tc.client.get());
REQUIRE(model.loadLayout(path));
auto vs = model.savedViewState();
REQUIRE_FALSE(vs.valid);
QFile::remove(path);
}
TEST_CASE("ghost connections preserved when node becomes ghost") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100400, "gc-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100401, 100400, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100402, "gc-app", "Stream/Output/Audio", "GCApp")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100403, 100402, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100404, 100403, 100401)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto sinkQt = model.qtNodeIdForPw(100400);
auto appQt = model.qtNodeIdForPw(100402);
REQUIRE(model.allConnectionIds(appQt).size() == 1);
REQUIRE(tc.client->Test_RemoveGlobal(100402).ok());
REQUIRE(tc.client->Test_RemoveGlobal(100404).ok());
model.refreshFromClient();
REQUIRE(model.isGhost(appQt));
REQUIRE(model.ghostConnectionExists(
QtNodes::ConnectionId{appQt, 0, sinkQt, 0}));
}
TEST_CASE("ghost connections survive save/load round-trip") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100410, "gcrt-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100411, 100410, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100412, "gcrt-app", "Stream/Output/Audio", "GCRTApp")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100413, 100412, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100414, 100413, 100411)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
REQUIRE(tc.client->Test_RemoveGlobal(100412).ok());
REQUIRE(tc.client->Test_RemoveGlobal(100414).ok());
model.refreshFromClient();
auto appQt = model.qtNodeIdForPw(100412);
REQUIRE(appQt == 0);
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_ghostconns.json";
model.saveLayout(path);
WarpGraphModel model2(tc.client.get());
model2.loadLayout(path);
model2.refreshFromClient();
QtNodes::NodeId sinkQt2 = 0;
QtNodes::NodeId appQt2 = 0;
for (auto id : model2.allNodeIds()) {
const WarpNodeData *d = model2.warpNodeData(id);
if (d && d->info.name == "gcrt-sink")
sinkQt2 = id;
if (d && d->info.name == "gcrt-app")
appQt2 = id;
}
REQUIRE(sinkQt2 != 0);
REQUIRE(appQt2 != 0);
REQUIRE(model2.isGhost(appQt2));
auto conns = model2.allGhostConnectionIds(appQt2);
REQUIRE(conns.size() == 1);
auto conn = *conns.begin();
REQUIRE(conn.outNodeId == appQt2);
REQUIRE(conn.inNodeId == sinkQt2);
QFile::remove(path);
}
TEST_CASE("ghost connections cleaned when ghost un-ghosts") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100420, "gcug-sink", "Audio/Sink")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100421, 100420, "in_FL", true)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100422, "gcug-app", "Stream/Output/Audio", "GCUGApp")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100423, 100422, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100424, 100423, 100421)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto appQt = model.qtNodeIdForPw(100422);
auto sinkQt = model.qtNodeIdForPw(100420);
REQUIRE(tc.client->Test_RemoveGlobal(100422).ok());
REQUIRE(tc.client->Test_RemoveGlobal(100424).ok());
model.refreshFromClient();
REQUIRE(model.isGhost(appQt));
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100425, "gcug-app", "Stream/Output/Audio", "GCUGApp")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100426, 100425, "out_FL", false)).ok());
model.refreshFromClient();
REQUIRE_FALSE(model.isGhost(appQt));
auto conns = model.allConnectionIds(appQt);
bool hasOldGhostConn = false;
for (const auto &c : conns) {
if (c.outNodeId == appQt && c.inNodeId == sinkQt)
hasOldGhostConn = true;
}
REQUIRE_FALSE(hasOldGhostConn);
}
TEST_CASE("clearSavedPositions resets model positions") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100340, "clear-pos-node", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto id = model.qtNodeIdForPw(100340);
REQUIRE(id != 0);
auto posBefore = model.nodeData(id, QtNodes::NodeRole::Position).toPointF();
model.clearSavedPositions();
model.autoArrange();
auto posAfter = model.nodeData(id, QtNodes::NodeRole::Position).toPointF();
REQUIRE(posAfter != QPointF(0, 0));
}
TEST_CASE("preset save/load round-trip preserves virtual devices and layout") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100500, "preset-vsink", "Audio/Sink", {}, {}, true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100501, 100500, "FL", true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100502, 100500, "FR", true)).ok());
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100503, "preset-src", "Audio/Source")).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100504, 100503, "out_FL", false)).ok());
REQUIRE(tc.client->Test_InsertLink(
MakeLink(100505, 100504, 100501)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
model.setNodeData(model.qtNodeIdForPw(100500),
QtNodes::NodeRole::Position, QPointF(300, 400));
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_preset.json";
REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model));
QFile file(path);
REQUIRE(file.open(QIODevice::ReadOnly));
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
REQUIRE(doc.isObject());
QJsonObject root = doc.object();
REQUIRE(root["version"].toInt() == 1);
REQUIRE(root["virtual_devices"].toArray().size() >= 1);
REQUIRE(root["routing"].toArray().size() >= 1);
REQUIRE(root["layout"].toArray().size() >= 2);
bool foundVsink = false;
for (const auto &val : root["virtual_devices"].toArray()) {
if (val.toObject()["name"].toString() == "preset-vsink") {
foundVsink = true;
REQUIRE(val.toObject()["channels"].toInt() == 2);
}
}
REQUIRE(foundVsink);
bool foundRoute = false;
for (const auto &val : root["routing"].toArray()) {
QJsonObject route = val.toObject();
if (route["out_node"].toString() == "preset-src" &&
route["in_node"].toString() == "preset-vsink") {
foundRoute = true;
}
}
REQUIRE(foundRoute);
bool foundLayout = false;
for (const auto &val : root["layout"].toArray()) {
QJsonObject obj = val.toObject();
if (obj["name"].toString() == "preset-vsink") {
foundLayout = true;
REQUIRE(obj["x"].toDouble() == Catch::Approx(300.0));
REQUIRE(obj["y"].toDouble() == Catch::Approx(400.0));
}
}
REQUIRE(foundLayout);
QFile::remove(path);
}
TEST_CASE("splitter sizes persist in layout JSON") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100510, "splitter-node", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
WarpGraphModel::ViewState vs;
vs.scale = 1.0;
vs.centerX = 0.0;
vs.centerY = 0.0;
vs.splitterGraph = 900;
vs.splitterSidebar = 400;
vs.valid = true;
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_splitter.json";
model.saveLayout(path, vs);
WarpGraphModel model2(tc.client.get());
model2.loadLayout(path);
auto restored = model2.savedViewState();
REQUIRE(restored.valid);
REQUIRE(restored.splitterGraph == 900);
REQUIRE(restored.splitterSidebar == 400);
QFile::remove(path);
}
TEST_CASE("model volume state defaults to 1.0 and unmuted") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100600, "vol-default", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100600);
REQUIRE(qtId != 0);
auto state = model.nodeVolumeState(qtId);
REQUIRE(state.volume == Catch::Approx(1.0f));
REQUIRE_FALSE(state.mute);
}
TEST_CASE("setNodeVolumeState updates model and calls test helper") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100610, "vol-set", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100610);
REQUIRE(qtId != 0);
WarpGraphModel::NodeVolumeState ns;
ns.volume = 0.5f;
ns.mute = true;
model.setNodeVolumeState(qtId, ns);
auto state = model.nodeVolumeState(qtId);
REQUIRE(state.volume == Catch::Approx(0.5f));
REQUIRE(state.mute);
auto apiState = tc.client->Test_GetNodeVolume(warppipe::NodeId{100610});
REQUIRE(apiState.ok());
REQUIRE(apiState.value.volume == Catch::Approx(0.5f));
REQUIRE(apiState.value.mute);
}
TEST_CASE("nodeVolumeChanged signal emitted on state change") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100620, "vol-signal", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100620);
REQUIRE(qtId != 0);
bool signalFired = false;
QObject::connect(&model, &WarpGraphModel::nodeVolumeChanged,
[&](QtNodes::NodeId id, WarpGraphModel::NodeVolumeState prev,
WarpGraphModel::NodeVolumeState cur) {
if (id == qtId) {
signalFired = true;
REQUIRE(prev.volume == Catch::Approx(1.0f));
REQUIRE(cur.volume == Catch::Approx(0.3f));
REQUIRE(cur.mute);
}
});
WarpGraphModel::NodeVolumeState ns;
ns.volume = 0.3f;
ns.mute = true;
model.setNodeVolumeState(qtId, ns);
REQUIRE(signalFired);
}
TEST_CASE("volume widget created for new nodes") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100630, "vol-widget", "Audio/Sink")).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100630);
REQUIRE(qtId != 0);
auto widget = model.nodeData(qtId, QtNodes::NodeRole::Widget);
REQUIRE(widget.isValid());
auto *w = widget.value<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("ClickSlider horizontal click jumps toward clicked position") {
ensureApp();
ClickSlider slider(Qt::Horizontal);
slider.setRange(0, 100);
slider.resize(120, 24);
slider.show();
QApplication::processEvents();
QPointF click_pos(90.0, 12.0);
QMouseEvent press(QEvent::MouseButtonPress, click_pos, click_pos,
Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&slider, &press);
REQUIRE(slider.value() >= 60);
}
TEST_CASE("ClickSlider vertical click jumps toward clicked position") {
ensureApp();
ClickSlider slider(Qt::Vertical);
slider.setRange(0, 100);
slider.resize(24, 120);
slider.show();
QApplication::processEvents();
QPointF click_pos(12.0, 20.0);
QMouseEvent press(QEvent::MouseButtonPress, click_pos, click_pos,
Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&slider, &press);
REQUIRE(slider.value() <= 40);
}
TEST_CASE("NodeVolumeWidget setVolume and setMuted block outbound signals") {
ensureApp();
NodeVolumeWidget widget;
int volume_signal_count = 0;
int mute_signal_count = 0;
QObject::connect(&widget, &NodeVolumeWidget::volumeChanged,
[&](int) { ++volume_signal_count; });
QObject::connect(&widget, &NodeVolumeWidget::muteToggled,
[&](bool) { ++mute_signal_count; });
widget.setVolume(35);
widget.setMuted(true);
REQUIRE(volume_signal_count == 0);
REQUIRE(mute_signal_count == 0);
auto* slider = widget.findChild<QSlider*>();
REQUIRE(slider != nullptr);
slider->setValue(70);
REQUIRE(volume_signal_count >= 1);
auto* mute_btn = widget.findChild<QToolButton*>();
REQUIRE(mute_btn != nullptr);
mute_btn->setChecked(false);
REQUIRE(mute_signal_count >= 1);
}
TEST_CASE("SquareConnectionPainter stroke handles straight and elbow paths") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
QtNodes::BasicGraphicsScene scene(model);
SquareConnectionPainter painter;
auto straight = makeConnectionGraphic(
scene,
QtNodes::ConnectionId{1u, 0u, 2u, 0u},
QPointF(20.0, 20.0),
QPointF(180.0, 20.0));
auto straightStroke = painter.getPainterStroke(*straight);
REQUIRE(!straightStroke.isEmpty());
REQUIRE(straightStroke.boundingRect().width() >= 150.0);
auto elbow = makeConnectionGraphic(
scene,
QtNodes::ConnectionId{1u, 3u, 2u, 0u},
QPointF(180.0, 40.0),
QPointF(20.0, 40.0));
auto elbowStroke = painter.getPainterStroke(*elbow);
REQUIRE(!elbowStroke.isEmpty());
REQUIRE(elbowStroke.boundingRect().left() <= 20.0);
REQUIRE(elbowStroke.boundingRect().right() >= 180.0);
}
TEST_CASE("SquareConnectionPainter paint renders sketch and connected states") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
QtNodes::BasicGraphicsScene scene(model);
SquareConnectionPainter painter;
auto sketch = makeConnectionGraphic(
scene,
QtNodes::ConnectionId{1u, 1u, QtNodes::InvalidNodeId, 0u},
QPointF(25.0, 25.0),
QPointF(190.0, 85.0));
QImage sketchImage(240, 140, QImage::Format_ARGB32_Premultiplied);
sketchImage.fill(Qt::transparent);
{
QPainter qp(&sketchImage);
painter.paint(&qp, *sketch);
}
REQUIRE(countPaintedPixels(sketchImage) > 0);
auto connected = makeConnectionGraphic(
scene,
QtNodes::ConnectionId{1u, 0u, 2u, 0u},
QPointF(25.0, 25.0),
QPointF(190.0, 85.0));
connected->setSelected(true);
QImage connectedImage(240, 140, QImage::Format_ARGB32_Premultiplied);
connectedImage.fill(Qt::transparent);
{
QPainter qp(&connectedImage);
painter.paint(&qp, *connected);
}
REQUIRE(countPaintedPixels(connectedImage) > 0);
}
TEST_CASE("ZoomGraphicsView wheel zoom honors sensitivity and zero delta") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
QtNodes::BasicGraphicsScene scene(model);
TestZoomGraphicsView view(&scene);
view.resize(320, 200);
view.setZoomSensitivity(1.6);
view.setupScale(1.0);
const QPointF pos(40.0, 40.0);
const QPointF global(40.0, 40.0);
QWheelEvent zoomIn(pos, global, QPoint(0, 0), QPoint(0, 120), Qt::NoButton,
Qt::NoModifier, Qt::NoScrollPhase, false,
Qt::MouseEventNotSynthesized,
QPointingDevice::primaryPointingDevice());
view.dispatchWheel(&zoomIn);
const double zoomed = view.transform().m11();
REQUIRE(zoomed > 1.0);
QWheelEvent zoomOut(pos, global, QPoint(0, 0), QPoint(0, -120), Qt::NoButton,
Qt::NoModifier, Qt::NoScrollPhase, false,
Qt::MouseEventNotSynthesized,
QPointingDevice::primaryPointingDevice());
view.dispatchWheel(&zoomOut);
REQUIRE(view.transform().m11() < zoomed);
const double beforeFlat = view.transform().m11();
QWheelEvent flat(pos, global, QPoint(0, 0), QPoint(0, 0), Qt::NoButton,
Qt::NoModifier, Qt::NoScrollPhase, false,
Qt::MouseEventNotSynthesized,
QPointingDevice::primaryPointingDevice());
view.dispatchWheel(&flat);
REQUIRE(view.transform().m11() == Catch::Approx(beforeFlat));
}
TEST_CASE("ZoomGraphicsView updateProxyCacheMode toggles proxy and connection cache") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
QtNodes::BasicGraphicsScene scene(model);
TestZoomGraphicsView view(&scene);
auto *proxy = scene.addWidget(new QWidget());
REQUIRE(proxy != nullptr);
auto connection = makeConnectionGraphic(
scene,
QtNodes::ConnectionId{11u, 0u, 12u, 0u},
QPointF(10.0, 10.0),
QPointF(200.0, 90.0));
REQUIRE(connection != nullptr);
view.setupScale(1.6);
view.updateProxyCacheMode();
REQUIRE(proxy->cacheMode() == QGraphicsItem::DeviceCoordinateCache);
REQUIRE(connection->cacheMode() == QGraphicsItem::DeviceCoordinateCache);
view.setupScale(1.0);
view.updateProxyCacheMode();
REQUIRE(proxy->cacheMode() == QGraphicsItem::NoCache);
REQUIRE(connection->cacheMode() == QGraphicsItem::NoCache);
}
TEST_CASE("ZoomGraphicsView mouse drag pans and release stops panning") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
QtNodes::BasicGraphicsScene scene(model);
TestZoomGraphicsView view(&scene);
view.resize(320, 200);
const int h0 = view.horizontalScrollBar()->value();
const int v0 = view.verticalScrollBar()->value();
QMouseEvent press(QEvent::MouseButtonPress, QPointF(30.0, 30.0),
QPointF(30.0, 30.0), Qt::LeftButton, Qt::LeftButton,
Qt::NoModifier);
view.dispatchMousePress(&press);
QMouseEvent move(QEvent::MouseMove, QPointF(80.0, 60.0), QPointF(80.0, 60.0),
Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
view.dispatchMouseMove(&move);
const int h1 = view.horizontalScrollBar()->value();
const int v1 = view.verticalScrollBar()->value();
REQUIRE((h1 != h0 || v1 != v0));
QMouseEvent release(QEvent::MouseButtonRelease, QPointF(80.0, 60.0),
QPointF(80.0, 60.0), Qt::LeftButton, Qt::NoButton,
Qt::NoModifier);
view.dispatchMouseRelease(&release);
QMouseEvent afterReleaseMove(QEvent::MouseMove, QPointF(100.0, 90.0),
QPointF(100.0, 90.0), Qt::NoButton,
Qt::NoButton, Qt::NoModifier);
view.dispatchMouseMove(&afterReleaseMove);
REQUIRE(view.horizontalScrollBar()->value() == h1);
REQUIRE(view.verticalScrollBar()->value() == v1);
}
TEST_CASE("ZoomGraphicsView drawBackground renders grid over background") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
WarpGraphModel model(tc.client.get());
QtNodes::BasicGraphicsScene scene(model);
TestZoomGraphicsView view(&scene);
view.resize(320, 200);
view.setupScale(1.0);
auto const &style = QtNodes::StyleCollection::flowViewStyle();
QImage image(320, 200, QImage::Format_ARGB32_Premultiplied);
image.fill(style.BackgroundColor);
{
QPainter qp(&image);
view.dispatchDrawBackground(&qp, QRectF(0.0, 0.0, 320.0, 200.0));
}
REQUIRE(countPixelsDifferentFrom(image, style.BackgroundColor) > 0);
}
TEST_CASE("preset saves and loads volume state") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
REQUIRE(tc.client->Test_InsertNode(
MakeNode(100650, "vol-preset", "Audio/Sink", {}, {}, true)).ok());
REQUIRE(tc.client->Test_InsertPort(
MakePort(100651, 100650, "FL", true)).ok());
WarpGraphModel model(tc.client.get());
model.refreshFromClient();
auto qtId = model.qtNodeIdForPw(100650);
WarpGraphModel::NodeVolumeState ns;
ns.volume = 0.6f;
ns.mute = true;
model.setNodeVolumeState(qtId, ns);
QString path = QStandardPaths::writableLocation(
QStandardPaths::TempLocation) +
"/warppipe_test_vol_preset.json";
REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model));
QFile file(path);
REQUIRE(file.open(QIODevice::ReadOnly));
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
QJsonObject root = doc.object();
REQUIRE(root.contains("volumes"));
QJsonArray volArr = root["volumes"].toArray();
bool found = false;
for (const auto &val : volArr) {
QJsonObject obj = val.toObject();
if (obj["name"].toString() == "vol-preset") {
found = true;
REQUIRE(obj["volume"].toDouble() == Catch::Approx(0.6));
REQUIRE(obj["mute"].toBool());
}
}
REQUIRE(found);
tc.client->Test_SetNodeVolume(warppipe::NodeId{100650}, 1.0f, false);
WarpGraphModel model2(tc.client.get());
model2.refreshFromClient();
auto qtId2 = model2.qtNodeIdForPw(100650);
auto stateBefore = model2.nodeVolumeState(qtId2);
REQUIRE(stateBefore.volume == Catch::Approx(1.0f));
REQUIRE(PresetManager::loadPreset(path, tc.client.get(), &model2));
auto stateAfter = model2.nodeVolumeState(qtId2);
REQUIRE(stateAfter.volume == Catch::Approx(0.6f));
REQUIRE(stateAfter.mute);
QFile::remove(path);
}
TEST_CASE("AudioLevelMeter setLevel clamps to 0-1") {
ensureApp();
AudioLevelMeter meter;
meter.setLevel(0.5f);
REQUIRE(meter.level() == Catch::Approx(0.5f));
meter.setLevel(-0.5f);
REQUIRE(meter.level() == Catch::Approx(0.0f));
meter.setLevel(1.5f);
REQUIRE(meter.level() == Catch::Approx(1.0f));
}
TEST_CASE("AudioLevelMeter peak hold tracks maximum") {
ensureApp();
AudioLevelMeter meter;
meter.setLevel(0.8f);
REQUIRE(meter.peakHold() == Catch::Approx(0.8f));
meter.setLevel(0.3f);
REQUIRE(meter.peakHold() == Catch::Approx(0.8f));
meter.setLevel(0.9f);
REQUIRE(meter.peakHold() == Catch::Approx(0.9f));
}
TEST_CASE("AudioLevelMeter peak decays after hold period") {
ensureApp();
AudioLevelMeter meter;
meter.setLevel(0.5f);
REQUIRE(meter.peakHold() == Catch::Approx(0.5f));
for (int i = 0; i < 7; ++i)
meter.setLevel(0.0f);
REQUIRE(meter.peakHold() < 0.5f);
REQUIRE(meter.peakHold() > 0.0f);
}
TEST_CASE("AudioLevelMeter resetPeakHold clears peak") {
ensureApp();
AudioLevelMeter meter;
meter.setLevel(0.7f);
REQUIRE(meter.peakHold() == Catch::Approx(0.7f));
meter.resetPeakHold();
REQUIRE(meter.peakHold() == Catch::Approx(0.0f));
}
TEST_CASE("AudioLevelMeter reports expected size hints") {
ensureApp();
AudioLevelMeter meter;
REQUIRE(meter.sizeHint() == QSize(40, 160));
REQUIRE(meter.minimumSizeHint() == QSize(12, 40));
}
TEST_CASE("AudioLevelMeter paint at silence draws background only") {
ensureApp();
AudioLevelMeter meter;
meter.resetPeakHold();
meter.setLevel(0.0f);
QImage image = renderWidgetImage(meter, QSize(20, 120));
const QColor kBackground(24, 24, 28);
const QColor kGreen(76, 175, 80);
const QColor kYellow(255, 193, 7);
const QColor kRed(244, 67, 54);
const QColor kWhite(255, 255, 255);
REQUIRE(countColorPixels(image, kBackground) > 0);
REQUIRE(countColorPixels(image, kGreen) == 0);
REQUIRE(countColorPixels(image, kYellow) == 0);
REQUIRE(countColorPixels(image, kRed) == 0);
REQUIRE(countColorPixels(image, kWhite) == 0);
}
TEST_CASE("AudioLevelMeter paint at high level draws green yellow red and peak") {
ensureApp();
AudioLevelMeter meter;
meter.setLevel(0.95f);
QImage image = renderWidgetImage(meter, QSize(20, 120));
const QColor kGreen(76, 175, 80);
const QColor kYellow(255, 193, 7);
const QColor kRed(244, 67, 54);
const QColor kWhite(255, 255, 255);
REQUIRE(countColorPixels(image, kGreen) > 0);
REQUIRE(countColorPixels(image, kYellow) > 0);
REQUIRE(countColorPixels(image, kRed) > 0);
REQUIRE(countColorPixels(image, kWhite) > 0);
}
TEST_CASE("AudioLevelMeter paint after drop keeps peak line without bar") {
ensureApp();
AudioLevelMeter meter;
meter.setLevel(0.8f);
meter.setLevel(0.0f);
QImage image = renderWidgetImage(meter, QSize(20, 120));
const QColor kGreen(76, 175, 80);
const QColor kYellow(255, 193, 7);
const QColor kRed(244, 67, 54);
const QColor kWhite(255, 255, 255);
REQUIRE(countColorPixels(image, kGreen) == 0);
REQUIRE(countColorPixels(image, kYellow) == 0);
REQUIRE(countColorPixels(image, kRed) == 0);
REQUIRE(countColorPixels(image, kWhite) > 0);
}
TEST_CASE("GraphEditorWidget has METERS tab") {
auto tc = TestClient::Create();
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
ensureApp();
GraphEditorWidget widget(tc.client.get());
auto *sidebar = widget.findChild<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");
}