diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 108e8b7..38849b7 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -39,7 +39,6 @@ #include #include #include -#include #include #include #include @@ -615,19 +614,9 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, &GraphEditorWidget::onRefreshTimer); if (m_client) { - QPointer changeTimer = m_changeTimer; - m_client->SetChangeCallback([changeTimer] { - auto *app = QCoreApplication::instance(); - if (!app) { - return; - } - - QMetaObject::invokeMethod(app, - [changeTimer]() { - if (changeTimer) { - changeTimer->start(); - } - }, + m_client->SetChangeCallback([this] { + QMetaObject::invokeMethod(m_changeTimer, + qOverload<>(&QTimer::start), Qt::QueuedConnection); }); } @@ -647,11 +636,6 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); - if (m_scene && - m_scene->itemIndexMethod() != QGraphicsScene::BspTreeIndex) { - m_scene->setItemIndexMethod(QGraphicsScene::BspTreeIndex); - } - if (!m_graphReady && m_model->allNodeIds().size() > 0) { m_graphReady = true; Q_EMIT graphReady(); @@ -664,13 +648,6 @@ void GraphEditorWidget::scheduleSaveLayout() { } GraphEditorWidget::~GraphEditorWidget() { - if (m_scene) { - disconnect(m_scene, nullptr, this, nullptr); - } - if (m_model) { - disconnect(m_model, nullptr, this, nullptr); - } - if (m_client) { m_client->SetChangeCallback(nullptr); } @@ -693,45 +670,6 @@ int GraphEditorWidget::linkCount() const { return count / 2; } -QAction *GraphEditorWidget::execMenuAction(QMenu &menu, - const QPoint &screenPos) { - return menu.exec(screenPos); -} - -QString GraphEditorWidget::promptTextInput(const QString &title, - const QString &label, - bool *ok) { - return QInputDialog::getText(this, - title, - label, - QLineEdit::Normal, - QString(), - ok); -} - -QString GraphEditorWidget::chooseSaveFilePath(const QString &title, - const QString &initialDir, - const QString &filter) { - return QFileDialog::getSaveFileName(this, - title, - initialDir, - filter); -} - -QString GraphEditorWidget::chooseOpenFilePath(const QString &title, - const QString &initialDir, - const QString &filter) { - return QFileDialog::getOpenFileName(this, - title, - initialDir, - filter); -} - -void GraphEditorWidget::showWarningDialog(const QString &title, - const QString &message) { - QMessageBox::warning(this, title, message); -} - void GraphEditorWidget::setDebugScreenshotDir(const QString &dir) { m_debugScreenshotDir = dir; QDir d(dir); @@ -874,7 +812,7 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, QAction *savePresetAction = menu.addAction(QStringLiteral("Save Preset...")); QAction *loadPresetAction = menu.addAction(QStringLiteral("Load Preset...")); - QAction *chosen = execMenuAction(menu, screenPos); + QAction *chosen = menu.exec(screenPos); if (chosen == createSink) { createVirtualNode(true, scenePos); } else if (chosen == createSource) { @@ -966,7 +904,7 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, QStringLiteral( "application/warppipe-virtual-graph")))); - QAction *chosen = execMenuAction(menu, screenPos); + QAction *chosen = menu.exec(screenPos); if (!chosen) { return; } @@ -993,9 +931,9 @@ void GraphEditorWidget::createVirtualNode(bool isSink, const QPointF &scenePos) { if (isSink) { bool ok = false; - QString name = promptTextInput(QStringLiteral("Create Virtual Sink"), - QStringLiteral("Node name:"), - &ok); + QString name = QInputDialog::getText( + this, QStringLiteral("Create Virtual Sink"), + QStringLiteral("Node name:"), QLineEdit::Normal, QString(), &ok); if (!ok || name.trimmed().isEmpty()) return; @@ -1003,8 +941,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink, m_model->setPendingPosition(nodeName, scenePos); auto result = m_client->CreateVirtualSink(nodeName); if (!result.status.ok()) { - showWarningDialog(QStringLiteral("Error"), - QString::fromStdString(result.status.message)); + QMessageBox::warning(this, QStringLiteral("Error"), + QString::fromStdString(result.status.message)); return; } m_model->refreshFromClient(); @@ -1078,8 +1016,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink, QString name = nameEdit->text().trimmed(); if (name.isEmpty()) { - showWarningDialog(QStringLiteral("Error"), - QStringLiteral("Name cannot be empty.")); + QMessageBox::warning(this, QStringLiteral("Error"), + QStringLiteral("Name cannot be empty.")); return; } @@ -1094,8 +1032,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink, auto result = m_client->CreateVirtualSource(nodeName, opts); if (!result.status.ok()) { - showWarningDialog(QStringLiteral("Error"), - QString::fromStdString(result.status.message)); + QMessageBox::warning(this, QStringLiteral("Error"), + QString::fromStdString(result.status.message)); return; } m_model->refreshFromClient(); @@ -1459,9 +1397,9 @@ void GraphEditorWidget::savePreset() { if (!dir.exists()) dir.mkpath("."); - QString path = chooseSaveFilePath(QStringLiteral("Save Preset"), - m_presetDir, - QStringLiteral("JSON files (*.json)")); + QString path = QFileDialog::getSaveFileName( + this, QStringLiteral("Save Preset"), m_presetDir, + QStringLiteral("JSON files (*.json)")); if (path.isEmpty()) return; if (!path.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive)) @@ -1472,15 +1410,15 @@ void GraphEditorWidget::savePreset() { mw->statusBar()->showMessage( QStringLiteral("Preset saved: ") + QFileInfo(path).fileName(), 4000); } else { - showWarningDialog(QStringLiteral("Error"), - QStringLiteral("Failed to save preset.")); + QMessageBox::warning(this, QStringLiteral("Error"), + QStringLiteral("Failed to save preset.")); } } void GraphEditorWidget::loadPreset() { - QString path = chooseOpenFilePath(QStringLiteral("Load Preset"), - m_presetDir, - QStringLiteral("JSON files (*.json)")); + QString path = QFileDialog::getOpenFileName( + this, QStringLiteral("Load Preset"), m_presetDir, + QStringLiteral("JSON files (*.json)")); if (path.isEmpty()) return; @@ -1489,8 +1427,8 @@ void GraphEditorWidget::loadPreset() { mw->statusBar()->showMessage( QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000); } else { - showWarningDialog(QStringLiteral("Error"), - QStringLiteral("Failed to load preset.")); + QMessageBox::warning(this, QStringLiteral("Error"), + QStringLiteral("Failed to load preset.")); } } diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 73247a4..52dc491 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -27,8 +27,6 @@ class QSplitter; class QTabWidget; class QSlider; class QTimer; -class QAction; -class QMenu; class DeleteVirtualNodeCommand; enum class ConnectionStyleType : uint8_t { @@ -61,20 +59,6 @@ private slots: void onContextMenuRequested(const QPoint &pos); void scheduleSaveLayout(); -protected: - virtual QAction *execMenuAction(QMenu &menu, const QPoint &screenPos); - virtual QString promptTextInput(const QString &title, - const QString &label, - bool *ok); - virtual QString chooseSaveFilePath(const QString &title, - const QString &initialDir, - const QString &filter); - virtual QString chooseOpenFilePath(const QString &title, - const QString &initialDir, - const QString &filter); - virtual void showWarningDialog(const QString &title, - const QString &message); - private: void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId, diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 03d1679..2e4d022 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -352,11 +352,12 @@ void WarpGraphModel::refreshFromClient() { return; } + Q_EMIT beginBatchUpdate(); m_refreshing = true; - bool sceneChanged = false; auto nodesResult = m_client->ListNodes(); if (!nodesResult.ok()) { m_refreshing = false; + Q_EMIT endBatchUpdate(); return; } @@ -511,10 +512,6 @@ void WarpGraphModel::refreshFromClient() { m_volumeStates[qtId] = {}; } - if (!sceneChanged) { - sceneChanged = true; - Q_EMIT beginBatchUpdate(); - } Q_EMIT nodeCreated(qtId); } @@ -529,10 +526,6 @@ void WarpGraphModel::refreshFromClient() { if (it == m_pwToQt.end()) { continue; } - if (!sceneChanged) { - sceneChanged = true; - Q_EMIT beginBatchUpdate(); - } QtNodes::NodeId qtId = it->second; auto nodeIt = m_nodes.find(qtId); if (nodeIt == m_nodes.end()) { @@ -588,10 +581,6 @@ void WarpGraphModel::refreshFromClient() { QtNodes::ConnectionId connId{outNodeIt->second, outPortIdx, inNodeIt->second, inPortIdx}; if (m_connections.find(connId) == m_connections.end()) { - if (!sceneChanged) { - sceneChanged = true; - Q_EMIT beginBatchUpdate(); - } m_connections.insert(connId); m_linkIdToConn.emplace(link.id.value, connId); Q_EMIT connectionCreated(connId); @@ -620,10 +609,6 @@ void WarpGraphModel::refreshFromClient() { { auto connIt = m_connections.find(connId); if (connIt != m_connections.end()) { - if (!sceneChanged) { - sceneChanged = true; - Q_EMIT beginBatchUpdate(); - } m_connections.erase(connIt); Q_EMIT connectionDeleted(connId); } @@ -711,9 +696,7 @@ void WarpGraphModel::refreshFromClient() { } m_refreshing = false; - if (sceneChanged) { - Q_EMIT endBatchUpdate(); - } + Q_EMIT endBatchUpdate(); } const WarpNodeData * diff --git a/gui/ZoomGraphicsView.h b/gui/ZoomGraphicsView.h index abc7edd..53106df 100644 --- a/gui/ZoomGraphicsView.h +++ b/gui/ZoomGraphicsView.h @@ -18,7 +18,7 @@ public: explicit ZoomGraphicsView(QtNodes::BasicGraphicsScene *scene, QWidget *parent = nullptr) : QtNodes::GraphicsView(scene, parent) { - setViewportUpdateMode(QGraphicsView::FullViewportUpdate); + setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); setCacheMode(QGraphicsView::CacheNone); } diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 93949c8..9e43a2c 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -3,45 +3,17 @@ #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 -#include -#include - -#include #include #include -#include -#include -#include -#include -#include #include -#include -#include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include #include #include @@ -115,294 +87,6 @@ AppGuard &ensureApp() { 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 -makeConnectionGraphic(QtNodes::BasicGraphicsScene &scene, - QtNodes::ConnectionId connectionId, - const QPointF &out, - const QPointF &in) { - auto cgo = std::make_unique(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(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(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(widget); - if (!dialog || !dialog->isVisible() || - !dialog->windowTitle().contains(QStringLiteral("Routing Rule"))) { - continue; - } - - auto edits = dialog->findChildren(); - if (!edits.isEmpty()) { - edits[0]->setText(appName); - } - - auto combos = dialog->findChildren(); - if (!combos.isEmpty()) { - int idx = combos[0]->findData(targetNodeName); - if (idx >= 0) { - combos[0]->setCurrentIndex(idx); - } - } - - auto *buttons = dialog->findChild(); - 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()) { - if (!label->text().contains(appLabelToken)) { - continue; - } - QWidget *cursor = label; - while (cursor && cursor != &root) { - for (auto *button : cursor->findChildren()) { - if (button->text() == QString(QChar(0x2715))) { - return button; - } - } - cursor = cursor->parentWidget(); - } - } - return nullptr; -} - -ZoomGraphicsView *findZoomView(QWidget &root) { - for (auto *view : root.findChildren()) { - if (auto *zoom = dynamic_cast(view)) { - return zoom; - } - } - return nullptr; -} - -QAction *findActionByText(const QList &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") { @@ -850,795 +534,6 @@ TEST_CASE("GraphEditorWidget registers custom keyboard actions") { 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(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(tc.client.get()); - auto *model = widget->findChild(); - REQUIRE(model != nullptr); - auto *view = findZoomView(*widget); - REQUIRE(view != nullptr); - auto *scene = dynamic_cast(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(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(tc.client.get()); - auto *view = findZoomView(*widget); - REQUIRE(view != nullptr); - auto *scene = dynamic_cast(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()) { - 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(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(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(); - REQUIRE(model != nullptr); - auto *view = findZoomView(widget); - REQUIRE(view != nullptr); - auto *scene = dynamic_cast(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(); - REQUIRE(model != nullptr); - auto *view = findZoomView(widget); - REQUIRE(view != nullptr); - auto *scene = dynamic_cast(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(); - 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(); - 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(); - REQUIRE(model != nullptr); - auto *view = findZoomView(widget); - REQUIRE(view != nullptr); - auto *sidebar = widget.findChild(); - 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()) { - 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; } @@ -2205,264 +1100,6 @@ TEST_CASE("setNodeVolumeState syncs inline widget") { 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(); - REQUIRE(slider != nullptr); - slider->setValue(70); - REQUIRE(volume_signal_count >= 1); - - auto* mute_btn = widget.findChild(); - 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; } @@ -2563,68 +1200,6 @@ TEST_CASE("AudioLevelMeter resetPeakHold clears peak") { 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; } diff --git a/tests/warppipe_tests.cpp b/tests/warppipe_tests.cpp index f03c743..4b70857 100644 --- a/tests/warppipe_tests.cpp +++ b/tests/warppipe_tests.cpp @@ -626,60 +626,6 @@ TEST_CASE("set default sink without metadata returns unavailable") { REQUIRE_FALSE(status.ok()); } -TEST_CASE("set default source without metadata returns unavailable") { - auto result = warppipe::Client::Create(DefaultOptions()); - if (!result.ok()) { - SUCCEED("PipeWire unavailable"); - return; - } - - auto status = result.value->SetDefaultSource(""); - REQUIRE_FALSE(status.ok()); -} - -TEST_CASE("GetVirtualNodeInfo returns details for created virtual sink") { - auto result = warppipe::Client::Create(DefaultOptions()); - if (!result.ok()) { - SUCCEED("PipeWire unavailable"); - return; - } - - warppipe::VirtualNodeOptions options; - options.display_name = "warppipe-test-info"; - options.group = "warppipe-test"; - options.format.rate = 48000; - options.format.channels = 2; - - auto sink = result.value->CreateVirtualSink("warppipe-info-sink", options); - if (!sink.ok()) { - if (sink.status.code == warppipe::StatusCode::kUnavailable) { - SUCCEED("PipeWire unavailable"); - return; - } - REQUIRE(sink.ok()); - } - - auto info = result.value->GetVirtualNodeInfo(sink.value.node); - REQUIRE(info.ok()); - REQUIRE(info.value.node.value == sink.value.node.value); - REQUIRE(info.value.name == sink.value.name); - REQUIRE_FALSE(info.value.is_source); - - REQUIRE(result.value->RemoveNode(sink.value.node).ok()); -} - -TEST_CASE("GetVirtualNodeInfo missing node returns not found") { - auto result = warppipe::Client::Create(DefaultOptions()); - if (!result.ok()) { - SUCCEED("PipeWire unavailable"); - return; - } - - auto info = result.value->GetVirtualNodeInfo(warppipe::NodeId{999999}); - REQUIRE_FALSE(info.ok()); - REQUIRE(info.status.code == warppipe::StatusCode::kNotFound); -} - TEST_CASE("NodeInfo captures application properties") { auto result = warppipe::Client::Create(DefaultOptions()); if (!result.ok()) {