| @@ -1,6 +1,8 @@ | |||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <map> | |||||
| #include <type_traits> | |||||
| #include <utility> | #include <utility> | ||||
| namespace { | namespace { | ||||
| @@ -29,6 +31,21 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||||
| return true; | 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) | bool validateConfig(const CoilNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | if (!config.address.isValid() || config.address.area() != RegisterArea::M) | ||||
| @@ -66,6 +83,38 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||||
| return true; | 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> | template<typename TItem> | ||||
| bool hasDuplicateId(const std::vector<TItem> &items) | bool hasDuplicateId(const std::vector<TItem> &items) | ||||
| { | { | ||||
| @@ -134,6 +183,55 @@ void normalizeExpression(ConditionExpression *expression) | |||||
| } // namespace | } // 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 | bool LogicNode::validate(std::string *error) const | ||||
| { | { | ||||
| if (id.empty()) | if (id.empty()) | ||||
| @@ -153,12 +251,14 @@ bool LogicNode::isConfigured() const | |||||
| bool LogicNode::isCondition() 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 | 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) | ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | ||||
| @@ -343,7 +443,7 @@ bool LadderRung::validateForRunning(std::string *error) const | |||||
| } | } | ||||
| if (!condition.has_value() || !output.has_value()) | if (!condition.has_value() || !output.has_value()) | ||||
| { | { | ||||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出线圈"); | |||||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出节点"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (!condition->validateForRunning(error)) | if (!condition->validateForRunning(error)) | ||||
| @@ -352,7 +452,7 @@ bool LadderRung::validateForRunning(std::string *error) const | |||||
| } | } | ||||
| if (!output->isConfigured()) | if (!output->isConfigured()) | ||||
| { | { | ||||
| setError(error, "输出线圈 " + output->id + " 尚未配置"); | |||||
| setError(error, "输出节点 " + output->id + " 尚未配置"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -383,7 +483,7 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| { | { | ||||
| if (!output->validate(error) || !output->isOutput()) | if (!output->validate(error) || !output->isOutput()) | ||||
| { | { | ||||
| setError(error, "梯形图网络输出必须是线圈节点"); | |||||
| setError(error, "梯形图网络输出必须是线圈或 TON 指令"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| node_ids.push_back(output->id); | node_ids.push_back(output->id); | ||||
| @@ -479,3 +579,71 @@ bool ControlLogic::validateForRunning(std::string *error) const | |||||
| } | } | ||||
| return true; | 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 | Reset | ||||
| }; | }; | ||||
| enum class EdgeMode | |||||
| { | |||||
| Rising, | |||||
| Falling | |||||
| }; | |||||
| enum class ComparisonOperator | enum class ComparisonOperator | ||||
| { | { | ||||
| Equal, | Equal, | ||||
| @@ -37,6 +43,12 @@ struct ContactNodeConfig | |||||
| ContactMode mode = ContactMode::NormallyOpen; | ContactMode mode = ContactMode::NormallyOpen; | ||||
| }; | }; | ||||
| struct EdgeContactNodeConfig | |||||
| { | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| EdgeMode mode = EdgeMode::Rising; | |||||
| }; | |||||
| struct CoilNodeConfig | struct CoilNodeConfig | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| @@ -50,7 +62,50 @@ struct CompareNodeConfig | |||||
| std::int16_t value = 0; | 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 | struct LogicNode | ||||
| { | { | ||||
| @@ -99,11 +154,12 @@ void collectConditionNodes( | |||||
| void collectConditionExpressionIds( | void collectConditionExpressionIds( | ||||
| const ConditionExpression &expression, std::vector<std::string> *ids); | const ConditionExpression &expression, std::vector<std::string> *ids); | ||||
| // 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧 | |||||
| // 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧 | |||||
| struct LadderRung | struct LadderRung | ||||
| { | { | ||||
| std::string id; | std::string id; | ||||
| std::string name; | std::string name; | ||||
| std::string comment; | |||||
| std::optional<ConditionExpression> condition; | std::optional<ConditionExpression> condition; | ||||
| std::optional<LogicNode> output; | std::optional<LogicNode> output; | ||||
| @@ -123,3 +179,7 @@ struct ControlLogic | |||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| bool validateForRunning(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 "project_model.h" | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | |||||
| namespace { | namespace { | ||||
| @@ -53,8 +54,43 @@ bool containsDuplicateName(const std::vector<TItem> &items) | |||||
| return false; | 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 | } // 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 | bool Project::validate(std::string *error) const | ||||
| { | { | ||||
| if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | ||||
| @@ -113,6 +149,25 @@ bool Project::validate(std::string *error) const | |||||
| return false; | 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) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| // 工程聚合校验会向下委托页面和控件的完整规则 | // 工程聚合校验会向下委托页面和控件的完整规则 | ||||
| @@ -171,5 +226,9 @@ bool Project::validateForRunning(std::string *error) const | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| if (!validateTimerReferencesForRunning(controlLogics, error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -7,6 +7,14 @@ | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| struct RegisterComment | |||||
| { | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| std::string text; | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 描述工程的基本身份和文件格式信息 | // 描述工程的基本身份和文件格式信息 | ||||
| struct ProjectMetadata | struct ProjectMetadata | ||||
| { | { | ||||
| @@ -29,9 +37,12 @@ struct Project | |||||
| std::string initialHmiPageId; | std::string initialHmiPageId; | ||||
| // 工程统一管理的报警定义 | // 工程统一管理的报警定义 | ||||
| std::vector<AlarmDefinition> alarmDefinitions; | std::vector<AlarmDefinition> alarmDefinitions; | ||||
| // 工程级 M/D 地址注释,不包含当前值和引用位置 | |||||
| std::vector<RegisterComment> registerComments; | |||||
| // 工程包含的控制逻辑集合 | // 工程包含的控制逻辑集合 | ||||
| std::vector<ControlLogic> controlLogics; | std::vector<ControlLogic> controlLogics; | ||||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | |||||
| // 校验工程配置并通过 error 返回失败原因 | // 校验工程配置并通过 error 返回失败原因 | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| @@ -253,6 +253,35 @@ bool parseAddress( | |||||
| return true; | 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 控件类型枚举转换为工程文件中的稳定字符串 | // 将 HMI 控件类型枚举转换为工程文件中的稳定字符串 | ||||
| QString hmiControlTypeName(HmiControlType type) | QString hmiControlTypeName(HmiControlType type) | ||||
| { | { | ||||
| @@ -478,6 +507,29 @@ bool parseAlarmDefinition( | |||||
| return true; | 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 对象 | // 将 HMI 控件矩形区域序列化为 JSON 对象 | ||||
| QJsonObject serializeBounds(const HmiRect &bounds) | QJsonObject serializeBounds(const HmiRect &bounds) | ||||
| { | { | ||||
| @@ -758,6 +810,33 @@ bool parseContactMode( | |||||
| return true; | 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) | QString coilModeName(CoilMode mode) | ||||
| { | { | ||||
| @@ -891,6 +970,24 @@ QJsonObject serializeNodeConfig(const ContactNodeConfig &config) | |||||
| return object; | 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) | QJsonObject serializeNodeConfig(const CoilNodeConfig &config) | ||||
| { | { | ||||
| @@ -912,6 +1009,15 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config) | |||||
| return object; | 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 中的实际配置类型选择对应重载 | // 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载 | ||||
| QJsonObject serializeLogicNode(const LogicNode &node) | QJsonObject serializeLogicNode(const LogicNode &node) | ||||
| { | { | ||||
| @@ -938,13 +1044,56 @@ bool parseNodeConfig( | |||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| std::string type; | 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; | 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}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| if (!parseAddress(address_object, context + ".address", &address, state)) | if (!parseAddress(address_object, context + ".address", &address, state)) | ||||
| { | { | ||||
| @@ -964,6 +1113,18 @@ bool parseNodeConfig( | |||||
| *config = ContactNodeConfig{address, mode}; | *config = ContactNodeConfig{address, mode}; | ||||
| return true; | 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") | if (type == "coil") | ||||
| { | { | ||||
| std::string mode_text; | std::string mode_text; | ||||
| @@ -1127,6 +1288,7 @@ QJsonObject serializeLadderRung(const LadderRung &rung) | |||||
| QJsonObject object; | QJsonObject object; | ||||
| object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | ||||
| object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | ||||
| object.insert(QStringLiteral("comment"), fromUtf8(rung.comment)); | |||||
| object.insert( | object.insert( | ||||
| QStringLiteral("condition"), | QStringLiteral("condition"), | ||||
| rung.condition.has_value() | rung.condition.has_value() | ||||
| @@ -1149,6 +1311,7 @@ bool parseLadderRung( | |||||
| QJsonValue output; | QJsonValue output; | ||||
| if (!readString(object, "id", context, &rung->id, state) | if (!readString(object, "id", context, &rung->id, state) | ||||
| || !readString(object, "name", context, &rung->name, state) | || !readString(object, "name", context, &rung->name, state) | ||||
| || !readString(object, "comment", context, &rung->comment, state) | |||||
| || !readValue(object, "output", context, &output, state)) | || !readValue(object, "output", context, &output, state)) | ||||
| { | { | ||||
| return false; | return false; | ||||
| @@ -1270,6 +1433,12 @@ QJsonObject serializeProject(const Project &project) | |||||
| alarms.append(serializeAlarmDefinition(definition)); | alarms.append(serializeAlarmDefinition(definition)); | ||||
| } | } | ||||
| QJsonArray register_comments; | |||||
| for (const RegisterComment &comment : project.registerComments) | |||||
| { | |||||
| register_comments.append(serializeRegisterComment(comment)); | |||||
| } | |||||
| QJsonObject object; | QJsonObject object; | ||||
| object.insert(QStringLiteral("formatVersion"), fromUtf8(project.metadata.formatVersion)); | object.insert(QStringLiteral("formatVersion"), fromUtf8(project.metadata.formatVersion)); | ||||
| object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); | object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); | ||||
| @@ -1278,6 +1447,7 @@ QJsonObject serializeProject(const Project &project) | |||||
| object.insert(QStringLiteral("initialHmiPageId"), | object.insert(QStringLiteral("initialHmiPageId"), | ||||
| fromUtf8(project.initialHmiPageId)); | fromUtf8(project.initialHmiPageId)); | ||||
| object.insert(QStringLiteral("alarmDefinitions"), alarms); | object.insert(QStringLiteral("alarmDefinitions"), alarms); | ||||
| object.insert(QStringLiteral("registerComments"), register_comments); | |||||
| object.insert(QStringLiteral("controlLogics"), logics); | object.insert(QStringLiteral("controlLogics"), logics); | ||||
| return object; | return object; | ||||
| } | } | ||||
| @@ -1288,6 +1458,7 @@ bool parseProject( | |||||
| { | { | ||||
| QJsonArray pages; | QJsonArray pages; | ||||
| QJsonArray alarms; | QJsonArray alarms; | ||||
| QJsonArray register_comments; | |||||
| QJsonArray logics; | QJsonArray logics; | ||||
| if (!readString( | if (!readString( | ||||
| object, | object, | ||||
| @@ -1312,6 +1483,7 @@ bool parseProject( | |||||
| || !readString(object, "initialHmiPageId", "project", | || !readString(object, "initialHmiPageId", "project", | ||||
| &project->initialHmiPageId, state) | &project->initialHmiPageId, state) | ||||
| || !readArray(object, "alarmDefinitions", "project", &alarms, state) | || !readArray(object, "alarmDefinitions", "project", &alarms, state) | ||||
| || !readArray(object, "registerComments", "project", ®ister_comments, state) | |||||
| || !readArray(object, "controlLogics", "project", &logics, state)) | || !readArray(object, "controlLogics", "project", &logics, state)) | ||||
| { | { | ||||
| return false; | return false; | ||||
| @@ -1360,6 +1532,27 @@ bool parseProject( | |||||
| project->alarmDefinitions.push_back(std::move(definition)); | 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())); | project->controlLogics.reserve(static_cast<std::size_t>(logics.size())); | ||||
| for (int index = 0; index < logics.size(); ++index) | 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.name = "Draft logic"; | ||||
| disabled_draft.enabled = false; | disabled_draft.enabled = false; | ||||
| disabled_draft.rungs.push_back( | 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); | project.controlLogics.push_back(disabled_draft); | ||||
| require(project.validateForRunning(), | require(project.validateForRunning(), | ||||
| "a disabled draft logic must not block offline running"); | "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"); | 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() | void testLadderLogicBoundaries() | ||||
| { | { | ||||
| LogicNode stop; | LogicNode stop; | ||||
| @@ -269,7 +371,8 @@ void testLadderLogicBoundaries() | |||||
| require(!logic.validateForRunning(), | require(!logic.validateForRunning(), | ||||
| "conditions without an output must block runtime validation"); | "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"); | require(empty_rung.validate(), "an empty editing network must be valid"); | ||||
| empty_rung.output = coil; | empty_rung.output = coil; | ||||
| @@ -365,6 +468,8 @@ int main() | |||||
| testRegisterAddressParsing(); | testRegisterAddressParsing(); | ||||
| testRegisterRepositorySeparatesAreas(); | testRegisterRepositorySeparatesAreas(); | ||||
| testLogicNodeConfigurationBoundaries(); | testLogicNodeConfigurationBoundaries(); | ||||
| testTimerAndCommentBoundaries(); | |||||
| testTimerReferencesForRunning(); | |||||
| testLadderLogicBoundaries(); | testLadderLogicBoundaries(); | ||||
| testModelsValidateBindingsAndIdentifiers(); | testModelsValidateBindingsAndIdentifiers(); | ||||
| testMultiPageAndLogicDomainRules(); | testMultiPageAndLogicDomainRules(); | ||||
| @@ -1,5 +1,6 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "infrastructure/json_project_storage.h" | #include "infrastructure/json_project_storage.h" | ||||
| #include "services/register_comment_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include <QFile> | #include <QFile> | ||||
| @@ -125,6 +126,7 @@ Project makeExampleProject() | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-1"; | rung.id = "rung-1"; | ||||
| rung.name = "Network 1"; | rung.name = "Network 1"; | ||||
| rung.comment = "启动条件与温度检查"; | |||||
| ConditionExpression parallel; | ConditionExpression parallel; | ||||
| parallel.id = "parallel-start"; | parallel.id = "parallel-start"; | ||||
| parallel.kind = ConditionExpressionKind::Parallel; | parallel.kind = ConditionExpressionKind::Parallel; | ||||
| @@ -141,6 +143,47 @@ Project makeExampleProject() | |||||
| rung.output = coil; | rung.output = coil; | ||||
| logic.rungs.push_back(rung); | 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 project; | ||||
| project.metadata = {"example-project", "Example project", "1.0"}; | project.metadata = {"example-project", "Example project", "1.0"}; | ||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| @@ -158,13 +201,16 @@ Project makeExampleProject() | |||||
| AlarmCondition::DHigh, | AlarmCondition::DHigh, | ||||
| 80, | 80, | ||||
| "Temperature high"}); | "Temperature high"}); | ||||
| project.registerComments = { | |||||
| {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, | |||||
| {RegisterAddress{RegisterArea::D, 2}, "当前温度"}}; | |||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||
| ControlLogic draft_logic; | ControlLogic draft_logic; | ||||
| draft_logic.id = "draft-logic"; | draft_logic.id = "draft-logic"; | ||||
| draft_logic.name = "Draft logic"; | draft_logic.name = "Draft logic"; | ||||
| draft_logic.enabled = false; | draft_logic.enabled = false; | ||||
| draft_logic.rungs.push_back( | 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); | project.controlLogics.push_back(draft_logic); | ||||
| return project; | return project; | ||||
| } | } | ||||
| @@ -227,6 +273,10 @@ void testExampleProjectRoundTrip() | |||||
| const QString missing_initial_path = directory.filePath("missing-initial.json"); | const QString missing_initial_path = directory.filePath("missing-initial.json"); | ||||
| const QString missing_target_path = directory.filePath("missing-target.json"); | const QString missing_target_path = directory.filePath("missing-target.json"); | ||||
| const QString missing_alarms_path = directory.filePath("missing-alarms.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, | require(service.saveAs(first_path.toStdString()).succeeded, | ||||
| "example project save must succeed"); | "example project save must succeed"); | ||||
| const QByteArray saved_json = readBytes(first_path); | const QByteArray saved_json = readBytes(first_path); | ||||
| @@ -242,6 +292,12 @@ void testExampleProjectRoundTrip() | |||||
| && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | ||||
| && saved_json.contains("\"targetPageId\": \"settings-page\"") | && saved_json.contains("\"targetPageId\": \"settings-page\"") | ||||
| && saved_json.contains("\"alarmDefinitions\"") | && 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\""), | && saved_json.contains("\"type\": \"alarmList\""), | ||||
| "version 1.0 projects must persist pages and alarm definitions"); | "version 1.0 projects must persist pages and alarm definitions"); | ||||
| require(!saved_json.contains("\"stages\"") | require(!saved_json.contains("\"stages\"") | ||||
| @@ -286,11 +342,18 @@ void testExampleProjectRoundTrip() | |||||
| == AlarmCondition::MOn | == AlarmCondition::MOn | ||||
| && project.alarmDefinitions.at(1).threshold == 80, | && project.alarmDefinitions.at(1).threshold == 80, | ||||
| "M and D alarm definitions must survive round trip"); | "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, | require(project.controlLogics.size() == 2, | ||||
| "control logic count must survive round trip"); | "control logic count must survive round trip"); | ||||
| require(!project.controlLogics.front().enabled, | require(!project.controlLogics.front().enabled, | ||||
| "control logic enabled state must survive round trip"); | "control logic enabled state must survive round trip"); | ||||
| const LadderRung &rung = project.controlLogics.front().rungs.front(); | const LadderRung &rung = project.controlLogics.front().rungs.front(); | ||||
| require(rung.comment == "启动条件与温度检查", | |||||
| "rung comments must survive round trip"); | |||||
| require(rung.condition.has_value() | require(rung.condition.has_value() | ||||
| && rung.condition->kind == ConditionExpressionKind::Series, | && rung.condition->kind == ConditionExpressionKind::Series, | ||||
| "series root expression must survive round trip"); | "series root expression must survive round trip"); | ||||
| @@ -301,6 +364,32 @@ void testExampleProjectRoundTrip() | |||||
| require(rung.output.has_value(), | require(rung.output.has_value(), | ||||
| "ladder output must survive round trip"); | "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>( | const auto &compare = std::get<CompareNodeConfig>( | ||||
| rung.condition->children.at(1).node->config); | rung.condition->children.at(1).node->config); | ||||
| require(compare.address.index() == 2 && compare.value == 100, | require(compare.address.index() == 2 && compare.value == 100, | ||||
| @@ -339,6 +428,35 @@ void testExampleProjectRoundTrip() | |||||
| && missing_result.storageError == ProjectStorageError::MissingField, | && missing_result.storageError == ProjectStorageError::MissingField, | ||||
| "the 1.0 schema must require alarmDefinitions without migration defaults"); | "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(); | QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); | ||||
| QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | ||||
| QJsonObject main_page = pages.at(0).toObject(); | QJsonObject main_page = pages.at(0).toObject(); | ||||
| @@ -422,6 +540,38 @@ void testServiceStateAndSaveErrors() | |||||
| "failed save as must keep the previous current path"); | "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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -431,6 +581,7 @@ int main() | |||||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | ||||
| testEmptyProjectRoundTrip(); | testEmptyProjectRoundTrip(); | ||||
| testExampleProjectRoundTrip(); | testExampleProjectRoundTrip(); | ||||
| testRegisterCommentService(); | |||||
| testInvalidFiles(); | testInvalidFiles(); | ||||
| testServiceStateAndSaveErrors(); | testServiceStateAndSaveErrors(); | ||||
| } | } | ||||
| @@ -18,6 +18,7 @@ SOURCES += \ | |||||
| ../src/domain/project_model.cpp \ | ../src/domain/project_model.cpp \ | ||||
| ../src/domain/runtime_state.cpp \ | ../src/domain/runtime_state.cpp \ | ||||
| ../src/services/project_service.cpp \ | ../src/services/project_service.cpp \ | ||||
| ../src/services/register_comment_service.cpp \ | |||||
| ../src/infrastructure/json_project_storage.cpp | ../src/infrastructure/json_project_storage.cpp | ||||
| HEADERS += \ | HEADERS += \ | ||||
| @@ -30,4 +31,5 @@ HEADERS += \ | |||||
| ../src/domain/runtime_state.h \ | ../src/domain/runtime_state.h \ | ||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| ../src/services/project_service.h \ | ../src/services/project_service.h \ | ||||
| ../src/services/register_comment_service.h \ | |||||
| ../src/infrastructure/json_project_storage.h | ../src/infrastructure/json_project_storage.h | ||||