diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f11d5ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +*.user +.cache/ +compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..014c8a1 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.22) +project(PipeDiagram VERSION 0.1 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Svg Test) + +add_subdirectory(src) + +enable_testing() +add_subdirectory(tests) diff --git a/README.md b/README.md new file mode 100644 index 0000000..651aeac --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# PipeDiagram — Pipeline Network Diagram Editor + +Qt 6 editor for drawing pipeline networks (water, power, …): SVG nodes with typed +ports, grid-aligned orthogonal connectors with arc jump-overs, grouped editable +properties, styleable edges, and a mock flow simulation with animated flow. + +## Build + +```bash +cmake -S . -B build -G Ninja +cmake --build build +./build/src/pipediagram +``` + +## Test + +```bash +ctest --test-dir build --output-on-failure +``` + +See `docs/plan/plan.md` for architecture and the feature checklist. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..b8c2e75 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,11 @@ +# Core: document model, routing, solver — no Widgets dependency, headless-testable. +add_library(diagcore STATIC + core/Types.h + core/Types.cpp + core/TypeRegistry.h + core/TypeRegistry.cpp + core/NetworkModel.h + core/NetworkModel.cpp +) +target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) diff --git a/src/core/NetworkModel.cpp b/src/core/NetworkModel.cpp new file mode 100644 index 0000000..d0446e0 --- /dev/null +++ b/src/core/NetworkModel.cpp @@ -0,0 +1,252 @@ +#include "NetworkModel.h" + +namespace diag { + +NetworkModel::NetworkModel(TypeRegistry* registry, QObject* parent) + : QObject(parent), m_registry(registry ? registry : &TypeRegistry::instance()) +{ +} + +QUuid NetworkModel::addNode(const QString& typeId, QPointF pos, QUuid id) +{ + const NodeType* type = m_registry->nodeType(typeId); + if (!type) + return {}; + Node n; + n.id = id.isNull() ? QUuid::createUuid() : id; + n.typeId = typeId; + n.pos = pos; + m_nodes.insert(n.id, n); + m_nodeOrder.append(n.id); + emit nodeAdded(n.id); + return n.id; +} + +void NetworkModel::removeNode(QUuid id) +{ + if (!m_nodes.contains(id)) + return; + const QList attached = edgesOfNode(id); + for (QUuid e : attached) + removeEdge(e); + m_nodes.remove(id); + m_nodeOrder.removeOne(id); + emit nodeRemoved(id); +} + +void NetworkModel::moveNode(QUuid id, QPointF pos) +{ + auto it = m_nodes.find(id); + if (it == m_nodes.end() || it->pos == pos) + return; + it->pos = pos; + emit nodeMoved(id); +} + +const Node* NetworkModel::node(QUuid id) const +{ + auto it = m_nodes.constFind(id); + return it == m_nodes.constEnd() ? nullptr : &it.value(); +} + +QList NetworkModel::nodeIds() const +{ + return m_nodeOrder; +} + +const NodeType* NetworkModel::nodeTypeOf(QUuid id) const +{ + const Node* n = node(id); + return n ? m_registry->nodeType(n->typeId) : nullptr; +} + +bool NetworkModel::canConnect(QUuid nodeA, const QString& portA, QUuid nodeB, + const QString& portB, QString* reason) const +{ + auto fail = [reason](const QString& why) { + if (reason) + *reason = why; + return false; + }; + + const NodeType* typeA = nodeTypeOf(nodeA); + const NodeType* typeB = nodeTypeOf(nodeB); + if (!typeA || !typeB) + return fail(tr("Unknown node")); + const PortSpec* pa = typeA->port(portA); + const PortSpec* pb = typeB->port(portB); + if (!pa || !pb) + return fail(tr("Unknown port")); + if (nodeA == nodeB && portA == portB) + return fail(tr("Cannot connect a port to itself")); + if (pa->relation != pb->relation) + return fail(tr("Incompatible port relations (%1 vs %2)").arg(pa->relation, pb->relation)); + if (pa->direction == PortDirection::In && pb->direction == PortDirection::In) + return fail(tr("Cannot connect two inputs")); + if (pa->direction == PortDirection::Out && pb->direction == PortDirection::Out) + return fail(tr("Cannot connect two outputs")); + if (pa->maxConnections > 0 && connectionCount(nodeA, portA) >= pa->maxConnections) + return fail(tr("Port %1 is already occupied").arg(portA)); + if (pb->maxConnections > 0 && connectionCount(nodeB, portB) >= pb->maxConnections) + return fail(tr("Port %1 is already occupied").arg(portB)); + for (const auto& e : m_edges) { + const bool same = (e.fromNode == nodeA && e.fromPort == portA && e.toNode == nodeB + && e.toPort == portB) + || (e.fromNode == nodeB && e.fromPort == portB && e.toNode == nodeA + && e.toPort == portA); + if (same) + return fail(tr("Ports are already connected")); + } + return true; +} + +QUuid NetworkModel::addEdge(QUuid fromNode, const QString& fromPort, QUuid toNode, + const QString& toPort, QUuid id) +{ + if (!canConnect(fromNode, fromPort, toNode, toPort)) + return {}; + const NodeType* typeA = nodeTypeOf(fromNode); + const PortSpec* pa = typeA->port(fromPort); + const PortSpec* pb = nodeTypeOf(toNode)->port(toPort); + + Edge e; + e.id = id.isNull() ? QUuid::createUuid() : id; + // Normalize direction so the edge always leaves an Out port when one is present. + if (pa->direction == PortDirection::In || pb->direction == PortDirection::Out) { + e.fromNode = toNode; + e.fromPort = toPort; + e.toNode = fromNode; + e.toPort = fromPort; + } else { + e.fromNode = fromNode; + e.fromPort = fromPort; + e.toNode = toNode; + e.toPort = toPort; + } + e.relation = pa->relation; + m_edges.insert(e.id, e); + m_edgeOrder.append(e.id); + emit edgeAdded(e.id); + return e.id; +} + +void NetworkModel::removeEdge(QUuid id) +{ + if (!m_edges.remove(id)) + return; + m_edgeOrder.removeOne(id); + emit edgeRemoved(id); +} + +const Edge* NetworkModel::edge(QUuid id) const +{ + auto it = m_edges.constFind(id); + return it == m_edges.constEnd() ? nullptr : &it.value(); +} + +QList NetworkModel::edgeIds() const +{ + return m_edgeOrder; +} + +QList NetworkModel::edgesOfNode(QUuid nodeId) const +{ + QList out; + for (QUuid id : m_edgeOrder) { + const Edge& e = m_edges[id]; + if (e.fromNode == nodeId || e.toNode == nodeId) + out.append(id); + } + return out; +} + +int NetworkModel::connectionCount(QUuid nodeId, const QString& portId) const +{ + int count = 0; + for (const auto& e : m_edges) { + if (e.fromNode == nodeId && e.fromPort == portId) + ++count; + if (e.toNode == nodeId && e.toPort == portId) + ++count; + } + return count; +} + +QVariant NetworkModel::defaultFor(const QList& specs, const QString& key) +{ + for (const auto& s : specs) + if (s.id == key) + return s.defaultValue; + return {}; +} + +void NetworkModel::setNodeProperty(QUuid id, const QString& key, const QVariant& value) +{ + auto it = m_nodes.find(id); + if (it == m_nodes.end() || it->props.value(key) == value) + return; + it->props.insert(key, value); + emit nodeChanged(id); +} + +QVariant NetworkModel::nodeProperty(QUuid id, const QString& key) const +{ + const Node* n = node(id); + if (!n) + return {}; + if (n->props.contains(key)) + return n->props.value(key); + return defaultFor(m_registry->nodeProperties(n->typeId), key); +} + +void NetworkModel::setEdgeProperty(QUuid id, const QString& key, const QVariant& value) +{ + auto it = m_edges.find(id); + if (it == m_edges.end() || it->props.value(key) == value) + return; + it->props.insert(key, value); + emit edgeChanged(id); +} + +QVariant NetworkModel::edgeProperty(QUuid id, const QString& key) const +{ + const Edge* e = edge(id); + if (!e) + return {}; + if (e->props.contains(key)) + return e->props.value(key); + return defaultFor(m_registry->edgeProperties(e->relation), key); +} + +void NetworkModel::setEdgeFlow(QUuid id, double rate) +{ + auto it = m_edges.find(id); + if (it == m_edges.end() || qFuzzyCompare(it->flowRate + 1.0, rate + 1.0)) + return; + it->flowRate = rate; + emit flowChanged(id); +} + +QPointF NetworkModel::portScenePos(QUuid nodeId, const QString& portId) const +{ + const Node* n = node(nodeId); + const NodeType* t = nodeTypeOf(nodeId); + if (!n || !t) + return {}; + const PortSpec* p = t->port(portId); + if (!p) + return n->pos; + return n->pos + + QPointF(p->pos.x() * t->size.width(), p->pos.y() * t->size.height()); +} + +void NetworkModel::clear() +{ + m_nodes.clear(); + m_edges.clear(); + m_nodeOrder.clear(); + m_edgeOrder.clear(); + emit modelReset(); +} + +} // namespace diag diff --git a/src/core/NetworkModel.h b/src/core/NetworkModel.h new file mode 100644 index 0000000..c4b2e66 --- /dev/null +++ b/src/core/NetworkModel.h @@ -0,0 +1,75 @@ +#pragma once + +#include "TypeRegistry.h" +#include "Types.h" + +#include +#include + +namespace diag { + +// The document model: nodes placed on the canvas and edges connecting their +// ports. All edits are signalled so views stay in sync. Pure Qt Core/Gui — +// usable headless in tests. +class NetworkModel : public QObject { + Q_OBJECT +public: + explicit NetworkModel(TypeRegistry* registry = nullptr, QObject* parent = nullptr); + + TypeRegistry* registry() const { return m_registry; } + + // --- nodes --- + QUuid addNode(const QString& typeId, QPointF pos, QUuid id = {}); + void removeNode(QUuid id); // cascades to attached edges + void moveNode(QUuid id, QPointF pos); + const Node* node(QUuid id) const; + QList nodeIds() const; + const NodeType* nodeTypeOf(QUuid id) const; + + // --- edges --- + bool canConnect(QUuid nodeA, const QString& portA, QUuid nodeB, const QString& portB, + QString* reason = nullptr) const; + QUuid addEdge(QUuid fromNode, const QString& fromPort, QUuid toNode, const QString& toPort, + QUuid id = {}); + void removeEdge(QUuid id); + const Edge* edge(QUuid id) const; + QList edgeIds() const; + QList edgesOfNode(QUuid nodeId) const; + int connectionCount(QUuid nodeId, const QString& portId) const; + + // --- properties (values fall back to the spec default when unset) --- + void setNodeProperty(QUuid id, const QString& key, const QVariant& value); + QVariant nodeProperty(QUuid id, const QString& key) const; + void setEdgeProperty(QUuid id, const QString& key, const QVariant& value); + QVariant edgeProperty(QUuid id, const QString& key) const; + + // Solver results (does not go through the undo stack). + void setEdgeFlow(QUuid id, double rate); + + // Scene position of a port (node pos + relative port pos * size). + QPointF portScenePos(QUuid nodeId, const QString& portId) const; + + void clear(); + +signals: + void nodeAdded(QUuid id); + void nodeRemoved(QUuid id); + void nodeMoved(QUuid id); + void nodeChanged(QUuid id); + void edgeAdded(QUuid id); + void edgeRemoved(QUuid id); + void edgeChanged(QUuid id); + void flowChanged(QUuid id); + void modelReset(); + +private: + static QVariant defaultFor(const QList& specs, const QString& key); + + TypeRegistry* m_registry; + QHash m_nodes; + QHash m_edges; + QList m_nodeOrder; // stable iteration/serialization order + QList m_edgeOrder; +}; + +} // namespace diag diff --git a/src/core/TypeRegistry.cpp b/src/core/TypeRegistry.cpp new file mode 100644 index 0000000..4e2eff2 --- /dev/null +++ b/src/core/TypeRegistry.cpp @@ -0,0 +1,338 @@ +#include "TypeRegistry.h" + +#include +#include +#include + +namespace diag { + +TypeRegistry::TypeRegistry() = default; + +TypeRegistry& TypeRegistry::instance() +{ + static TypeRegistry reg; + return reg; +} + +void TypeRegistry::clear() +{ + m_relations.clear(); + m_nodeTypes.clear(); + m_catalogues.clear(); + m_edgeProps.clear(); +} + +void TypeRegistry::addRelation(const Relation& r) +{ + m_relations.append(r); +} + +void TypeRegistry::addNodeType(const NodeType& t) +{ + m_nodeTypes.append(t); +} + +void TypeRegistry::addCatalogue(const Catalogue& c) +{ + m_catalogues.append(c); +} + +void TypeRegistry::setEdgeProperties(const QString& relationId, const QList& specs) +{ + m_edgeProps.insert(relationId, specs); +} + +const Relation* TypeRegistry::relation(const QString& id) const +{ + for (const auto& r : m_relations) + if (r.id == id) + return &r; + return nullptr; +} + +QList TypeRegistry::relations() const +{ + return m_relations; +} + +const NodeType* TypeRegistry::nodeType(const QString& id) const +{ + for (const auto& t : m_nodeTypes) + if (t.id == id) + return &t; + return nullptr; +} + +QList TypeRegistry::nodeTypes() const +{ + QList out; + out.reserve(m_nodeTypes.size()); + for (const auto& t : m_nodeTypes) + out.append(&t); + return out; +} + +QStringList TypeRegistry::nodeGroups() const +{ + QStringList groups; + for (const auto& t : m_nodeTypes) + if (!groups.contains(t.group)) + groups.append(t.group); + return groups; +} + +const Catalogue* TypeRegistry::catalogue(const QString& id) const +{ + for (const auto& c : m_catalogues) + if (c.id == id) + return &c; + return nullptr; +} + +QList TypeRegistry::catalogues() const +{ + QList out; + out.reserve(m_catalogues.size()); + for (const auto& c : m_catalogues) + out.append(&c); + return out; +} + +QList TypeRegistry::commonNodeProperties() +{ + PropertySpec title; + title.id = QStringLiteral("title"); + title.name = QStringLiteral("Title"); + title.group = QStringLiteral("General"); + title.type = PropertyType::String; + title.defaultValue = QString(); + + PropertySpec labelColor; + labelColor.id = QStringLiteral("labelColor"); + labelColor.name = QStringLiteral("Label color"); + labelColor.group = QStringLiteral("Presentation"); + labelColor.type = PropertyType::Color; + labelColor.defaultValue = QString(); // empty = theme default + + PropertySpec tags; + tags.id = QStringLiteral("tags"); + tags.name = QStringLiteral("Tags"); + tags.group = QStringLiteral("General"); + tags.type = PropertyType::ValueList; + tags.defaultValue = QStringList(); + + return {title, tags, labelColor}; +} + +QList TypeRegistry::commonEdgeProperties() +{ + QList out; + + PropertySpec title; + title.id = QStringLiteral("title"); + title.name = QStringLiteral("Title"); + title.group = QStringLiteral("General"); + title.type = PropertyType::String; + title.defaultValue = QString(); + out << title; + + PropertySpec lineStyle; + lineStyle.id = QStringLiteral("lineStyle"); + lineStyle.name = QStringLiteral("Line style"); + lineStyle.group = QStringLiteral("Presentation"); + lineStyle.type = PropertyType::Enum; + lineStyle.enumValues = {QStringLiteral("Solid"), QStringLiteral("Dashed"), + QStringLiteral("Dotted"), QStringLiteral("DashDot")}; + lineStyle.defaultValue = QStringLiteral("Solid"); + out << lineStyle; + + PropertySpec width; + width.id = QStringLiteral("lineWidth"); + width.name = QStringLiteral("Line width"); + width.group = QStringLiteral("Presentation"); + width.type = PropertyType::Float; + width.defaultValue = 2.0; + width.min = 0.5; + width.max = 10.0; + out << width; + + PropertySpec color; + color.id = QStringLiteral("color"); + color.name = QStringLiteral("Color"); + color.group = QStringLiteral("Presentation"); + color.type = PropertyType::Color; + color.defaultValue = QString(); // empty = relation color + out << color; + + const QStringList decorations = {QStringLiteral("None"), QStringLiteral("Arrow"), + QStringLiteral("Circle"), QStringLiteral("Diamond"), + QStringLiteral("Bar")}; + + PropertySpec head; + head.id = QStringLiteral("headDecoration"); + head.name = QStringLiteral("Head decoration"); + head.group = QStringLiteral("Presentation"); + head.type = PropertyType::Enum; + head.enumValues = decorations; + head.defaultValue = QStringLiteral("Arrow"); + out << head; + + PropertySpec tail; + tail.id = QStringLiteral("tailDecoration"); + tail.name = QStringLiteral("Tail decoration"); + tail.group = QStringLiteral("Presentation"); + tail.type = PropertyType::Enum; + tail.enumValues = decorations; + tail.defaultValue = QStringLiteral("None"); + out << tail; + + return out; +} + +QList TypeRegistry::nodeProperties(const QString& typeId) const +{ + QList out = commonNodeProperties(); + if (const NodeType* t = nodeType(typeId)) + out.append(t->properties); + return out; +} + +QList TypeRegistry::edgeProperties(const QString& relationId) const +{ + QList out = commonEdgeProperties(); + out.append(m_edgeProps.value(relationId)); + return out; +} + +PropertySpec propertySpecFromJson(const QJsonObject& o) +{ + PropertySpec s; + s.id = o.value(QLatin1String("id")).toString(); + s.name = o.value(QLatin1String("name")).toString(s.id); + s.group = o.value(QLatin1String("group")).toString(QStringLiteral("General")); + s.type = propertyTypeFromString(o.value(QLatin1String("type")).toString()); + s.unit = o.value(QLatin1String("unit")).toString(); + s.catalogueId = o.value(QLatin1String("catalogue")).toString(); + s.min = o.value(QLatin1String("min")).toDouble(); + s.max = o.value(QLatin1String("max")).toDouble(); + s.readOnly = o.value(QLatin1String("readOnly")).toBool(); + for (const auto& v : o.value(QLatin1String("values")).toArray()) + s.enumValues.append(v.toString()); + const QJsonValue def = o.value(QLatin1String("default")); + switch (s.type) { + case PropertyType::Int: + s.defaultValue = def.toInt(); + break; + case PropertyType::Float: + s.defaultValue = def.toDouble(); + break; + case PropertyType::CatalogueItems: + case PropertyType::ValueList: { + QStringList list; + for (const auto& v : def.toArray()) + list.append(v.toString()); + s.defaultValue = list; + break; + } + default: + s.defaultValue = def.toString(); + break; + } + return s; +} + +static QPointF pointFromJson(const QJsonValue& v, QPointF fallback = {}) +{ + const QJsonArray a = v.toArray(); + if (a.size() == 2) + return {a.at(0).toDouble(), a.at(1).toDouble()}; + return fallback; +} + +bool TypeRegistry::loadFromJson(const QByteArray& jsonData, QString* error) +{ + QJsonParseError perr; + const QJsonDocument doc = QJsonDocument::fromJson(jsonData, &perr); + if (doc.isNull()) { + if (error) + *error = perr.errorString(); + return false; + } + clear(); + const QJsonObject root = doc.object(); + + for (const auto& rv : root.value(QLatin1String("relations")).toArray()) { + const QJsonObject o = rv.toObject(); + Relation r; + r.id = o.value(QLatin1String("id")).toString(); + r.name = o.value(QLatin1String("name")).toString(r.id); + r.color = QColor(o.value(QLatin1String("color")).toString()); + r.darkColor = QColor(o.value(QLatin1String("darkColor")).toString( + o.value(QLatin1String("color")).toString())); + addRelation(r); + } + + for (const auto& cv : root.value(QLatin1String("catalogues")).toArray()) { + const QJsonObject o = cv.toObject(); + Catalogue c; + c.id = o.value(QLatin1String("id")).toString(); + c.name = o.value(QLatin1String("name")).toString(c.id); + for (const auto& iv : o.value(QLatin1String("items")).toArray()) { + const QJsonObject io = iv.toObject(); + CatalogueItem item; + item.id = io.value(QLatin1String("id")).toString(); + item.name = io.value(QLatin1String("name")).toString(item.id); + item.attributes = io.value(QLatin1String("attributes")).toObject().toVariantMap(); + c.items.append(item); + } + addCatalogue(c); + } + + const QJsonObject edgeProps = root.value(QLatin1String("edgeProperties")).toObject(); + for (auto it = edgeProps.begin(); it != edgeProps.end(); ++it) { + QList specs; + for (const auto& pv : it.value().toArray()) + specs.append(propertySpecFromJson(pv.toObject())); + setEdgeProperties(it.key(), specs); + } + + for (const auto& tv : root.value(QLatin1String("nodeTypes")).toArray()) { + const QJsonObject o = tv.toObject(); + NodeType t; + t.id = o.value(QLatin1String("id")).toString(); + t.name = o.value(QLatin1String("name")).toString(t.id); + t.group = o.value(QLatin1String("group")).toString(QStringLiteral("Misc")); + t.svgPath = o.value(QLatin1String("svg")).toString(); + const QPointF sz = pointFromJson(o.value(QLatin1String("size")), {60, 60}); + t.size = QSizeF(sz.x(), sz.y()); + for (const auto& pv : o.value(QLatin1String("ports")).toArray()) { + const QJsonObject po = pv.toObject(); + PortSpec p; + p.id = po.value(QLatin1String("id")).toString(); + p.relation = po.value(QLatin1String("relation")).toString(); + p.direction = portDirectionFromString(po.value(QLatin1String("direction")).toString()); + p.pos = pointFromJson(po.value(QLatin1String("pos")), {0.5, 0.5}); + p.side = sideFromString(po.value(QLatin1String("side")).toString()); + p.maxConnections = po.value(QLatin1String("maxConnections")).toInt(1); + t.ports.append(p); + } + for (const auto& pv : o.value(QLatin1String("properties")).toArray()) + t.properties.append(propertySpecFromJson(pv.toObject())); + addNodeType(t); + } + + return true; +} + +bool TypeRegistry::loadFromFile(const QString& path, QString* error) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + if (error) + *error = f.errorString(); + return false; + } + return loadFromJson(f.readAll(), error); +} + +} // namespace diag diff --git a/src/core/TypeRegistry.h b/src/core/TypeRegistry.h new file mode 100644 index 0000000..5eb46c5 --- /dev/null +++ b/src/core/TypeRegistry.h @@ -0,0 +1,55 @@ +#pragma once + +#include "Types.h" + +#include +#include + +namespace diag { + +// Holds relation, node-type, catalogue and edge-property definitions. +// Definitions are data-driven: loaded from a JSON config (see +// resources/config/nodetypes.json). Common presentation/general property +// specs for nodes and edges are built in. +class TypeRegistry { +public: + TypeRegistry(); + + static TypeRegistry& instance(); + + bool loadFromJson(const QByteArray& jsonData, QString* error = nullptr); + bool loadFromFile(const QString& path, QString* error = nullptr); + void clear(); + + void addRelation(const Relation& r); + void addNodeType(const NodeType& t); + void addCatalogue(const Catalogue& c); + void setEdgeProperties(const QString& relationId, const QList& specs); + + const Relation* relation(const QString& id) const; + QList relations() const; + + const NodeType* nodeType(const QString& id) const; + QList nodeTypes() const; + QStringList nodeGroups() const; // in definition order + + const Catalogue* catalogue(const QString& id) const; + QList catalogues() const; + + // Full spec list: common specs + type/relation specific ones. + QList nodeProperties(const QString& typeId) const; + QList edgeProperties(const QString& relationId) const; + + static QList commonNodeProperties(); + static QList commonEdgeProperties(); + +private: + QList m_relations; + QList m_nodeTypes; + QList m_catalogues; + QHash> m_edgeProps; // by relation id +}; + +PropertySpec propertySpecFromJson(const QJsonObject& o); + +} // namespace diag diff --git a/src/core/Types.cpp b/src/core/Types.cpp new file mode 100644 index 0000000..691af14 --- /dev/null +++ b/src/core/Types.cpp @@ -0,0 +1,107 @@ +#include "Types.h" + +namespace diag { + +const PortSpec* NodeType::port(const QString& portId) const +{ + for (const auto& p : ports) + if (p.id == portId) + return &p; + return nullptr; +} + +const CatalogueItem* Catalogue::item(const QString& itemId) const +{ + for (const auto& i : items) + if (i.id == itemId) + return &i; + return nullptr; +} + +QPointF sideVector(Side s) +{ + switch (s) { + case Side::Left: return {-1, 0}; + case Side::Right: return {1, 0}; + case Side::Top: return {0, -1}; + case Side::Bottom: return {0, 1}; + } + return {1, 0}; +} + +QString portDirectionToString(PortDirection d) +{ + switch (d) { + case PortDirection::In: return QStringLiteral("in"); + case PortDirection::Out: return QStringLiteral("out"); + case PortDirection::InOut: return QStringLiteral("inout"); + } + return QStringLiteral("inout"); +} + +PortDirection portDirectionFromString(const QString& s) +{ + if (s == QLatin1String("in")) + return PortDirection::In; + if (s == QLatin1String("out")) + return PortDirection::Out; + return PortDirection::InOut; +} + +QString sideToString(Side s) +{ + switch (s) { + case Side::Left: return QStringLiteral("left"); + case Side::Right: return QStringLiteral("right"); + case Side::Top: return QStringLiteral("top"); + case Side::Bottom: return QStringLiteral("bottom"); + } + return QStringLiteral("left"); +} + +Side sideFromString(const QString& s) +{ + if (s == QLatin1String("right")) + return Side::Right; + if (s == QLatin1String("top")) + return Side::Top; + if (s == QLatin1String("bottom")) + return Side::Bottom; + return Side::Left; +} + +QString propertyTypeToString(PropertyType t) +{ + switch (t) { + case PropertyType::String: return QStringLiteral("string"); + case PropertyType::Int: return QStringLiteral("int"); + case PropertyType::Float: return QStringLiteral("float"); + case PropertyType::Enum: return QStringLiteral("enum"); + case PropertyType::CatalogueItem: return QStringLiteral("catalogueItem"); + case PropertyType::CatalogueItems: return QStringLiteral("catalogueItems"); + case PropertyType::Color: return QStringLiteral("color"); + case PropertyType::ValueList: return QStringLiteral("valueList"); + } + return QStringLiteral("string"); +} + +PropertyType propertyTypeFromString(const QString& s) +{ + if (s == QLatin1String("int")) + return PropertyType::Int; + if (s == QLatin1String("float")) + return PropertyType::Float; + if (s == QLatin1String("enum")) + return PropertyType::Enum; + if (s == QLatin1String("catalogueItem")) + return PropertyType::CatalogueItem; + if (s == QLatin1String("catalogueItems")) + return PropertyType::CatalogueItems; + if (s == QLatin1String("color")) + return PropertyType::Color; + if (s == QLatin1String("valueList")) + return PropertyType::ValueList; + return PropertyType::String; +} + +} // namespace diag diff --git a/src/core/Types.h b/src/core/Types.h new file mode 100644 index 0000000..326a5d3 --- /dev/null +++ b/src/core/Types.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace diag { + +// Which side of the node rectangle a port sits on; defines the exit +// direction of the first routed segment. +enum class Side { Left, Right, Top, Bottom }; + +enum class PortDirection { In, Out, InOut }; + +// A connection domain (water, power, ...). Edges may only join ports of the +// same relation. Colors are per color scheme. +struct Relation { + QString id; + QString name; + QColor color; // light scheme + QColor darkColor; // dark / high-contrast schemes +}; + +struct PortSpec { + QString id; + QString relation; + PortDirection direction = PortDirection::InOut; + QPointF pos; // relative [0..1] within the node rect + Side side = Side::Left; + int maxConnections = 1; // 0 = unlimited +}; + +enum class PropertyType { + String, + Int, + Float, + Enum, + CatalogueItem, // value: item id (QString) + CatalogueItems, // value: item ids (QStringList) + Color, // value: "#rrggbb" string; empty = automatic + ValueList, // value: QStringList of free-form values +}; + +struct PropertySpec { + QString id; + QString name; + QString group; // "General", "Presentation", "Physics", ... + PropertyType type = PropertyType::String; + QVariant defaultValue; + QString unit; + QStringList enumValues; + QString catalogueId; + double min = 0.0; + double max = 0.0; // min == max means unbounded + bool readOnly = false; +}; + +struct NodeType { + QString id; + QString name; + QString group; // palette group + QString svgPath; // resource path of the vector image + QSizeF size{60, 60}; + QList ports; + QList properties; // in addition to common node properties + + const PortSpec* port(const QString& portId) const; +}; + +struct CatalogueItem { + QString id; + QString name; + QVariantMap attributes; // e.g. {"diameter": 50, "material": "steel"} +}; + +struct Catalogue { + QString id; + QString name; + QList items; + + const CatalogueItem* item(const QString& itemId) const; +}; + +struct Node { + QUuid id; + QString typeId; + QPointF pos; // scene coordinates of the node rect's top-left corner + QVariantMap props; +}; + +struct Edge { + QUuid id; + QUuid fromNode; + QString fromPort; + QUuid toNode; + QString toPort; + QString relation; + QVariantMap props; + double flowRate = 0.0; // solver result; positive flows from -> to +}; + +// Unit vector pointing away from the node for a given side. +QPointF sideVector(Side s); + +QString portDirectionToString(PortDirection d); +PortDirection portDirectionFromString(const QString& s); +QString sideToString(Side s); +Side sideFromString(const QString& s); +QString propertyTypeToString(PropertyType t); +PropertyType propertyTypeFromString(const QString& s); + +} // namespace diag diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..449baa0 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,10 @@ +# Test targets are added per module; all run offscreen via ctest. +function(diag_add_test NAME) + add_executable(${NAME} ${NAME}.cpp) + target_link_libraries(${NAME} PRIVATE ${ARGN} Qt6::Test) + add_test(NAME ${NAME} COMMAND ${NAME}) + set_tests_properties(${NAME} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endfunction() + +diag_add_test(tst_model diagcore) +diag_add_test(tst_properties diagcore) diff --git a/tests/TestFixtures.h b/tests/TestFixtures.h new file mode 100644 index 0000000..8497a26 --- /dev/null +++ b/tests/TestFixtures.h @@ -0,0 +1,135 @@ +#pragma once + +// Shared in-code registry fixture: a minimal water + power schema mirroring +// the shape of the shipped JSON config, without resource dependencies. + +#include "core/TypeRegistry.h" + +namespace fixtures { + +inline diag::PropertySpec floatSpec(const QString& id, const QString& name, const QString& group, + double def, const QString& unit = {}) +{ + diag::PropertySpec s; + s.id = id; + s.name = name; + s.group = group; + s.type = diag::PropertyType::Float; + s.defaultValue = def; + s.unit = unit; + return s; +} + +inline diag::PortSpec port(const QString& id, const QString& relation, diag::PortDirection dir, + QPointF pos, diag::Side side, int maxConnections = 1) +{ + diag::PortSpec p; + p.id = id; + p.relation = relation; + p.direction = dir; + p.pos = pos; + p.side = side; + p.maxConnections = maxConnections; + return p; +} + +inline void fillRegistry(diag::TypeRegistry& reg) +{ + using namespace diag; + reg.clear(); + + reg.addRelation({QStringLiteral("water"), QStringLiteral("Water"), QColor("#1c78c0"), + QColor("#4aa3e8")}); + reg.addRelation({QStringLiteral("power"), QStringLiteral("Power"), QColor("#d99000"), + QColor("#f0b429")}); + + Catalogue pipes; + pipes.id = QStringLiteral("pipe_series"); + pipes.name = QStringLiteral("Pipe series"); + pipes.items = {{QStringLiteral("dn50"), QStringLiteral("DN50 Steel"), + {{QStringLiteral("diameter"), 50}}}, + {QStringLiteral("dn100"), QStringLiteral("DN100 Steel"), + {{QStringLiteral("diameter"), 100}}}}; + reg.addCatalogue(pipes); + + QList waterEdge; + waterEdge << floatSpec(QStringLiteral("length"), QStringLiteral("Length"), + QStringLiteral("Physics"), 10.0, QStringLiteral("m")); + waterEdge << floatSpec(QStringLiteral("diameter"), QStringLiteral("Diameter"), + QStringLiteral("Physics"), 100.0, QStringLiteral("mm")); + PropertySpec flowType; + flowType.id = QStringLiteral("flowType"); + flowType.name = QStringLiteral("Flow type"); + flowType.group = QStringLiteral("Physics"); + flowType.type = PropertyType::Enum; + flowType.enumValues = {QStringLiteral("Laminar"), QStringLiteral("Turbulent")}; + flowType.defaultValue = QStringLiteral("Laminar"); + waterEdge << flowType; + PropertySpec series; + series.id = QStringLiteral("series"); + series.name = QStringLiteral("Pipe series"); + series.group = QStringLiteral("Physics"); + series.type = PropertyType::CatalogueItem; + series.catalogueId = QStringLiteral("pipe_series"); + waterEdge << series; + reg.setEdgeProperties(QStringLiteral("water"), waterEdge); + + NodeType source; + source.id = QStringLiteral("source"); + source.name = QStringLiteral("Source"); + source.group = QStringLiteral("Water"); + source.size = {60, 60}; + source.ports = {port(QStringLiteral("out"), QStringLiteral("water"), PortDirection::Out, + {1.0, 0.5}, Side::Right, 0)}; + source.properties = {floatSpec(QStringLiteral("supply"), QStringLiteral("Supply"), + QStringLiteral("Physics"), 10.0, QStringLiteral("m³/h"))}; + reg.addNodeType(source); + + NodeType pump; + pump.id = QStringLiteral("pump"); + pump.name = QStringLiteral("Pump"); + pump.group = QStringLiteral("Water"); + pump.size = {60, 60}; + pump.ports = {port(QStringLiteral("in"), QStringLiteral("water"), PortDirection::In, + {0.0, 0.5}, Side::Left), + port(QStringLiteral("out"), QStringLiteral("water"), PortDirection::Out, + {1.0, 0.5}, Side::Right)}; + reg.addNodeType(pump); + + NodeType junction; + junction.id = QStringLiteral("junction"); + junction.name = QStringLiteral("Junction"); + junction.group = QStringLiteral("Water"); + junction.size = {40, 40}; + junction.ports = {port(QStringLiteral("w"), QStringLiteral("water"), PortDirection::InOut, + {0.0, 0.5}, Side::Left, 0), + port(QStringLiteral("e"), QStringLiteral("water"), PortDirection::InOut, + {1.0, 0.5}, Side::Right, 0), + port(QStringLiteral("n"), QStringLiteral("water"), PortDirection::InOut, + {0.5, 0.0}, Side::Top, 0), + port(QStringLiteral("s"), QStringLiteral("water"), PortDirection::InOut, + {0.5, 1.0}, Side::Bottom, 0)}; + reg.addNodeType(junction); + + NodeType consumer; + consumer.id = QStringLiteral("consumer"); + consumer.name = QStringLiteral("Consumer"); + consumer.group = QStringLiteral("Water"); + consumer.size = {60, 60}; + consumer.ports = {port(QStringLiteral("in"), QStringLiteral("water"), PortDirection::In, + {0.0, 0.5}, Side::Left)}; + consumer.properties = {floatSpec(QStringLiteral("demand"), QStringLiteral("Demand"), + QStringLiteral("Physics"), 10.0, QStringLiteral("m³/h"))}; + reg.addNodeType(consumer); + + NodeType generator; + generator.id = QStringLiteral("generator"); + generator.name = QStringLiteral("Generator"); + generator.group = QStringLiteral("Power"); + generator.size = {60, 60}; + generator.ports = {port(QStringLiteral("out"), QStringLiteral("power"), PortDirection::Out, + {1.0, 0.5}, Side::Right, 0)}; + reg.addNodeType(generator); +} + +} // namespace fixtures diff --git a/tests/tst_model.cpp b/tests/tst_model.cpp new file mode 100644 index 0000000..9d14480 --- /dev/null +++ b/tests/tst_model.cpp @@ -0,0 +1,145 @@ +#include "TestFixtures.h" +#include "core/NetworkModel.h" + +#include +#include + +using namespace diag; + +class TestModel : public QObject { + Q_OBJECT +private slots: + void init() + { + fixtures::fillRegistry(m_reg); + delete m_model; + m_model = new NetworkModel(&m_reg, this); + } + + void addNode_unknownTypeFails() + { + QVERIFY(m_model->addNode(QStringLiteral("nope"), {0, 0}).isNull()); + QCOMPARE(m_model->nodeIds().size(), 0); + } + + void addNode_emitsAndStores() + { + QSignalSpy spy(m_model, &NetworkModel::nodeAdded); + const QUuid id = m_model->addNode(QStringLiteral("pump"), {100, 40}); + QVERIFY(!id.isNull()); + QCOMPARE(spy.count(), 1); + QCOMPARE(m_model->node(id)->pos, QPointF(100, 40)); + QCOMPARE(m_model->nodeTypeOf(id)->id, QStringLiteral("pump")); + } + + void connect_validOutToIn() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + QVERIFY(m_model->canConnect(src, "out", pump, "in")); + const QUuid e = m_model->addEdge(src, "out", pump, "in"); + QVERIFY(!e.isNull()); + QCOMPARE(m_model->edge(e)->relation, QStringLiteral("water")); + } + + void connect_directionNormalized() + { + // Dragging from the In port must still produce an Out->In edge. + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + const QUuid e = m_model->addEdge(pump, "in", src, "out"); + QVERIFY(!e.isNull()); + QCOMPARE(m_model->edge(e)->fromNode, src); + QCOMPARE(m_model->edge(e)->toNode, pump); + } + + void connect_relationMismatchFails() + { + const QUuid gen = m_model->addNode(QStringLiteral("generator"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + QString reason; + QVERIFY(!m_model->canConnect(gen, "out", pump, "in", &reason)); + QVERIFY(reason.contains(QStringLiteral("relation"))); + QVERIFY(m_model->addEdge(gen, "out", pump, "in").isNull()); + } + + void connect_twoInputsFails() + { + const QUuid a = m_model->addNode(QStringLiteral("pump"), {0, 0}); + const QUuid b = m_model->addNode(QStringLiteral("consumer"), {200, 0}); + QVERIFY(!m_model->canConnect(a, "in", b, "in")); + } + + void connect_selfPortFails() + { + const QUuid j = m_model->addNode(QStringLiteral("junction"), {0, 0}); + QVERIFY(!m_model->canConnect(j, "w", j, "w")); + // Different ports on the same node are allowed. + QVERIFY(m_model->canConnect(j, "w", j, "e")); + } + + void connect_occupiedPortFails() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid p1 = m_model->addNode(QStringLiteral("pump"), {200, 0}); + const QUuid p2 = m_model->addNode(QStringLiteral("pump"), {200, 100}); + QVERIFY(!m_model->addEdge(src, "out", p1, "in").isNull()); + QString reason; + QVERIFY(!m_model->canConnect(p2, "out", p1, "in", &reason)); + QVERIFY(reason.contains(QStringLiteral("occupied"))); + // Source "out" is unlimited (maxConnections = 0). + QVERIFY(m_model->canConnect(src, "out", p2, "in")); + } + + void connect_duplicateEdgeFails() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid j = m_model->addNode(QStringLiteral("junction"), {200, 0}); + QVERIFY(!m_model->addEdge(src, "out", j, "w").isNull()); + QVERIFY(!m_model->canConnect(src, "out", j, "w")); + QVERIFY(!m_model->canConnect(j, "w", src, "out")); + } + + void junction_allowsManyConnections() + { + const QUuid j = m_model->addNode(QStringLiteral("junction"), {0, 0}); + const QUuid src = m_model->addNode(QStringLiteral("source"), {-200, 0}); + const QUuid c1 = m_model->addNode(QStringLiteral("consumer"), {200, -100}); + const QUuid c2 = m_model->addNode(QStringLiteral("consumer"), {200, 100}); + QVERIFY(!m_model->addEdge(src, "out", j, "w").isNull()); + QVERIFY(!m_model->addEdge(j, "e", c1, "in").isNull()); + QVERIFY(!m_model->addEdge(j, "e", c2, "in").isNull()); + QCOMPARE(m_model->connectionCount(j, "e"), 2); + } + + void removeNode_cascadesEdges() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + const QUuid c = m_model->addNode(QStringLiteral("consumer"), {400, 0}); + m_model->addEdge(src, "out", pump, "in"); + m_model->addEdge(pump, "out", c, "in"); + QSignalSpy edgeSpy(m_model, &NetworkModel::edgeRemoved); + m_model->removeNode(pump); + QCOMPARE(edgeSpy.count(), 2); + QCOMPARE(m_model->edgeIds().size(), 0); + QCOMPARE(m_model->nodeIds().size(), 2); + } + + void moveNode_emitsAndUpdatesPortPos() + { + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {0, 0}); + QSignalSpy spy(m_model, &NetworkModel::nodeMoved); + m_model->moveNode(pump, {100, 100}); + QCOMPARE(spy.count(), 1); + // Port "out" is at relative (1.0, 0.5) of a 60x60 node. + QCOMPARE(m_model->portScenePos(pump, "out"), QPointF(160, 130)); + } + +private: + TypeRegistry m_reg; + NetworkModel* m_model = nullptr; +}; + +QTEST_GUILESS_MAIN(TestModel) +#include "tst_model.moc" diff --git a/tests/tst_properties.cpp b/tests/tst_properties.cpp new file mode 100644 index 0000000..2f5dff4 --- /dev/null +++ b/tests/tst_properties.cpp @@ -0,0 +1,134 @@ +#include "TestFixtures.h" +#include "core/NetworkModel.h" + +#include +#include +#include +#include + +using namespace diag; + +class TestProperties : public QObject { + Q_OBJECT +private slots: + void init() + { + fixtures::fillRegistry(m_reg); + delete m_model; + m_model = new NetworkModel(&m_reg, this); + } + + void nodeProperty_fallsBackToDefault() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 10.0); + QCOMPARE(m_model->nodeProperty(src, "title").toString(), QString()); + m_model->setNodeProperty(src, "supply", 42.5); + QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 42.5); + } + + void edgeProperty_commonAndRelationSpecs() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + const QUuid e = m_model->addEdge(src, "out", pump, "in"); + + const auto specs = m_reg.edgeProperties(QStringLiteral("water")); + QStringList ids; + for (const auto& s : specs) + ids << s.id; + // Common presentation specs plus water physics specs. + for (const char* expected : {"title", "lineStyle", "lineWidth", "color", + "headDecoration", "tailDecoration", "length", "diameter", + "flowType", "series"}) + QVERIFY2(ids.contains(QLatin1String(expected)), expected); + + QCOMPARE(m_model->edgeProperty(e, "lineStyle").toString(), QStringLiteral("Solid")); + QCOMPARE(m_model->edgeProperty(e, "diameter").toDouble(), 100.0); + QCOMPARE(m_model->edgeProperty(e, "flowType").toString(), QStringLiteral("Laminar")); + } + + void propertyGroups_present() + { + const auto specs = m_reg.edgeProperties(QStringLiteral("water")); + QStringList groups; + for (const auto& s : specs) + if (!groups.contains(s.group)) + groups << s.group; + QVERIFY(groups.contains(QStringLiteral("General"))); + QVERIFY(groups.contains(QStringLiteral("Presentation"))); + QVERIFY(groups.contains(QStringLiteral("Physics"))); + } + + void allPropertyTypes_storeAndRead() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0}); + const QUuid e = m_model->addEdge(src, "out", pump, "in"); + + m_model->setNodeProperty(src, "title", QStringLiteral("Main intake")); // String + m_model->setNodeProperty(src, "tags", QStringList{"district-1", "hp"}); // ValueList + m_model->setNodeProperty(src, "labelColor", QStringLiteral("#ff8800")); // Color + m_model->setEdgeProperty(e, "length", 125.5); // Float + m_model->setEdgeProperty(e, "flowType", QStringLiteral("Turbulent")); // Enum + m_model->setEdgeProperty(e, "series", QStringLiteral("dn50")); // CatalogueItem + + QCOMPARE(m_model->nodeProperty(src, "title").toString(), QStringLiteral("Main intake")); + QCOMPARE(m_model->nodeProperty(src, "tags").toStringList(), + (QStringList{"district-1", "hp"})); + QCOMPARE(m_model->nodeProperty(src, "labelColor").toString(), QStringLiteral("#ff8800")); + QCOMPARE(m_model->edgeProperty(e, "length").toDouble(), 125.5); + QCOMPARE(m_model->edgeProperty(e, "flowType").toString(), QStringLiteral("Turbulent")); + QCOMPARE(m_model->edgeProperty(e, "series").toString(), QStringLiteral("dn50")); + } + + void catalogue_lookup() + { + const Catalogue* c = m_reg.catalogue(QStringLiteral("pipe_series")); + QVERIFY(c); + QCOMPARE(c->items.size(), 2); + const CatalogueItem* dn50 = c->item(QStringLiteral("dn50")); + QVERIFY(dn50); + QCOMPARE(dn50->attributes.value(QStringLiteral("diameter")).toInt(), 50); + QVERIFY(!c->item(QStringLiteral("missing"))); + } + + void propertySpec_parsesFromJson() + { + const auto doc = QJsonDocument::fromJson(R"({ + "id": "materials", "name": "Materials", "group": "Physics", + "type": "catalogueItems", "catalogue": "pipe_series", + "default": ["dn50"], "readOnly": false + })"); + const PropertySpec s = propertySpecFromJson(doc.object()); + QCOMPARE(s.type, PropertyType::CatalogueItems); + QCOMPARE(s.catalogueId, QStringLiteral("pipe_series")); + QCOMPARE(s.defaultValue.toStringList(), QStringList{"dn50"}); + + const auto doc2 = QJsonDocument::fromJson(R"({ + "id": "mode", "type": "enum", "values": ["A", "B"], "default": "B", + "min": 1, "max": 5 + })"); + const PropertySpec s2 = propertySpecFromJson(doc2.object()); + QCOMPARE(s2.enumValues, (QStringList{"A", "B"})); + QCOMPARE(s2.defaultValue.toString(), QStringLiteral("B")); + QCOMPARE(s2.min, 1.0); + QCOMPARE(s2.max, 5.0); + } + + void propertyChange_emitsSignal() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + QSignalSpy spy(m_model, &NetworkModel::nodeChanged); + m_model->setNodeProperty(src, "title", QStringLiteral("A")); + m_model->setNodeProperty(src, "title", QStringLiteral("A")); // no-op + QCOMPARE(spy.count(), 1); + } + +private: + TypeRegistry m_reg; + NetworkModel* m_model = nullptr; +}; + +QTEST_GUILESS_MAIN(TestProperties) +#include "tst_properties.moc"