- 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>
216 lines
8.5 KiB
C++
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"
|