diagrams-fable-qt/tests/tst_scene.cpp
Ilya Ashikhmin b257c1a7cf feat(scene): add interactive graphics scene with grid, ports and styled edges
- SVG node items with snap-to-grid dragging; ports appear on focus/hover
  and highlight compatible targets during a connection drag
- Orthogonally routed edge items with line styles, head/tail decorations
  (arrow, circle, diamond, bar), selection halo and title labels; later
  edges jump crossings with arcs when enabled
- Grid background view with cursor-anchored zoom, middle-button pan,
  rubber-band selection and palette drag-and-drop
- Undoable commands: add/move/delete/connect/property/paste with
  clipboard fragments; light/dark/high-contrast themes
- Flow overlay: animated dashes, direction and density follow flow rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:25:06 +02:00

216 lines
8.5 KiB
C++

#include "TestFixtures.h"
#include "core/NetworkModel.h"
#include "scene/DiagramScene.h"
#include "scene/EdgeItem.h"
#include "scene/GridView.h"
#include "scene/NodeItem.h"
#include "scene/PortItem.h"
#include <QMimeData>
#include <QSignalSpy>
#include <QTest>
#include <QUndoStack>
using namespace diag;
class TestScene : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_scene;
delete m_model;
m_model = new NetworkModel(&m_reg, this);
m_undo = new QUndoStack(m_model);
m_scene = new DiagramScene(m_model, m_undo, this);
}
void dropNode_createsSnappedItemAndUndoes()
{
const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {107, 93});
QVERIFY(!id.isNull());
QVERIFY(m_scene->nodeItem(id));
// Center (107,93) minus half size (30,30) = (77,63), snapped to 20-grid.
QCOMPARE(m_model->node(id)->pos, QPointF(80, 60));
QCOMPARE(m_scene->nodeItem(id)->pos(), QPointF(80, 60));
m_undo->undo();
QVERIFY(!m_scene->nodeItem(id));
}
void nodeDrag_snapsAndIsUndoable()
{
const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {100, 100});
NodeItem* item = m_scene->nodeItem(id);
const QPointF before = item->pos();
// Simulate the drag protocol NodeItem uses.
item->setSelected(true);
m_scene->captureMoveOrigin();
item->setPos(before + QPointF(37, 42)); // snapped by itemChange
m_scene->commitMove();
QCOMPARE(item->pos(), before + QPointF(40, 40));
QCOMPARE(m_model->node(id)->pos, before + QPointF(40, 40));
m_undo->undo();
QCOMPARE(m_model->node(id)->pos, before);
QCOMPARE(item->pos(), before);
}
void ports_visibleOnlyWhenFocused()
{
const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {100, 100});
NodeItem* item = m_scene->nodeItem(id);
QCOMPARE(item->portItems().size(), 2);
for (auto* p : item->portItems())
QVERIFY(!p->isVisible());
item->setSelected(true);
for (auto* p : item->portItems())
QVERIFY(p->isVisible());
item->setSelected(false);
for (auto* p : item->portItems())
QVERIFY(!p->isVisible());
}
void interactiveConnect_compatiblePortsHighlighted()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {300, 0});
const QUuid gen = m_scene->dropNode(QStringLiteral("generator"), {300, 200});
PortItem* srcOut = m_scene->nodeItem(src)->portItem(QStringLiteral("out"));
PortItem* pumpIn = m_scene->nodeItem(pump)->portItem(QStringLiteral("in"));
PortItem* pumpOut = m_scene->nodeItem(pump)->portItem(QStringLiteral("out"));
PortItem* genOut = m_scene->nodeItem(gen)->portItem(QStringLiteral("out"));
m_scene->startConnection(srcOut);
QVERIFY(m_scene->isConnecting());
QCOMPARE(m_scene->connectStateFor(pumpIn), PortItem::ConnectState::Compatible);
QCOMPARE(m_scene->connectStateFor(pumpOut), PortItem::ConnectState::Incompatible);
QCOMPARE(m_scene->connectStateFor(genOut), PortItem::ConnectState::Incompatible);
QVERIFY(pumpIn->isVisible()); // compatible ports shown during drag
// Finish on the compatible port: edge created through the undo stack.
QVERIFY(m_scene->finishConnection(pumpIn->scenePos()));
QVERIFY(!m_scene->isConnecting());
QCOMPARE(m_model->edgeIds().size(), 1);
m_undo->undo();
QCOMPARE(m_model->edgeIds().size(), 0);
}
void interactiveConnect_rejectsIncompatible()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid gen = m_scene->dropNode(QStringLiteral("generator"), {300, 0});
PortItem* srcOut = m_scene->nodeItem(src)->portItem(QStringLiteral("out"));
PortItem* genOut = m_scene->nodeItem(gen)->portItem(QStringLiteral("out"));
QSignalSpy spy(m_scene, &DiagramScene::connectionMessage);
m_scene->startConnection(srcOut);
QVERIFY(!m_scene->finishConnection(genOut->scenePos()));
QCOMPARE(m_model->edgeIds().size(), 0);
QVERIFY(spy.count() >= 2); // start message + rejection reason
}
void edgeRoute_isOrthogonalBetweenPorts()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {400, 200});
const QUuid e = m_model->addEdge(src, "out", pump, "in");
EdgeItem* item = m_scene->edgeItem(e);
QVERIFY(item);
const auto& poly = item->polyline();
QVERIFY(poly.size() >= 2);
QCOMPARE(poly.first(), m_model->portScenePos(src, QStringLiteral("out")));
QCOMPARE(poly.last(), m_model->portScenePos(pump, QStringLiteral("in")));
for (int i = 0; i + 1 < poly.size(); ++i)
QVERIFY(qAbs(poly[i].x() - poly[i + 1].x()) < 1e-6
|| qAbs(poly[i].y() - poly[i + 1].y()) < 1e-6);
}
void edgesFollowNodeMove()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {400, 0});
const QUuid e = m_model->addEdge(src, "out", pump, "in");
m_model->moveNode(pump, {400, 300});
QCOMPARE(m_scene->edgeItem(e)->polyline().last(),
m_model->portScenePos(pump, QStringLiteral("in")));
}
void copyPasteDelete_roundTrip()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {300, 0});
m_model->addEdge(src, "out", pump, "in");
m_scene->nodeItem(src)->setSelected(true);
m_scene->nodeItem(pump)->setSelected(true);
m_scene->copySelection();
QVERIFY(m_scene->canPaste());
m_scene->paste();
QCOMPARE(m_model->nodeIds().size(), 4);
QCOMPARE(m_model->edgeIds().size(), 2);
// Pasted nodes become the new selection.
QCOMPARE(m_scene->selectedItems().size(), 2);
m_scene->selectAll();
m_scene->deleteSelection();
QCOMPARE(m_model->nodeIds().size(), 0);
QCOMPARE(m_model->edgeIds().size(), 0);
m_undo->undo();
QCOMPARE(m_model->nodeIds().size(), 4);
QCOMPARE(m_model->edgeIds().size(), 2);
}
void crossingEdges_getArcWhenEnabled()
{
// Build two crossing water lines: (0,0)->(400,0) area and a vertical
// crossing through them.
const QUuid s1 = m_scene->dropNode(QStringLiteral("source"), {0, 100});
const QUuid c1 = m_scene->dropNode(QStringLiteral("consumer"), {400, 100});
const QUuid j1 = m_scene->dropNode(QStringLiteral("junction"), {200, 0});
const QUuid j2 = m_scene->dropNode(QStringLiteral("junction"), {200, 240});
m_model->addEdge(s1, "out", c1, "in");
const QUuid crossing = m_model->addEdge(j1, "s", j2, "n");
EdgeItem* item = m_scene->edgeItem(crossing);
const double plainLen = Router::polylineLength(item->polyline());
// With arcs on (default), the painted path detours around the first edge.
RouteConfig cfg = m_scene->routeConfig();
QVERIFY(cfg.arcOnIntersection);
const auto hops = Router::crossings(item->polyline(),
m_scene->edgeItem(m_model->edgeIds().first())->polyline());
QCOMPARE(hops.size(), 1);
cfg.arcOnIntersection = false;
m_scene->setRouteConfig(cfg); // still routes, no arcs — no crash, same polyline
QCOMPARE(Router::polylineLength(m_scene->edgeItem(crossing)->polyline()), plainLen);
}
void gridView_dropMimeCreatesNode()
{
GridView view(m_scene);
view.resize(600, 400);
view.show();
QMimeData mime;
mime.setData(QLatin1String(GridView::kNodeTypeMime), QByteArrayLiteral("pump"));
QVERIFY(view.handleNodeTypeDrop(&mime, QPoint(300, 200)));
QCOMPARE(m_model->nodeIds().size(), 1);
QMimeData other;
other.setData(QStringLiteral("text/plain"), QByteArrayLiteral("x"));
QVERIFY(!view.handleNodeTypeDrop(&other, QPoint(300, 200)));
QCOMPARE(m_model->nodeIds().size(), 1);
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
QUndoStack* m_undo = nullptr;
DiagramScene* m_scene = nullptr;
};
QTEST_MAIN(TestScene)
#include "tst_scene.moc"