More tests
This commit is contained in:
parent
16fc02837a
commit
69d9a9e3f1
5 changed files with 742 additions and 27 deletions
|
|
@ -647,6 +647,11 @@ 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();
|
||||
|
|
@ -688,6 +693,45 @@ 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);
|
||||
|
|
@ -830,7 +874,7 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos,
|
|||
QAction *savePresetAction = menu.addAction(QStringLiteral("Save Preset..."));
|
||||
QAction *loadPresetAction = menu.addAction(QStringLiteral("Load Preset..."));
|
||||
|
||||
QAction *chosen = menu.exec(screenPos);
|
||||
QAction *chosen = execMenuAction(menu, screenPos);
|
||||
if (chosen == createSink) {
|
||||
createVirtualNode(true, scenePos);
|
||||
} else if (chosen == createSource) {
|
||||
|
|
@ -922,7 +966,7 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
|
|||
QStringLiteral(
|
||||
"application/warppipe-virtual-graph"))));
|
||||
|
||||
QAction *chosen = menu.exec(screenPos);
|
||||
QAction *chosen = execMenuAction(menu, screenPos);
|
||||
if (!chosen) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -949,9 +993,9 @@ void GraphEditorWidget::createVirtualNode(bool isSink,
|
|||
const QPointF &scenePos) {
|
||||
if (isSink) {
|
||||
bool ok = false;
|
||||
QString name = QInputDialog::getText(
|
||||
this, QStringLiteral("Create Virtual Sink"),
|
||||
QStringLiteral("Node name:"), QLineEdit::Normal, QString(), &ok);
|
||||
QString name = promptTextInput(QStringLiteral("Create Virtual Sink"),
|
||||
QStringLiteral("Node name:"),
|
||||
&ok);
|
||||
if (!ok || name.trimmed().isEmpty())
|
||||
return;
|
||||
|
||||
|
|
@ -959,8 +1003,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink,
|
|||
m_model->setPendingPosition(nodeName, scenePos);
|
||||
auto result = m_client->CreateVirtualSink(nodeName);
|
||||
if (!result.status.ok()) {
|
||||
QMessageBox::warning(this, QStringLiteral("Error"),
|
||||
QString::fromStdString(result.status.message));
|
||||
showWarningDialog(QStringLiteral("Error"),
|
||||
QString::fromStdString(result.status.message));
|
||||
return;
|
||||
}
|
||||
m_model->refreshFromClient();
|
||||
|
|
@ -1034,8 +1078,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink,
|
|||
|
||||
QString name = nameEdit->text().trimmed();
|
||||
if (name.isEmpty()) {
|
||||
QMessageBox::warning(this, QStringLiteral("Error"),
|
||||
QStringLiteral("Name cannot be empty."));
|
||||
showWarningDialog(QStringLiteral("Error"),
|
||||
QStringLiteral("Name cannot be empty."));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1050,8 +1094,8 @@ void GraphEditorWidget::createVirtualNode(bool isSink,
|
|||
|
||||
auto result = m_client->CreateVirtualSource(nodeName, opts);
|
||||
if (!result.status.ok()) {
|
||||
QMessageBox::warning(this, QStringLiteral("Error"),
|
||||
QString::fromStdString(result.status.message));
|
||||
showWarningDialog(QStringLiteral("Error"),
|
||||
QString::fromStdString(result.status.message));
|
||||
return;
|
||||
}
|
||||
m_model->refreshFromClient();
|
||||
|
|
@ -1415,9 +1459,9 @@ void GraphEditorWidget::savePreset() {
|
|||
if (!dir.exists())
|
||||
dir.mkpath(".");
|
||||
|
||||
QString path = QFileDialog::getSaveFileName(
|
||||
this, QStringLiteral("Save Preset"), m_presetDir,
|
||||
QStringLiteral("JSON files (*.json)"));
|
||||
QString path = chooseSaveFilePath(QStringLiteral("Save Preset"),
|
||||
m_presetDir,
|
||||
QStringLiteral("JSON files (*.json)"));
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
if (!path.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive))
|
||||
|
|
@ -1428,15 +1472,15 @@ void GraphEditorWidget::savePreset() {
|
|||
mw->statusBar()->showMessage(
|
||||
QStringLiteral("Preset saved: ") + QFileInfo(path).fileName(), 4000);
|
||||
} else {
|
||||
QMessageBox::warning(this, QStringLiteral("Error"),
|
||||
QStringLiteral("Failed to save preset."));
|
||||
showWarningDialog(QStringLiteral("Error"),
|
||||
QStringLiteral("Failed to save preset."));
|
||||
}
|
||||
}
|
||||
|
||||
void GraphEditorWidget::loadPreset() {
|
||||
QString path = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Load Preset"), m_presetDir,
|
||||
QStringLiteral("JSON files (*.json)"));
|
||||
QString path = chooseOpenFilePath(QStringLiteral("Load Preset"),
|
||||
m_presetDir,
|
||||
QStringLiteral("JSON files (*.json)"));
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
|
||||
|
|
@ -1445,8 +1489,8 @@ void GraphEditorWidget::loadPreset() {
|
|||
mw->statusBar()->showMessage(
|
||||
QStringLiteral("Preset loaded: ") + QFileInfo(path).fileName(), 4000);
|
||||
} else {
|
||||
QMessageBox::warning(this, QStringLiteral("Error"),
|
||||
QStringLiteral("Failed to load preset."));
|
||||
showWarningDialog(QStringLiteral("Error"),
|
||||
QStringLiteral("Failed to load preset."));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ class QSplitter;
|
|||
class QTabWidget;
|
||||
class QSlider;
|
||||
class QTimer;
|
||||
class QAction;
|
||||
class QMenu;
|
||||
class DeleteVirtualNodeCommand;
|
||||
|
||||
enum class ConnectionStyleType : uint8_t {
|
||||
|
|
@ -59,6 +61,20 @@ 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,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@
|
|||
#include <QScrollBar>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include <QtNodes/internal/ConnectionGraphicsObject.hpp>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
class ZoomGraphicsView : public QtNodes::GraphicsView {
|
||||
|
|
@ -41,8 +39,7 @@ public:
|
|||
auto cacheMode = highZoom ? QGraphicsItem::DeviceCoordinateCache
|
||||
: QGraphicsItem::NoCache;
|
||||
for (QGraphicsItem *item : scene()->items()) {
|
||||
if (item->type() == QGraphicsProxyWidget::Type ||
|
||||
item->type() == QtNodes::ConnectionGraphicsObject::Type)
|
||||
if (item->type() == QGraphicsProxyWidget::Type)
|
||||
item->setCacheMode(cacheMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QComboBox>
|
||||
#include <QDir>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFile>
|
||||
|
|
@ -150,6 +151,28 @@ int countPixelsDifferentFrom(const QImage &image, const QColor &color) {
|
|||
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;
|
||||
|
|
@ -169,6 +192,101 @@ public:
|
|||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
|
@ -275,6 +393,16 @@ bool hasNodeNamed(warppipe::Client *client, const std::string &name) {
|
|||
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") {
|
||||
|
|
@ -981,6 +1109,420 @@ TEST_CASE("GraphEditorWidget onRefreshTimer updates node count after injection")
|
|||
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; }
|
||||
|
|
@ -1829,7 +2371,7 @@ TEST_CASE("ZoomGraphicsView wheel zoom honors sensitivity and zero delta") {
|
|||
REQUIRE(view.transform().m11() == Catch::Approx(beforeFlat));
|
||||
}
|
||||
|
||||
TEST_CASE("ZoomGraphicsView updateProxyCacheMode toggles proxy and connection") {
|
||||
TEST_CASE("ZoomGraphicsView updateProxyCacheMode toggles proxy and leaves connection uncached") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
|
@ -1851,7 +2393,7 @@ TEST_CASE("ZoomGraphicsView updateProxyCacheMode toggles proxy and connection")
|
|||
view.setupScale(1.6);
|
||||
view.updateProxyCacheMode();
|
||||
REQUIRE(proxy->cacheMode() == QGraphicsItem::DeviceCoordinateCache);
|
||||
REQUIRE(connection->cacheMode() == QGraphicsItem::DeviceCoordinateCache);
|
||||
REQUIRE(connection->cacheMode() == QGraphicsItem::NoCache);
|
||||
|
||||
view.setupScale(1.0);
|
||||
view.updateProxyCacheMode();
|
||||
|
|
@ -2021,6 +2563,68 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -626,6 +626,60 @@ 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()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue