| @@ -1,6 +1,8 @@ | |||
| #include "control_logic_model.h" | |||
| #include <algorithm> | |||
| #include <map> | |||
| #include <type_traits> | |||
| #include <utility> | |||
| namespace { | |||
| @@ -29,6 +31,21 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||
| return true; | |||
| } | |||
| bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| { | |||
| setError(error, "边沿触点必须使用有效的 M 区地址"); | |||
| return false; | |||
| } | |||
| if (config.mode != EdgeMode::Rising && config.mode != EdgeMode::Falling) | |||
| { | |||
| setError(error, "边沿触点使用了不支持的模式"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool validateConfig(const CoilNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| @@ -66,6 +83,38 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| return true; | |||
| } | |||
| bool validateConfig(const TimerContactNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid()) | |||
| { | |||
| setError(error, "定时器触点必须使用有效的 T 地址"); | |||
| return false; | |||
| } | |||
| if (config.mode != ContactMode::NormallyOpen | |||
| && config.mode != ContactMode::NormallyClosed) | |||
| { | |||
| setError(error, "定时器触点使用了不支持的模式"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool validateConfig(const TonNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid()) | |||
| { | |||
| setError(error, "TON 必须使用有效的 T 地址"); | |||
| return false; | |||
| } | |||
| if (config.presetMs < TonNodeConfig::kMinimumPresetMs | |||
| || config.presetMs > TonNodeConfig::kMaximumPresetMs) | |||
| { | |||
| setError(error, "TON 预设时间必须在 1~86400000 ms 范围内"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| template<typename TItem> | |||
| bool hasDuplicateId(const std::vector<TItem> &items) | |||
| { | |||
| @@ -134,6 +183,55 @@ void normalizeExpression(ConditionExpression *expression) | |||
| } // namespace | |||
| TimerAddress::TimerAddress(int index) | |||
| : index_(index) | |||
| { | |||
| } | |||
| int TimerAddress::index() const | |||
| { | |||
| return index_; | |||
| } | |||
| bool TimerAddress::isValid() const | |||
| { | |||
| return index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||
| } | |||
| std::string TimerAddress::toString() const | |||
| { | |||
| return isValid() ? "T" + std::to_string(index_) : "InvalidTimerAddress"; | |||
| } | |||
| bool TimerAddress::operator==(const TimerAddress &other) const | |||
| { | |||
| return index_ == other.index_; | |||
| } | |||
| bool TimerAddress::operator!=(const TimerAddress &other) const | |||
| { | |||
| return !(*this == other); | |||
| } | |||
| std::optional<RegisterAddress> registerAddressForLogicNode( | |||
| const LogicNodeConfig &config) | |||
| { | |||
| return std::visit( | |||
| [](const auto &value) -> std::optional<RegisterAddress> | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig> | |||
| || std::is_same_v<Config, EdgeContactNodeConfig> | |||
| || std::is_same_v<Config, CoilNodeConfig> | |||
| || std::is_same_v<Config, CompareNodeConfig>) | |||
| { | |||
| return value.address; | |||
| } | |||
| return std::nullopt; | |||
| }, | |||
| config); | |||
| } | |||
| bool LogicNode::validate(std::string *error) const | |||
| { | |||
| if (id.empty()) | |||
| @@ -153,12 +251,14 @@ bool LogicNode::isConfigured() const | |||
| bool LogicNode::isCondition() const | |||
| { | |||
| return !std::holds_alternative<CoilNodeConfig>(config); | |||
| return !std::holds_alternative<CoilNodeConfig>(config) | |||
| && !std::holds_alternative<TonNodeConfig>(config); | |||
| } | |||
| bool LogicNode::isOutput() const | |||
| { | |||
| return std::holds_alternative<CoilNodeConfig>(config); | |||
| return std::holds_alternative<CoilNodeConfig>(config) | |||
| || std::holds_alternative<TonNodeConfig>(config); | |||
| } | |||
| ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | |||
| @@ -343,7 +443,7 @@ bool LadderRung::validateForRunning(std::string *error) const | |||
| } | |||
| if (!condition.has_value() || !output.has_value()) | |||
| { | |||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出线圈"); | |||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出节点"); | |||
| return false; | |||
| } | |||
| if (!condition->validateForRunning(error)) | |||
| @@ -352,7 +452,7 @@ bool LadderRung::validateForRunning(std::string *error) const | |||
| } | |||
| if (!output->isConfigured()) | |||
| { | |||
| setError(error, "输出线圈 " + output->id + " 尚未配置"); | |||
| setError(error, "输出节点 " + output->id + " 尚未配置"); | |||
| return false; | |||
| } | |||
| return true; | |||
| @@ -383,7 +483,7 @@ bool LadderRung::validateStructure(std::string *error) const | |||
| { | |||
| if (!output->validate(error) || !output->isOutput()) | |||
| { | |||
| setError(error, "梯形图网络输出必须是线圈节点"); | |||
| setError(error, "梯形图网络输出必须是线圈或 TON 指令"); | |||
| return false; | |||
| } | |||
| node_ids.push_back(output->id); | |||
| @@ -479,3 +579,71 @@ bool ControlLogic::validateForRunning(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| bool validateTimerReferencesForRunning( | |||
| const std::vector<ControlLogic> &logics, | |||
| std::string *error) | |||
| { | |||
| std::map<int, std::string> timer_outputs; | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| { | |||
| continue; | |||
| } | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| if (!rung.output.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config); | |||
| if (ton == nullptr) | |||
| { | |||
| continue; | |||
| } | |||
| const auto inserted = timer_outputs.emplace( | |||
| ton->address.index(), rung.output->id); | |||
| if (!inserted.second) | |||
| { | |||
| setError( | |||
| error, | |||
| "定时器 " + ton->address.toString() | |||
| + " 只能由一个已启用 TON 指令驱动"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| { | |||
| continue; | |||
| } | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| if (!rung.condition.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| std::vector<const LogicNode *> nodes; | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| for (const LogicNode *node : nodes) | |||
| { | |||
| const auto *contact = std::get_if<TimerContactNodeConfig>( | |||
| &node->config); | |||
| if (contact != nullptr | |||
| && timer_outputs.count(contact->address.index()) == 0U) | |||
| { | |||
| setError( | |||
| error, | |||
| "定时器触点 " + contact->address.toString() | |||
| + " 没有对应的已启用 TON 指令"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| return true; | |||
| } | |||
| @@ -21,6 +21,12 @@ enum class CoilMode | |||
| Reset | |||
| }; | |||
| enum class EdgeMode | |||
| { | |||
| Rising, | |||
| Falling | |||
| }; | |||
| enum class ComparisonOperator | |||
| { | |||
| Equal, | |||
| @@ -37,6 +43,12 @@ struct ContactNodeConfig | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| }; | |||
| struct EdgeContactNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| EdgeMode mode = EdgeMode::Rising; | |||
| }; | |||
| struct CoilNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| @@ -50,7 +62,50 @@ struct CompareNodeConfig | |||
| std::int16_t value = 0; | |||
| }; | |||
| using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | |||
| class TimerAddress | |||
| { | |||
| public: | |||
| static constexpr int kMinimumIndex = 0; | |||
| static constexpr int kMaximumIndex = 4000; | |||
| explicit TimerAddress(int index = 0); | |||
| int index() const; | |||
| bool isValid() const; | |||
| std::string toString() const; | |||
| bool operator==(const TimerAddress &other) const; | |||
| bool operator!=(const TimerAddress &other) const; | |||
| private: | |||
| int index_ = 0; | |||
| }; | |||
| struct TimerContactNodeConfig | |||
| { | |||
| TimerAddress address; | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| }; | |||
| struct TonNodeConfig | |||
| { | |||
| static constexpr int kMinimumPresetMs = 1; | |||
| static constexpr int kMaximumPresetMs = 86400000; | |||
| TimerAddress address; | |||
| int presetMs = 1000; | |||
| }; | |||
| using LogicNodeConfig = std::variant< | |||
| ContactNodeConfig, | |||
| EdgeContactNodeConfig, | |||
| TimerContactNodeConfig, | |||
| CoilNodeConfig, | |||
| CompareNodeConfig, | |||
| TonNodeConfig>; | |||
| std::optional<RegisterAddress> registerAddressForLogicNode( | |||
| const LogicNodeConfig &config); | |||
| struct LogicNode | |||
| { | |||
| @@ -99,11 +154,12 @@ void collectConditionNodes( | |||
| void collectConditionExpressionIds( | |||
| const ConditionExpression &expression, std::vector<std::string> *ids); | |||
| // 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧 | |||
| // 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧 | |||
| struct LadderRung | |||
| { | |||
| std::string id; | |||
| std::string name; | |||
| std::string comment; | |||
| std::optional<ConditionExpression> condition; | |||
| std::optional<LogicNode> output; | |||
| @@ -123,3 +179,7 @@ struct ControlLogic | |||
| bool validateStructure(std::string *error = nullptr) const; | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| bool validateTimerReferencesForRunning( | |||
| const std::vector<ControlLogic> &logics, | |||
| std::string *error = nullptr); | |||
| @@ -1,6 +1,7 @@ | |||
| #include "project_model.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| namespace { | |||
| @@ -53,8 +54,43 @@ bool containsDuplicateName(const std::vector<TItem> &items) | |||
| return false; | |||
| } | |||
| bool isBlank(const std::string &value) | |||
| { | |||
| return value.empty() | |||
| || std::all_of( | |||
| value.cbegin(), value.cend(), | |||
| [](unsigned char character) { return std::isspace(character) != 0; }); | |||
| } | |||
| } // namespace | |||
| bool RegisterComment::validate(std::string *error) const | |||
| { | |||
| if (!address.isValid()) | |||
| { | |||
| setError(error, "软元件注释必须使用有效的 M/D 地址"); | |||
| return false; | |||
| } | |||
| if (isBlank(text)) | |||
| { | |||
| setError(error, "软元件注释内容不能为空"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| const RegisterComment *Project::findRegisterComment( | |||
| const RegisterAddress &address) const | |||
| { | |||
| const auto comment = std::find_if( | |||
| registerComments.cbegin(), registerComments.cend(), | |||
| [&address](const RegisterComment &candidate) | |||
| { | |||
| return candidate.address == address; | |||
| }); | |||
| return comment == registerComments.cend() ? nullptr : &*comment; | |||
| } | |||
| bool Project::validate(std::string *error) const | |||
| { | |||
| if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | |||
| @@ -113,6 +149,25 @@ bool Project::validate(std::string *error) const | |||
| return false; | |||
| } | |||
| } | |||
| for (auto current = registerComments.cbegin(); current != registerComments.cend(); ++current) | |||
| { | |||
| if (!current->validate(error)) | |||
| { | |||
| return false; | |||
| } | |||
| const auto duplicate = std::find_if( | |||
| current + 1, | |||
| registerComments.cend(), | |||
| [¤t](const RegisterComment &candidate) | |||
| { | |||
| return candidate.address == current->address; | |||
| }); | |||
| if (duplicate != registerComments.cend()) | |||
| { | |||
| setError(error, "同一 M/D 地址只能保存一条软元件注释"); | |||
| return false; | |||
| } | |||
| } | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||
| @@ -171,5 +226,9 @@ bool Project::validateForRunning(std::string *error) const | |||
| return false; | |||
| } | |||
| } | |||
| if (!validateTimerReferencesForRunning(controlLogics, error)) | |||
| { | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -7,6 +7,14 @@ | |||
| #include <string> | |||
| #include <vector> | |||
| struct RegisterComment | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| std::string text; | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 描述工程的基本身份和文件格式信息 | |||
| struct ProjectMetadata | |||
| { | |||
| @@ -29,9 +37,12 @@ struct Project | |||
| std::string initialHmiPageId; | |||
| // 工程统一管理的报警定义 | |||
| std::vector<AlarmDefinition> alarmDefinitions; | |||
| // 工程级 M/D 地址注释,不包含当前值和引用位置 | |||
| std::vector<RegisterComment> registerComments; | |||
| // 工程包含的控制逻辑集合 | |||
| std::vector<ControlLogic> controlLogics; | |||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | |||
| // 校验工程配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| @@ -253,6 +253,35 @@ bool parseAddress( | |||
| return true; | |||
| } | |||
| QJsonObject serializeTimerAddress(const TimerAddress &address) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("index"), address.index()); | |||
| return object; | |||
| } | |||
| bool parseTimerAddress( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| TimerAddress *address, | |||
| ParseState *state) | |||
| { | |||
| int index = 0; | |||
| if (!readInt( | |||
| object, | |||
| "index", | |||
| context, | |||
| TimerAddress::kMinimumIndex, | |||
| TimerAddress::kMaximumIndex, | |||
| &index, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| *address = TimerAddress{index}; | |||
| return true; | |||
| } | |||
| // 将 HMI 控件类型枚举转换为工程文件中的稳定字符串 | |||
| QString hmiControlTypeName(HmiControlType type) | |||
| { | |||
| @@ -478,6 +507,29 @@ bool parseAlarmDefinition( | |||
| return true; | |||
| } | |||
| QJsonObject serializeRegisterComment(const RegisterComment &comment) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("address"), serializeAddress(comment.address)); | |||
| object.insert(QStringLiteral("text"), fromUtf8(comment.text)); | |||
| return object; | |||
| } | |||
| bool parseRegisterComment( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| RegisterComment *comment, | |||
| ParseState *state) | |||
| { | |||
| QJsonObject address; | |||
| if (!readObject(object, "address", context, &address, state) | |||
| || !readString(object, "text", context, &comment->text, state)) | |||
| { | |||
| return false; | |||
| } | |||
| return parseAddress(address, context + ".address", &comment->address, state); | |||
| } | |||
| // 将 HMI 控件矩形区域序列化为 JSON 对象 | |||
| QJsonObject serializeBounds(const HmiRect &bounds) | |||
| { | |||
| @@ -758,6 +810,33 @@ bool parseContactMode( | |||
| return true; | |||
| } | |||
| QString edgeModeName(EdgeMode mode) | |||
| { | |||
| return mode == EdgeMode::Rising | |||
| ? QStringLiteral("rising") | |||
| : QStringLiteral("falling"); | |||
| } | |||
| bool parseEdgeMode( | |||
| const std::string &value, EdgeMode *mode, ParseState *state) | |||
| { | |||
| if (value == "rising") | |||
| { | |||
| *mode = EdgeMode::Rising; | |||
| } | |||
| else if (value == "falling") | |||
| { | |||
| *mode = EdgeMode::Falling; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "不支持的边沿模式:" + value); | |||
| } | |||
| return true; | |||
| } | |||
| // 将线圈模式枚举转换为工程文件中的稳定字符串 | |||
| QString coilModeName(CoilMode mode) | |||
| { | |||
| @@ -891,6 +970,24 @@ QJsonObject serializeNodeConfig(const ContactNodeConfig &config) | |||
| return object; | |||
| } | |||
| QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("type"), QStringLiteral("edgeContact")); | |||
| object.insert(QStringLiteral("address"), serializeAddress(config.address)); | |||
| object.insert(QStringLiteral("mode"), edgeModeName(config.mode)); | |||
| return object; | |||
| } | |||
| QJsonObject serializeNodeConfig(const TimerContactNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("type"), QStringLiteral("timerContact")); | |||
| object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address)); | |||
| object.insert(QStringLiteral("mode"), contactModeName(config.mode)); | |||
| return object; | |||
| } | |||
| // 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 | |||
| QJsonObject serializeNodeConfig(const CoilNodeConfig &config) | |||
| { | |||
| @@ -912,6 +1009,15 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config) | |||
| return object; | |||
| } | |||
| QJsonObject serializeNodeConfig(const TonNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("type"), QStringLiteral("ton")); | |||
| object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address)); | |||
| object.insert(QStringLiteral("presetMs"), config.presetMs); | |||
| return object; | |||
| } | |||
| // 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载 | |||
| QJsonObject serializeLogicNode(const LogicNode &node) | |||
| { | |||
| @@ -938,13 +1044,56 @@ bool parseNodeConfig( | |||
| ParseState *state) | |||
| { | |||
| std::string type; | |||
| QJsonObject address_object; | |||
| if (!readString(object, "type", context, &type, state) | |||
| || !readObject(object, "address", context, &address_object, state)) | |||
| if (!readString(object, "type", context, &type, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (type == "timerContact" || type == "ton") | |||
| { | |||
| QJsonObject timer_object; | |||
| if (!readObject(object, "timer", context, &timer_object, state)) | |||
| { | |||
| return false; | |||
| } | |||
| TimerAddress address; | |||
| if (!parseTimerAddress(timer_object, context + ".timer", &address, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (type == "timerContact") | |||
| { | |||
| std::string mode_text; | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| if (!readString(object, "mode", context, &mode_text, state) | |||
| || !parseContactMode(mode_text, &mode, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *config = TimerContactNodeConfig{address, mode}; | |||
| return true; | |||
| } | |||
| int preset_ms = 0; | |||
| if (!readInt( | |||
| object, | |||
| "presetMs", | |||
| context, | |||
| TonNodeConfig::kMinimumPresetMs, | |||
| TonNodeConfig::kMaximumPresetMs, | |||
| &preset_ms, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| *config = TonNodeConfig{address, preset_ms}; | |||
| return true; | |||
| } | |||
| QJsonObject address_object; | |||
| if (!readObject(object, "address", context, &address_object, state)) | |||
| { | |||
| return false; | |||
| } | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| if (!parseAddress(address_object, context + ".address", &address, state)) | |||
| { | |||
| @@ -964,6 +1113,18 @@ bool parseNodeConfig( | |||
| *config = ContactNodeConfig{address, mode}; | |||
| return true; | |||
| } | |||
| if (type == "edgeContact") | |||
| { | |||
| std::string mode_text; | |||
| EdgeMode mode = EdgeMode::Rising; | |||
| if (!readString(object, "mode", context, &mode_text, state) | |||
| || !parseEdgeMode(mode_text, &mode, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *config = EdgeContactNodeConfig{address, mode}; | |||
| return true; | |||
| } | |||
| if (type == "coil") | |||
| { | |||
| std::string mode_text; | |||
| @@ -1127,6 +1288,7 @@ QJsonObject serializeLadderRung(const LadderRung &rung) | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | |||
| object.insert(QStringLiteral("comment"), fromUtf8(rung.comment)); | |||
| object.insert( | |||
| QStringLiteral("condition"), | |||
| rung.condition.has_value() | |||
| @@ -1149,6 +1311,7 @@ bool parseLadderRung( | |||
| QJsonValue output; | |||
| if (!readString(object, "id", context, &rung->id, state) | |||
| || !readString(object, "name", context, &rung->name, state) | |||
| || !readString(object, "comment", context, &rung->comment, state) | |||
| || !readValue(object, "output", context, &output, state)) | |||
| { | |||
| return false; | |||
| @@ -1270,6 +1433,12 @@ QJsonObject serializeProject(const Project &project) | |||
| alarms.append(serializeAlarmDefinition(definition)); | |||
| } | |||
| QJsonArray register_comments; | |||
| for (const RegisterComment &comment : project.registerComments) | |||
| { | |||
| register_comments.append(serializeRegisterComment(comment)); | |||
| } | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("formatVersion"), fromUtf8(project.metadata.formatVersion)); | |||
| object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); | |||
| @@ -1278,6 +1447,7 @@ QJsonObject serializeProject(const Project &project) | |||
| object.insert(QStringLiteral("initialHmiPageId"), | |||
| fromUtf8(project.initialHmiPageId)); | |||
| object.insert(QStringLiteral("alarmDefinitions"), alarms); | |||
| object.insert(QStringLiteral("registerComments"), register_comments); | |||
| object.insert(QStringLiteral("controlLogics"), logics); | |||
| return object; | |||
| } | |||
| @@ -1288,6 +1458,7 @@ bool parseProject( | |||
| { | |||
| QJsonArray pages; | |||
| QJsonArray alarms; | |||
| QJsonArray register_comments; | |||
| QJsonArray logics; | |||
| if (!readString( | |||
| object, | |||
| @@ -1312,6 +1483,7 @@ bool parseProject( | |||
| || !readString(object, "initialHmiPageId", "project", | |||
| &project->initialHmiPageId, state) | |||
| || !readArray(object, "alarmDefinitions", "project", &alarms, state) | |||
| || !readArray(object, "registerComments", "project", ®ister_comments, state) | |||
| || !readArray(object, "controlLogics", "project", &logics, state)) | |||
| { | |||
| return false; | |||
| @@ -1360,6 +1532,27 @@ bool parseProject( | |||
| project->alarmDefinitions.push_back(std::move(definition)); | |||
| } | |||
| project->registerComments.reserve(static_cast<std::size_t>(register_comments.size())); | |||
| for (int index = 0; index < register_comments.size(); ++index) | |||
| { | |||
| if (!register_comments.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "project.registerComments 的元素必须是对象"); | |||
| } | |||
| RegisterComment comment; | |||
| if (!parseRegisterComment( | |||
| register_comments.at(index).toObject(), | |||
| "project.registerComments[" + std::to_string(index) + ']', | |||
| &comment, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| project->registerComments.push_back(std::move(comment)); | |||
| } | |||
| project->controlLogics.reserve(static_cast<std::size_t>(logics.size())); | |||
| for (int index = 0; index < logics.size(); ++index) | |||
| { | |||
| @@ -0,0 +1,102 @@ | |||
| #include "register_comment_service.h" | |||
| #include "project_service.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <utility> | |||
| namespace { | |||
| std::string trim(const std::string &value) | |||
| { | |||
| const auto first = std::find_if_not( | |||
| value.cbegin(), value.cend(), | |||
| [](unsigned char character) { return std::isspace(character) != 0; }); | |||
| const auto last = std::find_if_not( | |||
| value.crbegin(), value.crend(), | |||
| [](unsigned char character) { return std::isspace(character) != 0; }).base(); | |||
| return first >= last ? std::string{} : std::string(first, last); | |||
| } | |||
| RegisterCommentResult failure( | |||
| RegisterCommentError error, const std::string &message) | |||
| { | |||
| return {false, error, message}; | |||
| } | |||
| } // namespace | |||
| RegisterCommentService::RegisterCommentService(ProjectService &project_service) | |||
| : project_service_(project_service) | |||
| { | |||
| } | |||
| const std::vector<RegisterComment> &RegisterCommentService::comments() const | |||
| { | |||
| return project_service_.project().registerComments; | |||
| } | |||
| const RegisterComment *RegisterCommentService::findComment( | |||
| const RegisterAddress &address) const | |||
| { | |||
| return project_service_.project().findRegisterComment(address); | |||
| } | |||
| RegisterCommentResult RegisterCommentService::setComment( | |||
| const RegisterAddress &address, const std::string &text) | |||
| { | |||
| const std::string normalized = trim(text); | |||
| RegisterComment candidate{address, normalized}; | |||
| std::string error; | |||
| if (!candidate.validate(&error)) | |||
| { | |||
| return failure(RegisterCommentError::InvalidComment, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto existing = std::find_if( | |||
| project.registerComments.begin(), project.registerComments.end(), | |||
| [&address](const RegisterComment &comment) | |||
| { | |||
| return comment.address == address; | |||
| }); | |||
| if (existing == project.registerComments.end()) | |||
| { | |||
| project.registerComments.push_back(std::move(candidate)); | |||
| } | |||
| else | |||
| { | |||
| existing->text = normalized; | |||
| } | |||
| std::sort( | |||
| project.registerComments.begin(), project.registerComments.end(), | |||
| [](const RegisterComment &left, const RegisterComment &right) | |||
| { | |||
| if (left.address.area() != right.address.area()) | |||
| { | |||
| return left.address.area() == RegisterArea::M; | |||
| } | |||
| return left.address.index() < right.address.index(); | |||
| }); | |||
| return {true, RegisterCommentError::None, {}}; | |||
| } | |||
| RegisterCommentResult RegisterCommentService::removeComment( | |||
| const RegisterAddress &address) | |||
| { | |||
| if (findComment(address) == nullptr) | |||
| { | |||
| return failure(RegisterCommentError::NotFound, "未找到软元件注释"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| project.registerComments.erase( | |||
| std::remove_if( | |||
| project.registerComments.begin(), project.registerComments.end(), | |||
| [&address](const RegisterComment &comment) | |||
| { | |||
| return comment.address == address; | |||
| }), | |||
| project.registerComments.end()); | |||
| return {true, RegisterCommentError::None, {}}; | |||
| } | |||
| @@ -0,0 +1,37 @@ | |||
| #pragma once | |||
| #include "domain/project_model.h" | |||
| #include <string> | |||
| #include <vector> | |||
| class ProjectService; | |||
| enum class RegisterCommentError | |||
| { | |||
| None, | |||
| InvalidComment, | |||
| NotFound | |||
| }; | |||
| struct RegisterCommentResult | |||
| { | |||
| bool succeeded = false; | |||
| RegisterCommentError error = RegisterCommentError::None; | |||
| std::string message; | |||
| }; | |||
| class RegisterCommentService | |||
| { | |||
| public: | |||
| explicit RegisterCommentService(ProjectService &project_service); | |||
| const std::vector<RegisterComment> &comments() const; | |||
| const RegisterComment *findComment(const RegisterAddress &address) const; | |||
| RegisterCommentResult setComment( | |||
| const RegisterAddress &address, const std::string &text); | |||
| RegisterCommentResult removeComment(const RegisterAddress &address); | |||
| private: | |||
| ProjectService &project_service_; | |||
| }; | |||
| @@ -169,7 +169,7 @@ void testMultiPageAndLogicDomainRules() | |||
| disabled_draft.name = "Draft logic"; | |||
| disabled_draft.enabled = false; | |||
| disabled_draft.rungs.push_back( | |||
| {"rung-1", "Draft network", std::nullopt, std::nullopt}); | |||
| {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); | |||
| project.controlLogics.push_back(disabled_draft); | |||
| require(project.validateForRunning(), | |||
| "a disabled draft logic must not block offline running"); | |||
| @@ -208,6 +208,108 @@ void testLogicNodeConfigurationBoundaries() | |||
| require(comparison.validate(), "comparison node bound to D address must be valid"); | |||
| } | |||
| void testTimerAndCommentBoundaries() | |||
| { | |||
| LogicNode edge; | |||
| edge.id = "edge"; | |||
| edge.config = EdgeContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising}; | |||
| require(edge.validate(), "a valid rising edge contact must pass validation"); | |||
| LogicNode timer_contact; | |||
| timer_contact.id = "timer-contact"; | |||
| timer_contact.config = TimerContactNodeConfig{ | |||
| TimerAddress{4000}, ContactMode::NormallyOpen}; | |||
| require(timer_contact.validate(), "T4000 must be a valid timer contact"); | |||
| timer_contact.config = TimerContactNodeConfig{ | |||
| TimerAddress{-1}, ContactMode::NormallyOpen}; | |||
| require(!timer_contact.validate(), "a negative T address must be rejected"); | |||
| LogicNode ton; | |||
| ton.id = "ton"; | |||
| ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs}; | |||
| require(ton.validate(), "the minimum TON preset must be valid"); | |||
| ton.config = TonNodeConfig{TimerAddress{0}, 0}; | |||
| require(!ton.validate(), "a zero TON preset must be rejected"); | |||
| ton.config = TonNodeConfig{ | |||
| TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1}; | |||
| require(!ton.validate(), "an oversized TON preset must be rejected"); | |||
| RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"}; | |||
| require(comment.validate(), "a nonblank register comment must be valid"); | |||
| comment.text = " \t"; | |||
| require(!comment.validate(), "a blank register comment must be rejected"); | |||
| Project project = makeValidProject(); | |||
| project.registerComments = { | |||
| {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, | |||
| {RegisterAddress{RegisterArea::M, 0}, "重复地址"}}; | |||
| require(!project.validate(), "duplicate register comments must be rejected"); | |||
| } | |||
| LadderRung makeTimerRung( | |||
| const std::string &rung_id, | |||
| const std::string &condition_id, | |||
| const std::string &output_id, | |||
| int timer_index, | |||
| int preset_ms) | |||
| { | |||
| LogicNode condition; | |||
| condition.id = condition_id; | |||
| condition.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen}; | |||
| LogicNode output; | |||
| output.id = output_id; | |||
| output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms}; | |||
| LadderRung rung; | |||
| rung.id = rung_id; | |||
| rung.name = rung_id; | |||
| rung.condition = ConditionExpression::fromNode(condition); | |||
| rung.output = output; | |||
| return rung; | |||
| } | |||
| void testTimerReferencesForRunning() | |||
| { | |||
| ControlLogic valid; | |||
| valid.id = "timer-valid"; | |||
| valid.name = "Timer valid"; | |||
| valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100)); | |||
| require(validateTimerReferencesForRunning({valid}), | |||
| "a timer contact-free TON network must pass timer reference validation"); | |||
| ControlLogic duplicate = valid; | |||
| duplicate.id = "timer-duplicate"; | |||
| duplicate.name = "Timer duplicate"; | |||
| duplicate.rungs.front().output->id = "ton-duplicate"; | |||
| require(!validateTimerReferencesForRunning({valid, duplicate}), | |||
| "the same T must not have two enabled TON drivers"); | |||
| ControlLogic missing; | |||
| missing.id = "timer-missing"; | |||
| missing.name = "Timer missing"; | |||
| LogicNode contact; | |||
| contact.id = "missing-contact"; | |||
| contact.config = TimerContactNodeConfig{ | |||
| TimerAddress{7}, ContactMode::NormallyOpen}; | |||
| LogicNode output; | |||
| output.id = "missing-coil"; | |||
| output.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal}; | |||
| LadderRung missing_rung; | |||
| missing_rung.id = "missing-rung"; | |||
| missing_rung.name = "Missing rung"; | |||
| missing_rung.condition = ConditionExpression::fromNode(contact); | |||
| missing_rung.output = output; | |||
| missing.rungs.push_back(missing_rung); | |||
| require(!validateTimerReferencesForRunning({missing}), | |||
| "a T contact without an enabled TON driver must be rejected"); | |||
| missing.enabled = false; | |||
| require(validateTimerReferencesForRunning({missing}), | |||
| "disabled timer drafts must not block runtime timer validation"); | |||
| } | |||
| void testLadderLogicBoundaries() | |||
| { | |||
| LogicNode stop; | |||
| @@ -269,7 +371,8 @@ void testLadderLogicBoundaries() | |||
| require(!logic.validateForRunning(), | |||
| "conditions without an output must block runtime validation"); | |||
| LadderRung empty_rung{"rung-empty", "Empty network", std::nullopt, std::nullopt}; | |||
| LadderRung empty_rung{ | |||
| "rung-empty", "Empty network", {}, std::nullopt, std::nullopt}; | |||
| require(empty_rung.validate(), "an empty editing network must be valid"); | |||
| empty_rung.output = coil; | |||
| @@ -365,6 +468,8 @@ int main() | |||
| testRegisterAddressParsing(); | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testTimerAndCommentBoundaries(); | |||
| testTimerReferencesForRunning(); | |||
| testLadderLogicBoundaries(); | |||
| testModelsValidateBindingsAndIdentifiers(); | |||
| testMultiPageAndLogicDomainRules(); | |||
| @@ -1,5 +1,6 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "services/register_comment_service.h" | |||
| #include "services/project_service.h" | |||
| #include <QFile> | |||
| @@ -125,6 +126,7 @@ Project makeExampleProject() | |||
| LadderRung rung; | |||
| rung.id = "rung-1"; | |||
| rung.name = "Network 1"; | |||
| rung.comment = "启动条件与温度检查"; | |||
| ConditionExpression parallel; | |||
| parallel.id = "parallel-start"; | |||
| parallel.kind = ConditionExpressionKind::Parallel; | |||
| @@ -141,6 +143,47 @@ Project makeExampleProject() | |||
| rung.output = coil; | |||
| logic.rungs.push_back(rung); | |||
| LogicNode rising_edge; | |||
| rising_edge.id = "rising-edge"; | |||
| rising_edge.config = EdgeContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising}; | |||
| LogicNode ton_output; | |||
| ton_output.id = "ton-output"; | |||
| ton_output.config = TonNodeConfig{TimerAddress{7}, 2500}; | |||
| LadderRung ton_rung; | |||
| ton_rung.id = "ton-rung"; | |||
| ton_rung.name = "TON network"; | |||
| ton_rung.comment = "延时启动"; | |||
| ton_rung.condition = ConditionExpression::fromNode(rising_edge); | |||
| ton_rung.output = ton_output; | |||
| logic.rungs.push_back(ton_rung); | |||
| LogicNode falling_edge; | |||
| falling_edge.id = "falling-edge"; | |||
| falling_edge.config = EdgeContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 5}, EdgeMode::Falling}; | |||
| LogicNode timer_contact; | |||
| timer_contact.id = "timer-contact"; | |||
| timer_contact.config = TimerContactNodeConfig{ | |||
| TimerAddress{7}, ContactMode::NormallyOpen}; | |||
| LogicNode timer_coil; | |||
| timer_coil.id = "timer-coil"; | |||
| timer_coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal}; | |||
| LadderRung timer_rung; | |||
| timer_rung.id = "timer-rung"; | |||
| timer_rung.name = "Timer feedback"; | |||
| timer_rung.comment = "定时器完成反馈"; | |||
| ConditionExpression timer_series; | |||
| timer_series.id = "timer-series"; | |||
| timer_series.kind = ConditionExpressionKind::Series; | |||
| timer_series.children = { | |||
| ConditionExpression::fromNode(falling_edge), | |||
| ConditionExpression::fromNode(timer_contact)}; | |||
| timer_rung.condition = std::move(timer_series); | |||
| timer_rung.output = timer_coil; | |||
| logic.rungs.push_back(timer_rung); | |||
| Project project; | |||
| project.metadata = {"example-project", "Example project", "1.0"}; | |||
| project.hmiPages.push_back(page); | |||
| @@ -158,13 +201,16 @@ Project makeExampleProject() | |||
| AlarmCondition::DHigh, | |||
| 80, | |||
| "Temperature high"}); | |||
| project.registerComments = { | |||
| {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, | |||
| {RegisterAddress{RegisterArea::D, 2}, "当前温度"}}; | |||
| project.controlLogics.push_back(logic); | |||
| ControlLogic draft_logic; | |||
| draft_logic.id = "draft-logic"; | |||
| draft_logic.name = "Draft logic"; | |||
| draft_logic.enabled = false; | |||
| draft_logic.rungs.push_back( | |||
| {"rung-1", "Draft network", std::nullopt, std::nullopt}); | |||
| {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); | |||
| project.controlLogics.push_back(draft_logic); | |||
| return project; | |||
| } | |||
| @@ -227,6 +273,10 @@ void testExampleProjectRoundTrip() | |||
| const QString missing_initial_path = directory.filePath("missing-initial.json"); | |||
| const QString missing_target_path = directory.filePath("missing-target.json"); | |||
| const QString missing_alarms_path = directory.filePath("missing-alarms.json"); | |||
| const QString missing_register_comments_path = directory.filePath( | |||
| "missing-register-comments.json"); | |||
| const QString missing_rung_comment_path = directory.filePath( | |||
| "missing-rung-comment.json"); | |||
| require(service.saveAs(first_path.toStdString()).succeeded, | |||
| "example project save must succeed"); | |||
| const QByteArray saved_json = readBytes(first_path); | |||
| @@ -242,6 +292,12 @@ void testExampleProjectRoundTrip() | |||
| && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | |||
| && saved_json.contains("\"targetPageId\": \"settings-page\"") | |||
| && saved_json.contains("\"alarmDefinitions\"") | |||
| && saved_json.contains("\"registerComments\"") | |||
| && saved_json.contains("\"text\": \"启动按钮\"") | |||
| && saved_json.contains("\"type\": \"edgeContact\"") | |||
| && saved_json.contains("\"type\": \"timerContact\"") | |||
| && saved_json.contains("\"type\": \"ton\"") | |||
| && saved_json.contains("\"comment\": \"启动条件与温度检查\"") | |||
| && saved_json.contains("\"type\": \"alarmList\""), | |||
| "version 1.0 projects must persist pages and alarm definitions"); | |||
| require(!saved_json.contains("\"stages\"") | |||
| @@ -286,11 +342,18 @@ void testExampleProjectRoundTrip() | |||
| == AlarmCondition::MOn | |||
| && project.alarmDefinitions.at(1).threshold == 80, | |||
| "M and D alarm definitions must survive round trip"); | |||
| require(project.registerComments.size() == 2 | |||
| && project.registerComments.front().address.area() == RegisterArea::M | |||
| && project.registerComments.front().text == "启动按钮" | |||
| && project.registerComments.at(1).address.area() == RegisterArea::D, | |||
| "M/D register comments must survive round trip"); | |||
| require(project.controlLogics.size() == 2, | |||
| "control logic count must survive round trip"); | |||
| require(!project.controlLogics.front().enabled, | |||
| "control logic enabled state must survive round trip"); | |||
| const LadderRung &rung = project.controlLogics.front().rungs.front(); | |||
| require(rung.comment == "启动条件与温度检查", | |||
| "rung comments must survive round trip"); | |||
| require(rung.condition.has_value() | |||
| && rung.condition->kind == ConditionExpressionKind::Series, | |||
| "series root expression must survive round trip"); | |||
| @@ -301,6 +364,32 @@ void testExampleProjectRoundTrip() | |||
| require(rung.output.has_value(), | |||
| "ladder output must survive round trip"); | |||
| const LadderRung &ton_rung = project.controlLogics.front().rungs.at(1); | |||
| require(ton_rung.output.has_value() | |||
| && std::holds_alternative<TonNodeConfig>(ton_rung.output->config) | |||
| && std::get<TonNodeConfig>(ton_rung.output->config).address | |||
| == TimerAddress{7} | |||
| && std::get<TonNodeConfig>(ton_rung.output->config).presetMs == 2500, | |||
| "TON timer address and preset must survive round trip"); | |||
| require(std::holds_alternative<EdgeContactNodeConfig>( | |||
| ton_rung.condition->node->config) | |||
| && std::get<EdgeContactNodeConfig>(ton_rung.condition->node->config).mode | |||
| == EdgeMode::Rising, | |||
| "rising edge configuration must survive round trip"); | |||
| const LadderRung &timer_rung = project.controlLogics.front().rungs.at(2); | |||
| require(timer_rung.comment == "定时器完成反馈" | |||
| && std::holds_alternative<TimerContactNodeConfig>( | |||
| timer_rung.condition->children.at(1).node->config) | |||
| && std::get<TimerContactNodeConfig>( | |||
| timer_rung.condition->children.at(1).node->config).address | |||
| == TimerAddress{7} | |||
| && std::holds_alternative<EdgeContactNodeConfig>( | |||
| timer_rung.condition->children.front().node->config) | |||
| && std::get<EdgeContactNodeConfig>( | |||
| timer_rung.condition->children.front().node->config).mode | |||
| == EdgeMode::Falling, | |||
| "falling edge and T contact configurations must survive round trip"); | |||
| const auto &compare = std::get<CompareNodeConfig>( | |||
| rung.condition->children.at(1).node->config); | |||
| require(compare.address.index() == 2 && compare.value == 100, | |||
| @@ -339,6 +428,35 @@ void testExampleProjectRoundTrip() | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "the 1.0 schema must require alarmDefinitions without migration defaults"); | |||
| QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object(); | |||
| missing_register_comments.remove(QStringLiteral("registerComments")); | |||
| writeText( | |||
| missing_register_comments_path, | |||
| QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact)); | |||
| missing_result = service.load(missing_register_comments_path.toStdString()); | |||
| require(!missing_result.succeeded | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "the 1.0 schema must require registerComments without migration defaults"); | |||
| QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object(); | |||
| QJsonArray missing_comment_logics = missing_rung_comment.value( | |||
| QStringLiteral("controlLogics")).toArray(); | |||
| QJsonObject first_logic = missing_comment_logics.at(0).toObject(); | |||
| QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray(); | |||
| QJsonObject first_rung = first_rungs.at(0).toObject(); | |||
| first_rung.remove(QStringLiteral("comment")); | |||
| first_rungs.replace(0, first_rung); | |||
| first_logic.insert(QStringLiteral("rungs"), first_rungs); | |||
| missing_comment_logics.replace(0, first_logic); | |||
| missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics); | |||
| writeText( | |||
| missing_rung_comment_path, | |||
| QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact)); | |||
| missing_result = service.load(missing_rung_comment_path.toStdString()); | |||
| require(!missing_result.succeeded | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "the 1.0 schema must require rung comments without migration defaults"); | |||
| QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); | |||
| QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | |||
| QJsonObject main_page = pages.at(0).toObject(); | |||
| @@ -422,6 +540,38 @@ void testServiceStateAndSaveErrors() | |||
| "failed save as must keep the previous current path"); | |||
| } | |||
| void testRegisterCommentService() | |||
| { | |||
| JsonProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| RegisterCommentService service(project_service); | |||
| require(service.setComment( | |||
| RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded, | |||
| "register comment service must create a trimmed D comment"); | |||
| require(service.setComment( | |||
| RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded, | |||
| "register comment service must create an M comment"); | |||
| require(service.comments().size() == 2U | |||
| && service.comments().front().address.area() == RegisterArea::M | |||
| && service.comments().front().address.index() == 2 | |||
| && service.comments().back().text == "目标温度", | |||
| "register comments must be sorted by area and address"); | |||
| require(service.setComment( | |||
| RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded | |||
| && service.comments().size() == 2U | |||
| && service.findComment(RegisterAddress{RegisterArea::M, 2})->text | |||
| == "新的启动信号", | |||
| "setting an existing address must update instead of duplicating it"); | |||
| require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded, | |||
| "blank register comments must be rejected by the service"); | |||
| require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded | |||
| && service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr, | |||
| "register comments must be removable"); | |||
| require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded, | |||
| "removing a missing register comment must fail explicitly"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -431,6 +581,7 @@ int main() | |||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | |||
| testEmptyProjectRoundTrip(); | |||
| testExampleProjectRoundTrip(); | |||
| testRegisterCommentService(); | |||
| testInvalidFiles(); | |||
| testServiceStateAndSaveErrors(); | |||
| } | |||
| @@ -18,6 +18,7 @@ SOURCES += \ | |||
| ../src/domain/project_model.cpp \ | |||
| ../src/domain/runtime_state.cpp \ | |||
| ../src/services/project_service.cpp \ | |||
| ../src/services/register_comment_service.cpp \ | |||
| ../src/infrastructure/json_project_storage.cpp | |||
| HEADERS += \ | |||
| @@ -30,4 +31,5 @@ HEADERS += \ | |||
| ../src/domain/runtime_state.h \ | |||
| ../src/domain/project_storage.h \ | |||
| ../src/services/project_service.h \ | |||
| ../src/services/register_comment_service.h \ | |||
| ../src/infrastructure/json_project_storage.h | |||