From 01a7488cbe5004c838620028c2c5b95039ddbe7a Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Sat, 8 Aug 2026 13:35:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8C=96=20JSON?= =?UTF-8?q?=20=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrated_platform.pro | 9 +- app/src/domain/project_storage.h | 63 + app/src/infrastructure/.gitkeep | 1 - .../infrastructure/json_project_storage.cpp | 1134 +++++++++++++++++ app/src/infrastructure/json_project_storage.h | 16 + app/src/services/.gitkeep | 1 - app/src/services/project_service.cpp | 181 +++ app/src/services/project_service.h | 80 ++ app/tests/project_management_tests.cpp | 237 ++++ app/tests/project_management_tests.pro | 31 + docs/ai/handoff.md | 20 +- docs/architecture.md | 5 + docs/开发顺序.md | 1 + 13 files changed, 1771 insertions(+), 8 deletions(-) create mode 100644 app/src/domain/project_storage.h delete mode 100644 app/src/infrastructure/.gitkeep create mode 100644 app/src/infrastructure/json_project_storage.cpp create mode 100644 app/src/infrastructure/json_project_storage.h delete mode 100644 app/src/services/.gitkeep create mode 100644 app/src/services/project_service.cpp create mode 100644 app/src/services/project_service.h create mode 100644 app/tests/project_management_tests.cpp create mode 100644 app/tests/project_management_tests.pro diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 44b6c21..5d8f3a8 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -22,7 +22,9 @@ SOURCES += \ src/domain/hmi_model.cpp \ src/domain/control_logic_model.cpp \ src/domain/project_model.cpp \ - src/domain/runtime_state.cpp + src/domain/runtime_state.cpp \ + src/services/project_service.cpp \ + src/infrastructure/json_project_storage.cpp HEADERS += \ src/ui/main_window.h \ @@ -31,7 +33,10 @@ HEADERS += \ src/domain/hmi_model.h \ src/domain/control_logic_model.h \ src/domain/project_model.h \ - src/domain/runtime_state.h + src/domain/runtime_state.h \ + src/domain/project_storage.h \ + src/services/project_service.h \ + src/infrastructure/json_project_storage.h FORMS += \ src/ui/main_window.ui diff --git a/app/src/domain/project_storage.h b/app/src/domain/project_storage.h new file mode 100644 index 0000000..8fbbfb1 --- /dev/null +++ b/app/src/domain/project_storage.h @@ -0,0 +1,63 @@ +#pragma once + +#include "project_model.h" + +#include + +namespace integrated_platform::domain { + +// 工程文件存储失败原因 +enum class ProjectStorageError +{ + None, // 无错误 + FileOpenFailed, // 文件打开失败 + FileReadFailed, // 文件读取失败 + FileWriteFailed, // 文件写入失败 + InvalidJson, // JSON 格式无效 + MissingField, // 缺少必要字段 + InvalidField, // 字段值无效 + UnsupportedVersion, // 工程版本不受支持 + InvalidProject // 工程内容无效 +}; + +// 工程保存结果 +struct ProjectSaveResult +{ + bool succeeded = false; // 保存是否成功 + ProjectStorageError error = ProjectStorageError::None; // 保存失败原因 + std::string message; // 结果描述信息 +}; + +// 工程加载结果 +struct ProjectLoadResult +{ + bool succeeded = false; // 加载是否成功 + Project project; // 加载得到的工程 + ProjectStorageError error = ProjectStorageError::None; // 加载失败原因 + std::string message; // 结果描述信息 +}; + +// 工程文件存储边界 +class ProjectStorage +{ +public: + virtual ~ProjectStorage() = default; + + /** + * @brief 将工程保存到指定文件 + * @param project 需要保存的有效工程 + * @param file_path UTF-8 编码的目标文件路径 + * @return 工程保存结果 + */ + virtual ProjectSaveResult save( + const Project &project, const std::string &file_path) = 0; + + /** + * @brief 从指定文件加载工程 + * @param file_path UTF-8 编码的工程文件路径 + * @return 工程加载结果 + */ + virtual ProjectLoadResult load(const std::string &file_path) = 0; +}; + +} // namespace integrated_platform::domain diff --git a/app/src/infrastructure/.gitkeep b/app/src/infrastructure/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/app/src/infrastructure/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp new file mode 100644 index 0000000..ab334ca --- /dev/null +++ b/app/src/infrastructure/json_project_storage.cpp @@ -0,0 +1,1134 @@ +#include "json_project_storage.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace integrated_platform::infrastructure { + +namespace { + +// 当前读写实现支持的工程文件格式版本 +constexpr const char *kCurrentFormatVersion = "1.0"; + +// 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 +struct ParseState +{ + domain::ProjectStorageError error = domain::ProjectStorageError::None; + std::string message; + + // 记录首次解析错误并返回 false,便于解析函数直接向上传播失败 + bool fail(domain::ProjectStorageError new_error, const std::string &new_message) + { + if (error == domain::ProjectStorageError::None) + { + error = new_error; + message = new_message; + } + return false; + } +}; + +// 将领域层使用的 UTF-8 字符串转换为 Qt 字符串 +QString fromUtf8(const std::string &value) +{ + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +// 将 Qt 字符串转换为领域层使用的 UTF-8 字符串 +std::string toUtf8(const QString &value) +{ + const QByteArray bytes = value.toUtf8(); + return std::string(bytes.constData(), static_cast(bytes.size())); +} + +// 拼接带上下文的字段路径,用于生成可定位的错误信息 +std::string fieldPath(const std::string &context, const char *field) +{ + return context + '.' + field; +} + +// 读取必填 JSON 字段,字段不存在时记录 MissingField 错误 +bool readValue( + const QJsonObject &object, + const char *field, + const std::string &context, + QJsonValue *value, + ParseState *state) +{ + const QString key = QString::fromLatin1(field); + if (!object.contains(key)) + { + return state->fail( + domain::ProjectStorageError::MissingField, + "missing required field " + fieldPath(context, field)); + } + *value = object.value(key); + return true; +} + +// 读取必填字符串字段并转换为 UTF-8 标准字符串 +bool readString( + const QJsonObject &object, + const char *field, + const std::string &context, + std::string *value, + ParseState *state) +{ + QJsonValue json_value; + if (!readValue(object, field, context, &json_value, state)) + { + return false; + } + if (!json_value.isString()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + fieldPath(context, field) + " must be a string"); + } + *value = toUtf8(json_value.toString()); + return true; +} + +// 读取必填布尔字段并校验 JSON 类型 +bool readBool( + const QJsonObject &object, + const char *field, + const std::string &context, + bool *value, + ParseState *state) +{ + QJsonValue json_value; + if (!readValue(object, field, context, &json_value, state)) + { + return false; + } + if (!json_value.isBool()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + fieldPath(context, field) + " must be a boolean"); + } + *value = json_value.toBool(); + return true; +} + +// 读取指定闭区间内的整数,拒绝小数、非有限值和越界值 +bool readInt( + const QJsonObject &object, + const char *field, + const std::string &context, + int minimum, + int maximum, + int *value, + ParseState *state) +{ + QJsonValue json_value; + if (!readValue(object, field, context, &json_value, state)) + { + return false; + } + if (!json_value.isDouble()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + fieldPath(context, field) + " must be an integer"); + } + + // Qt JSON 统一用 double 表示数值,需要额外确认它能无损转换为 int + const double number = json_value.toDouble(); + if (!std::isfinite(number) || std::floor(number) != number + || number < minimum || number > maximum) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + fieldPath(context, field) + " is outside the supported integer range"); + } + *value = static_cast(number); + return true; +} + +// 读取必填对象字段并校验 JSON 类型 +bool readObject( + const QJsonObject &object, + const char *field, + const std::string &context, + QJsonObject *value, + ParseState *state) +{ + QJsonValue json_value; + if (!readValue(object, field, context, &json_value, state)) + { + return false; + } + if (!json_value.isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + fieldPath(context, field) + " must be an object"); + } + *value = json_value.toObject(); + return true; +} + +// 读取必填数组字段并校验 JSON 类型 +bool readArray( + const QJsonObject &object, + const char *field, + const std::string &context, + QJsonArray *value, + ParseState *state) +{ + QJsonValue json_value; + if (!readValue(object, field, context, &json_value, state)) + { + return false; + } + if (!json_value.isArray()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + fieldPath(context, field) + " must be an array"); + } + *value = json_value.toArray(); + return true; +} + +// 将寄存器地址序列化为包含区域和原始索引的 JSON 对象 +QJsonObject serializeAddress(const domain::RegisterAddress &address) +{ + QJsonObject object; + object.insert(QStringLiteral("area"), + address.area() == domain::RegisterArea::M ? QStringLiteral("M") + : QStringLiteral("D")); + object.insert(QStringLiteral("index"), address.index()); + return object; +} + +// 解析寄存器地址并校验区域名称及项目允许的索引范围 +bool parseAddress( + const QJsonObject &object, + const std::string &context, + domain::RegisterAddress *address, + ParseState *state) +{ + std::string area_text; + int index = 0; + if (!readString(object, "area", context, &area_text, state) + || !readInt( + object, + "index", + context, + domain::RegisterAddress::kMinimumIndex, + domain::RegisterAddress::kMaximumIndex, + &index, + state)) + { + return false; + } + + domain::RegisterArea area = domain::RegisterArea::M; + if (area_text == "M") + { + area = domain::RegisterArea::M; + } + else if (area_text == "D") + { + area = domain::RegisterArea::D; + } + else + { + return state->fail( + domain::ProjectStorageError::InvalidField, + context + ".area must be M or D"); + } + + *address = domain::RegisterAddress{area, index}; + return true; +} + +// 将 HMI 控件类型枚举转换为工程文件中的稳定字符串 +QString hmiControlTypeName(domain::HmiControlType type) +{ + switch (type) + { + case domain::HmiControlType::Button: + { + return QStringLiteral("button"); + } + case domain::HmiControlType::Indicator: + { + return QStringLiteral("indicator"); + } + case domain::HmiControlType::NumericDisplay: + { + return QStringLiteral("numericDisplay"); + } + case domain::HmiControlType::NumericInput: + { + return QStringLiteral("numericInput"); + } + case domain::HmiControlType::Label: + { + return QStringLiteral("label"); + } + default: + { + return {}; + } + } +} + +// 将工程文件中的控件类型字符串转换为 HMI 控件类型枚举 +bool parseHmiControlType( + const std::string &value, domain::HmiControlType *type, ParseState *state) +{ + if (value == "button") + { + *type = domain::HmiControlType::Button; + } + else if (value == "indicator") + { + *type = domain::HmiControlType::Indicator; + } + else if (value == "numericDisplay") + { + *type = domain::HmiControlType::NumericDisplay; + } + else if (value == "numericInput") + { + *type = domain::HmiControlType::NumericInput; + } + else if (value == "label") + { + *type = domain::HmiControlType::Label; + } + else + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "unsupported HMI control type " + value); + } + return true; +} + +// 将 HMI 控件矩形区域序列化为 JSON 对象 +QJsonObject serializeBounds(const domain::HmiRect &bounds) +{ + QJsonObject object; + object.insert(QStringLiteral("x"), bounds.x); + object.insert(QStringLiteral("y"), bounds.y); + object.insert(QStringLiteral("width"), bounds.width); + object.insert(QStringLiteral("height"), bounds.height); + return object; +} + +// 解析控件矩形区域,允许任意坐标但要求宽高为正数 +bool parseBounds( + const QJsonObject &object, + const std::string &context, + domain::HmiRect *bounds, + ParseState *state) +{ + return readInt( + object, + "x", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &bounds->x, + state) + && readInt( + object, + "y", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &bounds->y, + state) + && readInt( + object, + "width", + context, + 1, + std::numeric_limits::max(), + &bounds->width, + state) + && readInt( + object, + "height", + context, + 1, + std::numeric_limits::max(), + &bounds->height, + state); +} + +// 将 HMI 控件的字符串扩展属性序列化为 JSON 对象 +QJsonObject serializeProperties(const std::map &properties) +{ + QJsonObject object; + for (const auto &property : properties) + { + object.insert(fromUtf8(property.first), fromUtf8(property.second)); + } + return object; +} + +// 解析 HMI 控件扩展属性,并确保所有属性值都是字符串 +bool parseProperties( + const QJsonObject &object, + std::map *properties, + ParseState *state) +{ + for (auto current = object.constBegin(); current != object.constEnd(); ++current) + { + if (!current.value().isString()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "HMI control property values must be strings"); + } + properties->emplace(toUtf8(current.key()), toUtf8(current.value().toString())); + } + return true; +} + +// 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象 +QJsonObject serializeHmiControl(const domain::HmiControl &control) +{ + QJsonObject object; + object.insert(QStringLiteral("id"), fromUtf8(control.id)); + object.insert(QStringLiteral("type"), hmiControlTypeName(control.type)); + object.insert(QStringLiteral("bounds"), serializeBounds(control.bounds)); + object.insert(QStringLiteral("text"), fromUtf8(control.text)); + // 使用 JSON null 明确表示控件没有寄存器绑定 + if (control.binding.has_value()) + { + object.insert(QStringLiteral("binding"), serializeAddress(*control.binding)); + } + else + { + object.insert(QStringLiteral("binding"), QJsonValue::Null); + } + object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); + return object; +} + +// 解析单个 HMI 控件,并逐层校验类型、区域、绑定和扩展属性 +bool parseHmiControl( + const QJsonObject &object, + const std::string &context, + domain::HmiControl *control, + ParseState *state) +{ + std::string type_text; + QJsonObject bounds; + QJsonObject properties; + QJsonValue binding; + if (!readString(object, "id", context, &control->id, state) + || !readString(object, "type", context, &type_text, state) + || !readObject(object, "bounds", context, &bounds, state) + || !readString(object, "text", context, &control->text, state) + || !readValue(object, "binding", context, &binding, state) + || !readObject(object, "properties", context, &properties, state)) + { + return false; + } + if (!parseHmiControlType(type_text, &control->type, state) + || !parseBounds(bounds, context + ".bounds", &control->bounds, state) + || !parseProperties(properties, &control->properties, state)) + { + return false; + } + + // binding 允许为 null,其余非空值必须是合法的寄存器地址对象 + if (binding.isNull()) + { + control->binding.reset(); + return true; + } + if (!binding.isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + context + ".binding must be an object or null"); + } + + domain::RegisterAddress address{domain::RegisterArea::M, 0}; + if (!parseAddress(binding.toObject(), context + ".binding", &address, state)) + { + return false; + } + control->binding = address; + return true; +} + +// 将 HMI 页面及其全部控件序列化为 JSON 对象 +QJsonObject serializeHmiPage(const domain::HmiPage &page) +{ + QJsonArray controls; + for (const domain::HmiControl &control : page.controls) + { + controls.append(serializeHmiControl(control)); + } + + QJsonObject object; + object.insert(QStringLiteral("id"), fromUtf8(page.id)); + object.insert(QStringLiteral("name"), fromUtf8(page.name)); + object.insert(QStringLiteral("width"), page.width); + object.insert(QStringLiteral("height"), page.height); + object.insert(QStringLiteral("controls"), controls); + return object; +} + +// 解析 HMI 页面基础信息,并按数组顺序构造页面控件 +bool parseHmiPage( + const QJsonObject &object, + const std::string &context, + domain::HmiPage *page, + ParseState *state) +{ + QJsonArray controls; + if (!readString(object, "id", context, &page->id, state) + || !readString(object, "name", context, &page->name, state) + || !readInt(object, "width", context, 1, std::numeric_limits::max(), + &page->width, state) + || !readInt(object, "height", context, 1, std::numeric_limits::max(), + &page->height, state) + || !readArray(object, "controls", context, &controls, state)) + { + return false; + } + + // 预留准确容量,避免逐项加入控件时重复扩容 + page->controls.reserve(static_cast(controls.size())); + for (int index = 0; index < controls.size(); ++index) + { + if (!controls.at(index).isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + context + ".controls items must be objects"); + } + domain::HmiControl control; + if (!parseHmiControl( + controls.at(index).toObject(), + context + ".controls[" + std::to_string(index) + ']', + &control, + state)) + { + return false; + } + page->controls.push_back(std::move(control)); + } + return true; +} + +// 将触点模式枚举转换为工程文件中的稳定字符串 +QString contactModeName(domain::ContactMode mode) +{ + return mode == domain::ContactMode::NormallyOpen + ? QStringLiteral("normallyOpen") + : QStringLiteral("normallyClosed"); +} + +// 将工程文件中的触点模式字符串转换为枚举 +bool parseContactMode( + const std::string &value, domain::ContactMode *mode, ParseState *state) +{ + if (value == "normallyOpen") + { + *mode = domain::ContactMode::NormallyOpen; + } + else if (value == "normallyClosed") + { + *mode = domain::ContactMode::NormallyClosed; + } + else + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "unsupported contact mode " + value); + } + return true; +} + +// 将线圈模式枚举转换为工程文件中的稳定字符串 +QString coilModeName(domain::CoilMode mode) +{ + switch (mode) + { + case domain::CoilMode::Normal: + { + return QStringLiteral("normal"); + } + case domain::CoilMode::Set: + { + return QStringLiteral("set"); + } + case domain::CoilMode::Reset: + { + return QStringLiteral("reset"); + } + default: + { + return {}; + } + } +} + +// 将工程文件中的线圈模式字符串转换为枚举 +bool parseCoilMode(const std::string &value, domain::CoilMode *mode, ParseState *state) +{ + if (value == "normal") + { + *mode = domain::CoilMode::Normal; + } + else if (value == "set") + { + *mode = domain::CoilMode::Set; + } + else if (value == "reset") + { + *mode = domain::CoilMode::Reset; + } + else + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "unsupported coil mode " + value); + } + return true; +} + +// 将比较运算符枚举转换为工程文件中的稳定字符串 +QString comparisonName(domain::ComparisonOperator comparison) +{ + switch (comparison) + { + case domain::ComparisonOperator::Equal: + { + return QStringLiteral("equal"); + } + case domain::ComparisonOperator::NotEqual: + { + return QStringLiteral("notEqual"); + } + case domain::ComparisonOperator::LessThan: + { + return QStringLiteral("lessThan"); + } + case domain::ComparisonOperator::LessThanOrEqual: + { + return QStringLiteral("lessThanOrEqual"); + } + case domain::ComparisonOperator::GreaterThan: + { + return QStringLiteral("greaterThan"); + } + case domain::ComparisonOperator::GreaterThanOrEqual: + { + return QStringLiteral("greaterThanOrEqual"); + } + default: + { + return {}; + } + } +} + +// 将工程文件中的比较运算符字符串转换为枚举 +bool parseComparison( + const std::string &value, + domain::ComparisonOperator *comparison, + ParseState *state) +{ + if (value == "equal") + { + *comparison = domain::ComparisonOperator::Equal; + } + else if (value == "notEqual") + { + *comparison = domain::ComparisonOperator::NotEqual; + } + else if (value == "lessThan") + { + *comparison = domain::ComparisonOperator::LessThan; + } + else if (value == "lessThanOrEqual") + { + *comparison = domain::ComparisonOperator::LessThanOrEqual; + } + else if (value == "greaterThan") + { + *comparison = domain::ComparisonOperator::GreaterThan; + } + else if (value == "greaterThanOrEqual") + { + *comparison = domain::ComparisonOperator::GreaterThanOrEqual; + } + else + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "unsupported comparison operator " + value); + } + return true; +} + +// 将触点节点配置序列化,并写入用于反序列化分派的类型标记 +QJsonObject serializeNodeConfig(const domain::ContactNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("contact")); + object.insert(QStringLiteral("address"), serializeAddress(config.address)); + object.insert(QStringLiteral("mode"), contactModeName(config.mode)); + return object; +} + +// 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 +QJsonObject serializeNodeConfig(const domain::CoilNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("coil")); + object.insert(QStringLiteral("address"), serializeAddress(config.address)); + object.insert(QStringLiteral("mode"), coilModeName(config.mode)); + return object; +} + +// 将数值比较节点配置序列化,并保留有符号 16 位比较常量 +QJsonObject serializeNodeConfig(const domain::CompareNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("compare")); + object.insert(QStringLiteral("address"), serializeAddress(config.address)); + object.insert(QStringLiteral("comparison"), comparisonName(config.comparison)); + object.insert(QStringLiteral("value"), config.value); + return object; +} + +// 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载 +QJsonObject serializeLogicNode(const domain::LogicNode &node) +{ + QJsonObject object; + object.insert(QStringLiteral("id"), fromUtf8(node.id)); + // std::visit 将不同节点配置统一转换为 config JSON 对象 + object.insert( + QStringLiteral("config"), + std::visit( + [](const auto &config) + { + return serializeNodeConfig(config); + }, + node.config)); + return object; +} + +// 根据 type 字段解析具体节点配置,并写入 LogicNodeConfig 变体 +bool parseNodeConfig( + const QJsonObject &object, + const std::string &context, + domain::LogicNodeConfig *config, + ParseState *state) +{ + std::string type; + QJsonObject address_object; + if (!readString(object, "type", context, &type, state) + || !readObject(object, "address", context, &address_object, state)) + { + return false; + } + + domain::RegisterAddress address{domain::RegisterArea::M, 0}; + if (!parseAddress(address_object, context + ".address", &address, state)) + { + return false; + } + + // 每种节点只读取自身需要的字段,避免无关配置进入领域模型 + if (type == "contact") + { + std::string mode_text; + domain::ContactMode mode = domain::ContactMode::NormallyOpen; + if (!readString(object, "mode", context, &mode_text, state) + || !parseContactMode(mode_text, &mode, state)) + { + return false; + } + *config = domain::ContactNodeConfig{address, mode}; + return true; + } + if (type == "coil") + { + std::string mode_text; + domain::CoilMode mode = domain::CoilMode::Normal; + if (!readString(object, "mode", context, &mode_text, state) + || !parseCoilMode(mode_text, &mode, state)) + { + return false; + } + *config = domain::CoilNodeConfig{address, mode}; + return true; + } + if (type == "compare") + { + std::string comparison_text; + int value = 0; + domain::ComparisonOperator comparison = domain::ComparisonOperator::Equal; + if (!readString(object, "comparison", context, &comparison_text, state) + || !readInt( + object, + "value", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &value, + state) + || !parseComparison(comparison_text, &comparison, state)) + { + return false; + } + // 先按 int 校验范围,再安全收窄为领域模型要求的 int16_t + *config = domain::CompareNodeConfig{ + address, comparison, static_cast(value)}; + return true; + } + + return state->fail( + domain::ProjectStorageError::InvalidField, + "unsupported logic node type " + type); +} + +// 解析逻辑节点标识及其多态配置 +bool parseLogicNode( + const QJsonObject &object, + const std::string &context, + domain::LogicNode *node, + ParseState *state) +{ + QJsonObject config; + return readString(object, "id", context, &node->id, state) + && readObject(object, "config", context, &config, state) + && parseNodeConfig(config, context + ".config", &node->config, state); +} + +// 将逻辑节点之间的有向连接序列化为 JSON 对象 +QJsonObject serializeConnection(const domain::LogicConnection &connection) +{ + QJsonObject object; + object.insert(QStringLiteral("fromNodeId"), fromUtf8(connection.fromNodeId)); + object.insert(QStringLiteral("toNodeId"), fromUtf8(connection.toNodeId)); + return object; +} + +// 解析逻辑连接的起点和终点节点标识 +bool parseConnection( + const QJsonObject &object, + const std::string &context, + domain::LogicConnection *connection, + ParseState *state) +{ + return readString(object, "fromNodeId", context, &connection->fromNodeId, state) + && readString(object, "toNodeId", context, &connection->toNodeId, state); +} + +// 将一套控制逻辑及其节点和连接序列化为 JSON 对象 +QJsonObject serializeControlLogic(const domain::ControlLogic &logic) +{ + QJsonArray nodes; + for (const domain::LogicNode &node : logic.nodes) + { + nodes.append(serializeLogicNode(node)); + } + + QJsonArray connections; + for (const domain::LogicConnection &connection : logic.connections) + { + connections.append(serializeConnection(connection)); + } + + QJsonObject object; + object.insert(QStringLiteral("id"), fromUtf8(logic.id)); + object.insert(QStringLiteral("name"), fromUtf8(logic.name)); + object.insert(QStringLiteral("enabled"), logic.enabled); + object.insert(QStringLiteral("nodes"), nodes); + object.insert(QStringLiteral("connections"), connections); + return object; +} + +// 解析一套控制逻辑,并逐项构造节点和连接集合 +bool parseControlLogic( + const QJsonObject &object, + const std::string &context, + domain::ControlLogic *logic, + ParseState *state) +{ + QJsonArray nodes; + QJsonArray connections; + if (!readString(object, "id", context, &logic->id, state) + || !readString(object, "name", context, &logic->name, state) + || !readBool(object, "enabled", context, &logic->enabled, state) + || !readArray(object, "nodes", context, &nodes, state) + || !readArray(object, "connections", context, &connections, state)) + { + return false; + } + + // 数组元素必须是对象,错误上下文包含下标以便定位损坏数据 + logic->nodes.reserve(static_cast(nodes.size())); + for (int index = 0; index < nodes.size(); ++index) + { + if (!nodes.at(index).isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + context + ".nodes items must be objects"); + } + domain::LogicNode node; + if (!parseLogicNode( + nodes.at(index).toObject(), + context + ".nodes[" + std::to_string(index) + ']', + &node, + state)) + { + return false; + } + logic->nodes.push_back(std::move(node)); + } + + logic->connections.reserve(static_cast(connections.size())); + for (int index = 0; index < connections.size(); ++index) + { + if (!connections.at(index).isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + context + ".connections items must be objects"); + } + domain::LogicConnection connection; + if (!parseConnection( + connections.at(index).toObject(), + context + ".connections[" + std::to_string(index) + ']', + &connection, + state)) + { + return false; + } + logic->connections.push_back(std::move(connection)); + } + return true; +} + +// 将完整领域工程聚合为工程文件的顶层 JSON 对象 +QJsonObject serializeProject(const domain::Project &project) +{ + QJsonArray pages; + for (const domain::HmiPage &page : project.hmiPages) + { + pages.append(serializeHmiPage(page)); + } + + QJsonArray logics; + for (const domain::ControlLogic &logic : project.controlLogics) + { + logics.append(serializeControlLogic(logic)); + } + + QJsonObject object; + object.insert(QStringLiteral("formatVersion"), fromUtf8(project.metadata.formatVersion)); + object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); + object.insert(QStringLiteral("name"), fromUtf8(project.metadata.name)); + object.insert(QStringLiteral("hmiPages"), pages); + object.insert(QStringLiteral("controlLogics"), logics); + return object; +} + +// 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 +bool parseProject( + const QJsonObject &object, domain::Project *project, ParseState *state) +{ + QJsonArray pages; + QJsonArray logics; + if (!readString( + object, + "formatVersion", + "project", + &project->metadata.formatVersion, + state)) + { + return false; + } + // 不尝试猜测其他版本的结构,避免按当前格式错误解释数据 + if (project->metadata.formatVersion != kCurrentFormatVersion) + { + return state->fail( + domain::ProjectStorageError::UnsupportedVersion, + "unsupported project format version " + project->metadata.formatVersion); + } + + if (!readString(object, "id", "project", &project->metadata.id, state) + || !readString(object, "name", "project", &project->metadata.name, state) + || !readArray(object, "hmiPages", "project", &pages, state) + || !readArray(object, "controlLogics", "project", &logics, state)) + { + return false; + } + + // 逐层解析时持续传递字段路径,任何失败都保留最初的具体位置 + project->hmiPages.reserve(static_cast(pages.size())); + for (int index = 0; index < pages.size(); ++index) + { + if (!pages.at(index).isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "project.hmiPages items must be objects"); + } + domain::HmiPage page; + if (!parseHmiPage( + pages.at(index).toObject(), + "project.hmiPages[" + std::to_string(index) + ']', + &page, + state)) + { + return false; + } + project->hmiPages.push_back(std::move(page)); + } + + project->controlLogics.reserve(static_cast(logics.size())); + for (int index = 0; index < logics.size(); ++index) + { + if (!logics.at(index).isObject()) + { + return state->fail( + domain::ProjectStorageError::InvalidField, + "project.controlLogics items must be objects"); + } + domain::ControlLogic logic; + if (!parseControlLogic( + logics.at(index).toObject(), + "project.controlLogics[" + std::to_string(index) + ']', + &logic, + state)) + { + return false; + } + project->controlLogics.push_back(std::move(logic)); + } + return true; +} + +// 将 Qt 文件错误转换为领域层统一的工程保存失败结果 +domain::ProjectSaveResult saveFailure( + domain::ProjectStorageError error, const QString &message) +{ + return {false, error, toUtf8(message)}; +} + +} // namespace + +// 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 +domain::ProjectSaveResult JsonProjectStorage::save( + const domain::Project &project, const std::string &file_path) +{ + // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 + std::string validation_error; + if (!project.validate(&validation_error)) + { + return {false, domain::ProjectStorageError::InvalidProject, validation_error}; + } + if (project.metadata.formatVersion != kCurrentFormatVersion) + { + return {false, + domain::ProjectStorageError::UnsupportedVersion, + "unsupported project format version " + project.metadata.formatVersion}; + } + + // QSaveFile 先写临时文件,仅在 commit 成功后替换目标文件 + QSaveFile file(fromUtf8(file_path)); + if (!file.open(QIODevice::WriteOnly)) + { + return saveFailure(domain::ProjectStorageError::FileOpenFailed, file.errorString()); + } + + const QByteArray data = QJsonDocument(serializeProject(project)).toJson( + QJsonDocument::Indented); + // 短写入也视为失败,并取消临时文件提交 + if (file.write(data) != data.size()) + { + file.cancelWriting(); + return saveFailure(domain::ProjectStorageError::FileWriteFailed, file.errorString()); + } + if (!file.commit()) + { + return saveFailure(domain::ProjectStorageError::FileWriteFailed, file.errorString()); + } + return {true, domain::ProjectStorageError::None, {}}; +} + +// 读取并解析工程文件,全部校验通过后才返回新的领域工程 +domain::ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) +{ + QFile file(fromUtf8(file_path)); + if (!file.open(QIODevice::ReadOnly)) + { + return {false, + {}, + domain::ProjectStorageError::FileOpenFailed, + toUtf8(file.errorString())}; + } + + const QByteArray data = file.readAll(); + if (file.error() != QFileDevice::NoError) + { + return {false, + {}, + domain::ProjectStorageError::FileReadFailed, + toUtf8(file.errorString())}; + } + + // 顶层必须是 JSON 对象,数组或标量不能表示完整工程 + QJsonParseError parse_error; + const QJsonDocument document = QJsonDocument::fromJson(data, &parse_error); + if (parse_error.error != QJsonParseError::NoError || !document.isObject()) + { + const QString message = parse_error.error == QJsonParseError::NoError + ? QStringLiteral("project JSON root must be an object") + : parse_error.errorString(); + return {false, + {}, + domain::ProjectStorageError::InvalidJson, + toUtf8(message)}; + } + + // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 + domain::Project project; + ParseState state; + if (!parseProject(document.object(), &project, &state)) + { + return {false, {}, state.error, state.message}; + } + + // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 + std::string validation_error; + if (!project.validate(&validation_error)) + { + return {false, + {}, + domain::ProjectStorageError::InvalidProject, + validation_error}; + } + return {true, std::move(project), domain::ProjectStorageError::None, {}}; +} + +} // namespace integrated_platform::infrastructure diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h new file mode 100644 index 0000000..b824b15 --- /dev/null +++ b/app/src/infrastructure/json_project_storage.h @@ -0,0 +1,16 @@ +#pragma once + +#include "domain/project_storage.h" + +namespace integrated_platform::infrastructure { + +// 使用版本化 JSON 文件保存和加载工程 +class JsonProjectStorage final : public domain::ProjectStorage +{ +public: + domain::ProjectSaveResult save( + const domain::Project &project, const std::string &file_path) override; + domain::ProjectLoadResult load(const std::string &file_path) override; +}; + +} // namespace integrated_platform::infrastructure diff --git a/app/src/services/.gitkeep b/app/src/services/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/app/src/services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp new file mode 100644 index 0000000..aca9149 --- /dev/null +++ b/app/src/services/project_service.cpp @@ -0,0 +1,181 @@ +#include "project_service.h" + +#include +#include +#include +#include +#include +#include + +namespace integrated_platform::services { + +namespace { + +// 使用高精度时间戳和随机值生成工程标识 +std::string generateProjectId() +{ + const auto timestamp = static_cast( + std::chrono::high_resolution_clock::now().time_since_epoch().count()); + std::random_device random_device; + const auto random_value = static_cast(random_device()); + + std::ostringstream stream; + stream << "project-" << std::hex << timestamp << '-' << random_value; + return stream.str(); +} + +} // namespace + +ProjectService::ProjectService(domain::ProjectStorage &storage) + : storage_(storage), + project_(makeNewProject("Untitled")) +{ +} + +const domain::Project &ProjectService::project() const +{ + return project_; +} + +domain::Project &ProjectService::editProject() +{ + modified_ = true; + return project_; +} + +const std::string &ProjectService::currentFilePath() const +{ + return current_file_path_; +} + +bool ProjectService::hasCurrentFile() const +{ + return !current_file_path_.empty(); +} + +bool ProjectService::isModified() const +{ + return modified_; +} + +ProjectOperationResult ProjectService::createNewProject(const std::string &name) +{ + if (isBlank(name)) + { + return {false, + ProjectServiceError::InvalidProjectName, + domain::ProjectStorageError::None, + "project name must not be empty"}; + } + + // 新工程创建成功后清除原文件关联,并标记为待保存 + project_ = makeNewProject(name); + current_file_path_.clear(); + modified_ = true; + return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; +} + +ProjectOperationResult ProjectService::save() +{ + if (!hasCurrentFile()) + { + return {false, + ProjectServiceError::FilePathRequired, + domain::ProjectStorageError::None, + "project file path is required"}; + } + return saveAs(current_file_path_); +} + +ProjectOperationResult ProjectService::saveAs(const std::string &file_path) +{ + if (isBlank(file_path)) + { + return {false, + ProjectServiceError::FilePathRequired, + domain::ProjectStorageError::None, + "project file path is required"}; + } + + std::string validation_error; + if (!project_.validate(&validation_error)) + { + return {false, + ProjectServiceError::InvalidProject, + domain::ProjectStorageError::InvalidProject, + validation_error}; + } + + // 先完成领域校验,避免将非法工程交给存储层 + const domain::ProjectSaveResult result = storage_.save(project_, file_path); + if (!result.succeeded) + { + return storageFailure(result.error, result.message); + } + + // 只有存储成功后才更新文件关联和未保存状态 + current_file_path_ = file_path; + modified_ = false; + return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; +} + +ProjectOperationResult ProjectService::load(const std::string &file_path) +{ + if (isBlank(file_path)) + { + return {false, + ProjectServiceError::FilePathRequired, + domain::ProjectStorageError::None, + "project file path is required"}; + } + + domain::ProjectLoadResult result = storage_.load(file_path); + if (!result.succeeded) + { + return storageFailure(result.error, result.message); + } + + std::string validation_error; + if (!result.project.validate(&validation_error)) + { + return {false, + ProjectServiceError::InvalidProject, + domain::ProjectStorageError::InvalidProject, + validation_error}; + } + + // 文件读取和领域校验均成功后,才替换当前工程状态 + project_ = std::move(result.project); + current_file_path_ = file_path; + modified_ = false; + return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; +} + +domain::Project ProjectService::makeNewProject(const std::string &name) +{ + domain::Project project; + project.metadata.id = generateProjectId(); + project.metadata.name = name; + project.metadata.formatVersion = "1.0"; + return project; +} + +bool ProjectService::isBlank(const std::string &value) +{ + return value.empty() + || std::all_of( + value.cbegin(), + value.cend(), + [](unsigned char character) + { + return std::isspace(character) != 0; + }); +} + +ProjectOperationResult ProjectService::storageFailure( + domain::ProjectStorageError error, const std::string &message) +{ + return {false, ProjectServiceError::StorageFailure, error, message}; +} + +} // namespace integrated_platform::services diff --git a/app/src/services/project_service.h b/app/src/services/project_service.h new file mode 100644 index 0000000..cfb124b --- /dev/null +++ b/app/src/services/project_service.h @@ -0,0 +1,80 @@ +#pragma once + +#include "domain/project_storage.h" + +#include + +namespace integrated_platform::services { + +// 工程管理操作失败原因 +enum class ProjectServiceError +{ + None, // 无错误 + InvalidProjectName, // 工程名称为空或仅包含空白字符 + FilePathRequired, // 未提供工程文件路径 + InvalidProject, // 当前工程不符合领域规则 + StorageFailure // 工程文件存储操作失败 +}; + +// 工程管理操作结果 +struct ProjectOperationResult +{ + bool succeeded = false; // 操作是否成功 + ProjectServiceError error = ProjectServiceError::None; // 服务层错误原因 + domain::ProjectStorageError storageError = domain::ProjectStorageError::None; // 存储层错误原因 + std::string message; // 面向调用方的结果描述 +}; + +// 编排当前工程的新建、保存和加载流程 +class ProjectService +{ +public: + // 使用外部提供的工程存储实现创建服务 + explicit ProjectService(domain::ProjectStorage &storage); + + const domain::Project &project() const; + domain::Project &editProject(); + const std::string ¤tFilePath() const; + bool hasCurrentFile() const; + bool isModified() const; + + /** + * @brief 创建未保存的新工程 + * @param name 工程名称,不得为空或仅包含空白字符 + * @return 新建操作结果 + */ + ProjectOperationResult createNewProject(const std::string &name); + + /** + * @brief 保存到当前工程文件 + * @return 保存操作结果,当前工程没有文件路径时返回失败 + */ + ProjectOperationResult save(); + + /** + * @brief 将当前工程保存到指定文件 + * @param file_path UTF-8 编码的目标文件路径,不得为空或仅包含空白字符 + * @return 保存操作结果 + */ + ProjectOperationResult saveAs(const std::string &file_path); + + /** + * @brief 从指定文件加载工程 + * @param file_path UTF-8 编码的工程文件路径,不得为空或仅包含空白字符 + * @return 加载操作结果,失败时不替换当前工程 + */ + ProjectOperationResult load(const std::string &file_path); + +private: + static domain::Project makeNewProject(const std::string &name); + static bool isBlank(const std::string &value); + static ProjectOperationResult storageFailure( + domain::ProjectStorageError error, const std::string &message); + + domain::ProjectStorage &storage_; // 非拥有的工程存储依赖 + domain::Project project_; // 当前编辑中的工程 + std::string current_file_path_; // 当前工程对应的文件路径 + bool modified_ = true; // 当前工程是否有未保存修改 +}; + +} // namespace integrated_platform::services diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp new file mode 100644 index 0000000..4fa8e92 --- /dev/null +++ b/app/tests/project_management_tests.cpp @@ -0,0 +1,237 @@ +#include "domain/project_storage.h" +#include "infrastructure/json_project_storage.h" +#include "services/project_service.h" + +#include +#include + +#include +#include +#include +#include + +namespace domain = integrated_platform::domain; +namespace infrastructure = integrated_platform::infrastructure; +namespace services = integrated_platform::services; + +namespace { + +void require(bool condition, const std::string &message) +{ + if (!condition) + { + throw std::runtime_error(message); + } +} + +domain::Project makeExampleProject() +{ + domain::HmiControl start_button; + start_button.id = "start-button"; + start_button.type = domain::HmiControlType::Button; + start_button.bounds = {10, 20, 120, 48}; + start_button.text = "Start"; + start_button.binding = domain::RegisterAddress{domain::RegisterArea::M, 0}; + start_button.properties.emplace("color", "green"); + + domain::HmiPage page; + page.id = "main-page"; + page.name = "Main"; + page.controls.push_back(start_button); + + domain::LogicNode contact; + contact.id = "start-contact"; + contact.config = domain::ContactNodeConfig{ + domain::RegisterAddress{domain::RegisterArea::M, 0}, + domain::ContactMode::NormallyOpen}; + + domain::LogicNode compare; + compare.id = "temperature-check"; + compare.config = domain::CompareNodeConfig{ + domain::RegisterAddress{domain::RegisterArea::D, 2}, + domain::ComparisonOperator::GreaterThanOrEqual, + static_cast(100)}; + + domain::LogicNode coil; + coil.id = "run-coil"; + coil.config = domain::CoilNodeConfig{ + domain::RegisterAddress{domain::RegisterArea::M, 1}, + domain::CoilMode::Set}; + + domain::ControlLogic logic; + logic.id = "start-logic"; + logic.name = "Start logic"; + logic.enabled = false; + logic.nodes = {contact, compare, coil}; + logic.connections.push_back({contact.id, compare.id}); + logic.connections.push_back({compare.id, coil.id}); + + domain::Project project; + project.metadata = {"example-project", "Example project", "1.0"}; + project.hmiPages.push_back(page); + project.controlLogics.push_back(logic); + return project; +} + +void writeText(const QString &path, const QByteArray &content) +{ + QFile file(path); + require(file.open(QIODevice::WriteOnly), "test file must be writable"); + require(file.write(content) == content.size(), "test file must be written completely"); +} + +QByteArray readBytes(const QString &path) +{ + QFile file(path); + require(file.open(QIODevice::ReadOnly), "saved project must be readable"); + return file.readAll(); +} + +void testEmptyProjectRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + infrastructure::JsonProjectStorage storage; + services::ProjectService service(storage); + require(service.createNewProject("Empty project").succeeded, + "empty project creation must succeed"); + + const QString path = directory.filePath("empty.json"); + require(service.saveAs(path.toStdString()).succeeded, + "empty project save must succeed"); + require(!service.isModified(), "saved project must not be marked modified"); + + require(service.load(path.toStdString()).succeeded, + "empty project load must succeed"); + require(service.project().metadata.name == "Empty project", + "empty project name must survive round trip"); + require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); + require(service.project().controlLogics.empty(), + "empty project must have no control logics"); +} + +void testExampleProjectRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + infrastructure::JsonProjectStorage storage; + services::ProjectService service(storage); + service.editProject() = makeExampleProject(); + + const QString first_path = directory.filePath("example.json"); + const QString second_path = directory.filePath("example-copy.json"); + require(service.saveAs(first_path.toStdString()).succeeded, + "example project save must succeed"); + require(service.load(first_path.toStdString()).succeeded, + "example project load must succeed"); + + const domain::Project &project = service.project(); + require(project.metadata.id == "example-project", "project id must survive round trip"); + require(project.hmiPages.size() == 1, "HMI page count must survive round trip"); + require(project.hmiPages.front().controls.front().binding->area() + == domain::RegisterArea::M, + "HMI M binding must survive round trip"); + require(project.hmiPages.front().controls.front().properties.at("color") == "green", + "HMI properties must survive round trip"); + require(project.controlLogics.size() == 1, + "control logic count must survive round trip"); + require(!project.controlLogics.front().enabled, + "control logic enabled state must survive round trip"); + require(project.controlLogics.front().nodes.size() == 3, + "logic node count must survive round trip"); + + const auto &compare = std::get( + project.controlLogics.front().nodes.at(1).config); + require(compare.address.index() == 2 && compare.value == 100, + "comparison configuration must survive round trip"); + + require(service.saveAs(second_path.toStdString()).succeeded, + "save as must succeed after load"); + require(readBytes(first_path) == readBytes(second_path), + "save and save as must produce stable JSON"); +} + +void testInvalidFiles() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + infrastructure::JsonProjectStorage storage; + services::ProjectService service(storage); + service.editProject().metadata.name = "Current project"; + const QString invalid_json = directory.filePath("invalid-json.json"); + const QString missing_field = directory.filePath("missing-field.json"); + const QString unsupported_version = directory.filePath("unsupported-version.json"); + + writeText(invalid_json, "{"); + auto result = service.load(invalid_json.toStdString()); + require(!result.succeeded + && result.storageError == domain::ProjectStorageError::InvalidJson, + "invalid JSON must be rejected"); + require(service.project().metadata.name == "Current project", + "invalid load must keep current project"); + + writeText(missing_field, R"({"formatVersion":"1.0"})"); + result = service.load(missing_field.toStdString()); + require(!result.succeeded + && result.storageError == domain::ProjectStorageError::MissingField, + "missing fields must be rejected"); + + writeText(unsupported_version, R"({"formatVersion":"2.0"})"); + result = service.load(unsupported_version.toStdString()); + require(!result.succeeded + && result.storageError == domain::ProjectStorageError::UnsupportedVersion, + "unsupported versions must be rejected"); +} + +void testServiceStateAndSaveErrors() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + infrastructure::JsonProjectStorage storage; + services::ProjectService service(storage); + require(service.save().error == services::ProjectServiceError::FilePathRequired, + "save without a current path must be rejected"); + require(service.createNewProject(" ").error + == services::ProjectServiceError::InvalidProjectName, + "blank project names must be rejected"); + + const QString path = directory.filePath("state.json"); + service.editProject().metadata.name = "State project"; + require(service.saveAs(path.toStdString()).succeeded, + "state project save must succeed"); + service.editProject().metadata.name = "Changed project"; + require(service.isModified(), "editing the project must mark it modified"); + require(service.save().succeeded, "save must use the current file path"); + require(!service.isModified(), "successful save must clear modified state"); + + const QString failed_path = directory.filePath("missing/subdir/state.json"); + require(!service.saveAs(failed_path.toStdString()).succeeded, + "save to an unavailable path must fail"); + require(service.currentFilePath() == path.toStdString(), + "failed save as must keep the previous current path"); +} + +} // namespace + +int main() +{ + try + { + testEmptyProjectRoundTrip(); + testExampleProjectRoundTrip(); + testInvalidFiles(); + testServiceStateAndSaveErrors(); + } + catch (const std::exception &error) + { + std::cerr << "project management tests failed: " << error.what() << '\n'; + return 1; + } + + std::cout << "project management tests passed\n"; + return 0; +} diff --git a/app/tests/project_management_tests.pro b/app/tests/project_management_tests.pro new file mode 100644 index 0000000..dccd1c9 --- /dev/null +++ b/app/tests/project_management_tests.pro @@ -0,0 +1,31 @@ +TEMPLATE = app +TARGET = project_management_tests + +CONFIG += console c++17 testcase warn_on +CONFIG -= app_bundle + +QT += core + +INCLUDEPATH += ../src + +SOURCES += \ + project_management_tests.cpp \ + ../src/domain/register_address.cpp \ + ../src/domain/register_repository.cpp \ + ../src/domain/hmi_model.cpp \ + ../src/domain/control_logic_model.cpp \ + ../src/domain/project_model.cpp \ + ../src/domain/runtime_state.cpp \ + ../src/services/project_service.cpp \ + ../src/infrastructure/json_project_storage.cpp + +HEADERS += \ + ../src/domain/register_address.h \ + ../src/domain/register_repository.h \ + ../src/domain/hmi_model.h \ + ../src/domain/control_logic_model.h \ + ../src/domain/project_model.h \ + ../src/domain/runtime_state.h \ + ../src/domain/project_storage.h \ + ../src/services/project_service.h \ + ../src/infrastructure/json_project_storage.h diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md index 8fa1ce2..98097d7 100644 --- a/docs/ai/handoff.md +++ b/docs/ai/handoff.md @@ -1,8 +1,8 @@ # Current Handoff -- Goal: 完成综合平台编程器的核心数据模型与接口。 +- Goal: 完成综合平台编程器的工程管理和后续编辑功能。 - Branch: `main`。 -- Current status: 已完成开发顺序第 3 步,核心领域模型、寄存器仓库接口和运行模式边界已定义。 +- Current status: 已完成开发顺序第 4 步,工程管理服务、版本化 JSON 存储和核心往返测试已完成。 - Changed files: - `app/integrated_platform.pro` - `app/src/main.cpp` @@ -29,8 +29,15 @@ - `app/src/domain/project_model.cpp` - `app/src/domain/runtime_state.h` - `app/src/domain/runtime_state.cpp` + - `app/src/domain/project_storage.h` + - `app/src/services/project_service.h` + - `app/src/services/project_service.cpp` + - `app/src/infrastructure/json_project_storage.h` + - `app/src/infrastructure/json_project_storage.cpp` - `app/tests/domain_tests.pro` - `app/tests/domain_tests.cpp` + - `app/tests/project_management_tests.pro` + - `app/tests/project_management_tests.cpp` - Decisions made: - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 @@ -40,6 +47,9 @@ - 领域模型仅使用 C++17 标准库,不依赖 Qt UI、串口或 Modbus。 - M 地址只承载布尔值,D 地址只承载带符号 16 位值,地址范围统一为 `0~4000`。 - `RegisterRepository` 统一虚拟寄存器和真实 PLC 缓存的访问契约。 + - `ProjectStorage` 是领域层的工程文件存储契约,具体 JSON 和 Qt 文件 API 位于基础设施层。 + - `ProjectService` 负责工程路径、修改状态和新建保存加载流程;加载成功后才替换当前工程。 + - 工程文件格式版本当前为 `1.0`,保存使用 `QSaveFile` 原子提交。 - 控制逻辑节点使用 `std::variant` 组合触点、线圈和数值比较的独立配置类型。 - 当前控制逻辑范围不包含定时器;不得因为设备手册或旧开发计划提前加入未确认功能。 - 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。 @@ -54,6 +64,8 @@ - 在 `build/core-model/` 重新执行 qmake 和 `mingw32-make -j2`,应用构建成功。 - 在 `build/domain-tests/` 构建并运行领域测试,输出 `domain tests passed`。 - 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。 -- Remaining work: 进入开发顺序第 4 步,实现工程新建、保存、另存为和加载。 + - 在 `build/project-management-tests/` 构建并运行工程管理测试,输出 `project management tests passed`。 + - 工程管理加入应用后重新执行 Qt 应用构建,构建成功。 +- Remaining work: 进入开发顺序第 5 步,完成主界面和状态管理。 - Known risks / blockers: 无。 -- Suggested next command: 基于 `Project` 领域模型设计版本化的工程文件 DTO 与存储接口。 +- Suggested next command: 在主窗口注入 `ProjectService`,接入新建、打开、保存和另存为操作。 diff --git a/docs/architecture.md b/docs/architecture.md index 844daeb..88cc402 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,11 +56,16 @@ main.cpp -> UI + Services + Infrastructure | --- | --- | | `register_address.*` | 定义 M/D 地址值对象,统一校验项目地址范围 `0~4000`。 | | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | +| `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 | | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定。 | | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | +工程管理由 `services/ProjectService` 编排新建、保存、另存为和加载。JSON 文件转换和原子写入 +由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功 +后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。 + `RegisterRepository` 是 HMI、逻辑执行器和运行服务唯一可见的寄存器边界。离线运行使用 `VirtualRegisterRepository`;真机运行后续注入由基础设施层实现的 PLC 缓存仓库。仓库读取的是 最近一次有效值,写入成功只表示请求被仓库接受;真实 PLC 的异步确认由服务层负责反馈。 diff --git a/docs/开发顺序.md b/docs/开发顺序.md index 34d26df..8ee18d8 100644 --- a/docs/开发顺序.md +++ b/docs/开发顺序.md @@ -30,6 +30,7 @@ - 实现新建、保存、另存为和加载工程。 - 保存 HMI 配置、控制逻辑、M/D 绑定和必要的工程版本信息。 +- 使用服务层编排工程操作,基础设施层负责版本化 JSON 转换和原子文件写入。 - 对无效文件、缺失字段和不兼容版本给出明确错误。 完成标准:空工程和示例工程可以保存后重新加载,数据保持一致。