From 81f2a9ff8b00842808115b46954b6eb4088718ea Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:14:11 +0200 Subject: [PATCH] feat(core): add JSON document save/load Co-Authored-By: Claude Fable 5 --- src/CMakeLists.txt | 2 + src/core/JsonIo.cpp | 128 +++++++++++++++++++++++++++++++++++++++++++ src/core/JsonIo.h | 19 +++++++ tests/CMakeLists.txt | 1 + tests/tst_json.cpp | 84 ++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 src/core/JsonIo.cpp create mode 100644 src/core/JsonIo.h create mode 100644 tests/tst_json.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5933ef3..c30754a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,8 @@ add_library(diagcore STATIC core/Router.cpp core/FlowSolver.h core/FlowSolver.cpp + core/JsonIo.h + core/JsonIo.cpp ) target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) diff --git a/src/core/JsonIo.cpp b/src/core/JsonIo.cpp new file mode 100644 index 0000000..da9d9b9 --- /dev/null +++ b/src/core/JsonIo.cpp @@ -0,0 +1,128 @@ +#include "JsonIo.h" + +#include "NetworkModel.h" + +#include +#include +#include +#include + +namespace diag { + +QJsonObject JsonIo::toJson(const NetworkModel& model) +{ + QJsonArray nodes; + for (QUuid id : model.nodeIds()) { + const Node* n = model.node(id); + QJsonObject o; + o.insert(QLatin1String("id"), n->id.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("type"), n->typeId); + o.insert(QLatin1String("pos"), QJsonArray{n->pos.x(), n->pos.y()}); + if (!n->props.isEmpty()) + o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(n->props)); + nodes.append(o); + } + + QJsonArray edges; + for (QUuid id : model.edgeIds()) { + const Edge* e = model.edge(id); + QJsonObject o; + o.insert(QLatin1String("id"), e->id.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("from"), e->fromNode.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("fromPort"), e->fromPort); + o.insert(QLatin1String("to"), e->toNode.toString(QUuid::WithoutBraces)); + o.insert(QLatin1String("toPort"), e->toPort); + if (!e->props.isEmpty()) + o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(e->props)); + edges.append(o); + } + + QJsonObject root; + root.insert(QLatin1String("format"), QStringLiteral("pipediagram")); + root.insert(QLatin1String("version"), 1); + root.insert(QLatin1String("nodes"), nodes); + root.insert(QLatin1String("edges"), edges); + return root; +} + +bool JsonIo::fromJson(const QJsonObject& doc, NetworkModel* model, QString* error) +{ + if (doc.value(QLatin1String("format")).toString() != QLatin1String("pipediagram")) { + if (error) + *error = QStringLiteral("Not a pipediagram document"); + return false; + } + model->clear(); + + for (const auto& nv : doc.value(QLatin1String("nodes")).toArray()) { + const QJsonObject o = nv.toObject(); + const QUuid id(o.value(QLatin1String("id")).toString()); + const QJsonArray pos = o.value(QLatin1String("pos")).toArray(); + const QUuid added = model->addNode(o.value(QLatin1String("type")).toString(), + {pos.at(0).toDouble(), pos.at(1).toDouble()}, id); + if (added.isNull()) { + if (error) + *error = QStringLiteral("Unknown node type: %1") + .arg(o.value(QLatin1String("type")).toString()); + return false; + } + const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap(); + for (auto it = props.begin(); it != props.end(); ++it) + model->setNodeProperty(added, it.key(), it.value()); + } + + for (const auto& ev : doc.value(QLatin1String("edges")).toArray()) { + const QJsonObject o = ev.toObject(); + const QUuid id(o.value(QLatin1String("id")).toString()); + const QUuid added = model->addEdge(QUuid(o.value(QLatin1String("from")).toString()), + o.value(QLatin1String("fromPort")).toString(), + QUuid(o.value(QLatin1String("to")).toString()), + o.value(QLatin1String("toPort")).toString(), id); + if (added.isNull()) { + if (error) + *error = QStringLiteral("Invalid edge %1").arg(o.value(QLatin1String("id")).toString()); + return false; + } + const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap(); + for (auto it = props.begin(); it != props.end(); ++it) + model->setEdgeProperty(added, it.key(), it.value()); + } + return true; +} + +bool JsonIo::save(const NetworkModel& model, const QString& path, QString* error) +{ + QSaveFile f(path); + if (!f.open(QIODevice::WriteOnly)) { + if (error) + *error = f.errorString(); + return false; + } + f.write(QJsonDocument(toJson(model)).toJson(QJsonDocument::Indented)); + if (!f.commit()) { + if (error) + *error = f.errorString(); + return false; + } + return true; +} + +bool JsonIo::load(NetworkModel* model, const QString& path, QString* error) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + if (error) + *error = f.errorString(); + return false; + } + QJsonParseError perr; + const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &perr); + if (doc.isNull()) { + if (error) + *error = perr.errorString(); + return false; + } + return fromJson(doc.object(), model, error); +} + +} // namespace diag diff --git a/src/core/JsonIo.h b/src/core/JsonIo.h new file mode 100644 index 0000000..c40138c --- /dev/null +++ b/src/core/JsonIo.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace diag { + +class NetworkModel; + +// Document persistence: nodes, edges and their property overrides as JSON. +class JsonIo { +public: + static QJsonObject toJson(const NetworkModel& model); + static bool fromJson(const QJsonObject& doc, NetworkModel* model, QString* error = nullptr); + static bool save(const NetworkModel& model, const QString& path, QString* error = nullptr); + static bool load(NetworkModel* model, const QString& path, QString* error = nullptr); +}; + +} // namespace diag diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 08b1540..dcc0d08 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,3 +10,4 @@ diag_add_test(tst_model diagcore) diag_add_test(tst_properties diagcore) diag_add_test(tst_router diagcore) diag_add_test(tst_solver diagcore) +diag_add_test(tst_json diagcore) diff --git a/tests/tst_json.cpp b/tests/tst_json.cpp new file mode 100644 index 0000000..3528ebe --- /dev/null +++ b/tests/tst_json.cpp @@ -0,0 +1,84 @@ +#include "TestFixtures.h" +#include "core/JsonIo.h" +#include "core/NetworkModel.h" + +#include +#include + +using namespace diag; + +class TestJson : public QObject { + Q_OBJECT +private slots: + void init() + { + fixtures::fillRegistry(m_reg); + delete m_model; + m_model = new NetworkModel(&m_reg, this); + } + + void roundTrip_preservesDocument() + { + const QUuid src = m_model->addNode(QStringLiteral("source"), {20, 40}); + const QUuid pump = m_model->addNode(QStringLiteral("pump"), {220, 40}); + const QUuid c = m_model->addNode(QStringLiteral("consumer"), {420, 40}); + const QUuid e1 = m_model->addEdge(src, "out", pump, "in"); + const QUuid e2 = m_model->addEdge(pump, "out", c, "in"); + m_model->setNodeProperty(src, "title", QStringLiteral("Intake")); + m_model->setNodeProperty(src, "tags", QStringList{"a", "b"}); + m_model->setEdgeProperty(e1, "diameter", 150.0); + m_model->setEdgeProperty(e1, "headDecoration", QStringLiteral("Diamond")); + m_model->setEdgeProperty(e2, "series", QStringLiteral("dn100")); + + const QJsonObject doc = JsonIo::toJson(*m_model); + + NetworkModel restored(&m_reg); + QString err; + QVERIFY2(JsonIo::fromJson(doc, &restored, &err), qPrintable(err)); + + QCOMPARE(restored.nodeIds().size(), 3); + QCOMPARE(restored.edgeIds().size(), 2); + QCOMPARE(restored.node(src)->pos, QPointF(20, 40)); + QCOMPARE(restored.nodeProperty(src, "title").toString(), QStringLiteral("Intake")); + QCOMPARE(restored.nodeProperty(src, "tags").toStringList(), (QStringList{"a", "b"})); + QCOMPARE(restored.edgeProperty(e1, "diameter").toDouble(), 150.0); + QCOMPARE(restored.edgeProperty(e1, "headDecoration").toString(), + QStringLiteral("Diamond")); + QCOMPARE(restored.edgeProperty(e2, "series").toString(), QStringLiteral("dn100")); + QCOMPARE(restored.edge(e1)->fromNode, src); + QCOMPARE(restored.edge(e1)->toNode, pump); + QCOMPARE(restored.edge(e1)->relation, QStringLiteral("water")); + } + + void saveAndLoad_file() + { + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("doc.json")); + const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0}); + const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0}); + m_model->addEdge(src, "out", c, "in"); + + QString err; + QVERIFY2(JsonIo::save(*m_model, path, &err), qPrintable(err)); + + NetworkModel restored(&m_reg); + QVERIFY2(JsonIo::load(&restored, path, &err), qPrintable(err)); + QCOMPARE(restored.nodeIds().size(), 2); + QCOMPARE(restored.edgeIds().size(), 1); + } + + void invalidDocument_rejected() + { + NetworkModel restored(&m_reg); + QString err; + QVERIFY(!JsonIo::fromJson(QJsonObject{{"format", "other"}}, &restored, &err)); + QVERIFY(!err.isEmpty()); + } + +private: + TypeRegistry m_reg; + NetworkModel* m_model = nullptr; +}; + +QTEST_GUILESS_MAIN(TestJson) +#include "tst_json.moc"