2756 lines
86 KiB
C++
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.isEmpty()) {
|
|
int idx = combos[0]->findData(targetNodeName);
|
|
if (idx >= 0) {
|
|
combos[0]->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 leaves connection uncached") {
|
|
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::NoCache);
|
|
|
|
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");
|
|
}
|