diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index fa83d92..10a3696 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -1,6 +1,8 @@ #include "control_logic_model.h" #include +#include +#include #include 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 bool hasDuplicateId(const std::vector &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 registerAddressForLogicNode( + const LogicNodeConfig &config) +{ + return std::visit( + [](const auto &value) -> std::optional + { + using Config = std::decay_t; + if constexpr (std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v) + { + 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(config); + return !std::holds_alternative(config) + && !std::holds_alternative(config); } bool LogicNode::isOutput() const { - return std::holds_alternative(config); + return std::holds_alternative(config) + || std::holds_alternative(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 &logics, + std::string *error) +{ + std::map 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(&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 nodes; + collectConditionNodes(*rung.condition, &nodes); + for (const LogicNode *node : nodes) + { + const auto *contact = std::get_if( + &node->config); + if (contact != nullptr + && timer_outputs.count(contact->address.index()) == 0U) + { + setError( + error, + "定时器触点 " + contact->address.toString() + + " 没有对应的已启用 TON 指令"); + return false; + } + } + } + } + return true; +} diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index 8c6fedc..e633cf2 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -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; +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 registerAddressForLogicNode( + const LogicNodeConfig &config); struct LogicNode { @@ -99,11 +154,12 @@ void collectConditionNodes( void collectConditionExpressionIds( const ConditionExpression &expression, std::vector *ids); -// 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧 +// 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧 struct LadderRung { std::string id; std::string name; + std::string comment; std::optional condition; std::optional 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 &logics, + std::string *error = nullptr); diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index ccf4ad9..53bc1d2 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -1,6 +1,7 @@ #include "project_model.h" #include +#include namespace { @@ -53,8 +54,43 @@ bool containsDuplicateName(const std::vector &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; } diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 992527b..96b7b4d 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -7,6 +7,14 @@ #include #include +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 alarmDefinitions; + // 工程级 M/D 地址注释,不包含当前值和引用位置 + std::vector registerComments; // 工程包含的控制逻辑集合 std::vector controlLogics; + const RegisterComment *findRegisterComment(const RegisterAddress &address) const; // 校验工程配置并通过 error 返回失败原因 bool validate(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index fdf677a..8a8ea55 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -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(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(logics.size())); for (int index = 0; index < logics.size(); ++index) { diff --git a/app/src/services/register_comment_service.cpp b/app/src/services/register_comment_service.cpp new file mode 100644 index 0000000..bfce035 --- /dev/null +++ b/app/src/services/register_comment_service.cpp @@ -0,0 +1,102 @@ +#include "register_comment_service.h" + +#include "project_service.h" + +#include +#include +#include + +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 &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, {}}; +} diff --git a/app/src/services/register_comment_service.h b/app/src/services/register_comment_service.h new file mode 100644 index 0000000..5a3b287 --- /dev/null +++ b/app/src/services/register_comment_service.h @@ -0,0 +1,37 @@ +#pragma once + +#include "domain/project_model.h" + +#include +#include + +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 &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_; +}; diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index bfebd76..aa62909 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -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(); diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index f538d05..0ee999e 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -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 @@ -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(ton_rung.output->config) + && std::get(ton_rung.output->config).address + == TimerAddress{7} + && std::get(ton_rung.output->config).presetMs == 2500, + "TON timer address and preset must survive round trip"); + require(std::holds_alternative( + ton_rung.condition->node->config) + && std::get(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( + timer_rung.condition->children.at(1).node->config) + && std::get( + timer_rung.condition->children.at(1).node->config).address + == TimerAddress{7} + && std::holds_alternative( + timer_rung.condition->children.front().node->config) + && std::get( + 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( 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(); } diff --git a/app/tests/project_management_tests.pro b/app/tests/project_management_tests.pro index 5ffac5e..532a1f4 100644 --- a/app/tests/project_management_tests.pro +++ b/app/tests/project_management_tests.pro @@ -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