From ab1457cd6893a7527c973501f0386cc5581259d8 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Mon, 17 Aug 2026 13:13:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E5=AD=97=E6=93=8D=E4=BD=9C=E6=8C=87=E4=BB=A4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/domain/control_logic_model.cpp | 294 ++++++++++++++++- app/src/domain/control_logic_model.h | 90 +++++- app/src/domain/project_model.cpp | 2 +- .../infrastructure/json_project_storage.cpp | 303 +++++++++++++++++- app/tests/domain_tests.cpp | 118 +++++++ app/tests/project_management_tests.cpp | 140 ++++++++ 6 files changed, 937 insertions(+), 10 deletions(-) diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 10a3696..af7b78a 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -31,6 +31,35 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) return true; } +bool validateWordAddress( + const RegisterAddress &address, + const std::string &description, + std::string *error) +{ + if (!address.isValid() || address.area() != RegisterArea::D) + { + setError(error, description + "必须使用有效的 D 区地址"); + return false; + } + return true; +} + +bool validateConfig(const CounterContactNodeConfig &config, std::string *error) +{ + if (!config.address.isValid()) + { + setError(error, "计数器触点必须使用有效的 C 地址"); + return false; + } + if (config.mode != ContactMode::NormallyOpen + && config.mode != ContactMode::NormallyClosed) + { + setError(error, "计数器触点使用了不支持的模式"); + return false; + } + return true; +} + bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) { if (!config.address.isValid() || config.address.area() != RegisterArea::M) @@ -115,6 +144,56 @@ bool validateConfig(const TonNodeConfig &config, std::string *error) return true; } +bool validateConfig(const CounterNodeConfig &config, std::string *error) +{ + if (!config.address.isValid()) + { + setError(error, "计数器指令必须使用有效的 C 地址"); + return false; + } + if (config.mode != CounterMode::Up && config.mode != CounterMode::Down) + { + setError(error, "计数器指令使用了不支持的方向"); + return false; + } + if (!validateWordAddress(config.currentValueAddress, "计数当前值", error) + || !config.preset.validate(error)) + { + return false; + } + if (!config.resetAddress.isValid() + || config.resetAddress.area() != RegisterArea::M) + { + setError(error, "计数器复位输入必须使用有效的 M 区地址"); + return false; + } + return true; +} + +bool validateConfig(const MoveNodeConfig &config, std::string *error) +{ + if (!config.source.validate(error)) + { + return false; + } + return validateWordAddress(config.destination, "MOVE 目标", error); +} + +bool validateConfig(const ArithmeticNodeConfig &config, std::string *error) +{ + if (config.operation != ArithmeticOperation::Add + && config.operation != ArithmeticOperation::Subtract) + { + setError(error, "算术指令使用了不支持的运算"); + return false; + } + if (!config.left.validate(error) || !config.right.validate(error)) + { + return false; + } + return validateWordAddress(config.destination, "算术指令目标", error); +} + template bool hasDuplicateId(const std::vector &items) { @@ -181,6 +260,16 @@ void normalizeExpression(ConditionExpression *expression) } } +void appendValidAddress( + std::vector *addresses, + const RegisterAddress &address) +{ + if (address.isValid()) + { + addresses->push_back(address); + } +} + } // namespace TimerAddress::TimerAddress(int index) @@ -188,6 +277,55 @@ TimerAddress::TimerAddress(int index) { } +bool WordOperand::validate(std::string *error) const +{ + if (kind == WordOperandKind::Constant) + { + return true; + } + if (kind != WordOperandKind::Register) + { + setError(error, "字操作数使用了不支持的类型"); + return false; + } + if (!address.isValid() || address.area() != RegisterArea::D) + { + setError(error, "字操作数必须使用有效的 D 区地址"); + return false; + } + return true; +} + +CounterAddress::CounterAddress(int index) + : index_(index) +{ +} + +int CounterAddress::index() const +{ + return index_; +} + +bool CounterAddress::isValid() const +{ + return index_ >= kMinimumIndex && index_ <= kMaximumIndex; +} + +std::string CounterAddress::toString() const +{ + return isValid() ? "C" + std::to_string(index_) : "InvalidCounterAddress"; +} + +bool CounterAddress::operator==(const CounterAddress &other) const +{ + return index_ == other.index_; +} + +bool CounterAddress::operator!=(const CounterAddress &other) const +{ + return !(*this == other); +} + int TimerAddress::index() const { return index_; @@ -227,11 +365,76 @@ std::optional registerAddressForLogicNode( { return value.address; } + else if constexpr (std::is_same_v) + { + return value.currentValueAddress; + } + else if constexpr (std::is_same_v + || std::is_same_v) + { + return value.destination; + } return std::nullopt; }, config); } +void collectRegisterAddressesForLogicNode( + const LogicNodeConfig &config, + std::vector *addresses) +{ + if (addresses == nullptr) + { + return; + } + std::visit( + [addresses](const auto &value) + { + using Config = std::decay_t; + if constexpr (std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v) + { + appendValidAddress(addresses, value.address); + } + else if constexpr (std::is_same_v) + { + appendValidAddress(addresses, value.currentValueAddress); + appendValidAddress(addresses, value.resetAddress); + if (value.preset.kind == WordOperandKind::Register) + { + appendValidAddress(addresses, value.preset.address); + } + } + else if constexpr (std::is_same_v) + { + appendValidAddress(addresses, value.destination); + if (value.source.kind == WordOperandKind::Register) + { + appendValidAddress(addresses, value.source.address); + } + } + else if constexpr (std::is_same_v) + { + appendValidAddress(addresses, value.destination); + if (value.left.kind == WordOperandKind::Register) + { + appendValidAddress(addresses, value.left.address); + } + if (value.right.kind == WordOperandKind::Register) + { + appendValidAddress(addresses, value.right.address); + } + } + else if constexpr (std::is_same_v) + { + return; + } + }, + config); +} + bool LogicNode::validate(std::string *error) const { if (id.empty()) @@ -251,14 +454,20 @@ bool LogicNode::isConfigured() const bool LogicNode::isCondition() const { - return !std::holds_alternative(config) - && !std::holds_alternative(config); + return std::holds_alternative(config) + || std::holds_alternative(config) + || std::holds_alternative(config) + || std::holds_alternative(config) + || std::holds_alternative(config); } bool LogicNode::isOutput() const { return std::holds_alternative(config) - || std::holds_alternative(config); + || std::holds_alternative(config) + || std::holds_alternative(config) + || std::holds_alternative(config) + || std::holds_alternative(config); } ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) @@ -483,7 +692,7 @@ bool LadderRung::validateStructure(std::string *error) const { if (!output->validate(error) || !output->isOutput()) { - setError(error, "梯形图网络输出必须是线圈或 TON 指令"); + setError(error, "梯形图网络输出必须是有效的输出指令"); return false; } node_ids.push_back(output->id); @@ -647,3 +856,80 @@ bool validateTimerReferencesForRunning( } return true; } + +bool validateCounterReferencesForRunning( + const std::vector &logics, + std::string *error) +{ + std::map counter_outputs; + for (const ControlLogic &logic : logics) + { + if (!logic.enabled) + { + continue; + } + for (const LadderRung &rung : logic.rungs) + { + if (!rung.output.has_value()) + { + continue; + } + const auto *counter = std::get_if( + &rung.output->config); + if (counter == nullptr) + { + continue; + } + const auto inserted = counter_outputs.emplace( + counter->address.index(), rung.output->id); + if (!inserted.second) + { + setError( + error, + "计数器 " + counter->address.toString() + + " 只能由一个已启用计数指令驱动"); + 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 + && counter_outputs.count(contact->address.index()) == 0U) + { + setError( + error, + "计数器触点 " + contact->address.toString() + + " 没有对应的已启用计数指令"); + return false; + } + } + } + } + return true; +} + +bool validateLogicResourceReferencesForRunning( + const std::vector &logics, + std::string *error) +{ + return validateTimerReferencesForRunning(logics, error) + && validateCounterReferencesForRunning(logics, error); +} diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index e633cf2..fde5952 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -37,6 +37,21 @@ enum class ComparisonOperator GreaterThanOrEqual }; +enum class WordOperandKind +{ + Constant, + Register +}; + +struct WordOperand +{ + WordOperandKind kind = WordOperandKind::Constant; + RegisterAddress address{RegisterArea::D, 0}; + std::int16_t constant = 0; + + bool validate(std::string *error = nullptr) const; +}; + struct ContactNodeConfig { RegisterAddress address{RegisterArea::M, 0}; @@ -87,6 +102,37 @@ struct TimerContactNodeConfig ContactMode mode = ContactMode::NormallyOpen; }; +class CounterAddress +{ +public: + static constexpr int kMinimumIndex = 0; + static constexpr int kMaximumIndex = 4000; + + explicit CounterAddress(int index = 0); + + int index() const; + bool isValid() const; + std::string toString() const; + + bool operator==(const CounterAddress &other) const; + bool operator!=(const CounterAddress &other) const; + +private: + int index_ = 0; +}; + +enum class CounterMode +{ + Up, + Down +}; + +struct CounterContactNodeConfig +{ + CounterAddress address; + ContactMode mode = ContactMode::NormallyOpen; +}; + struct TonNodeConfig { static constexpr int kMinimumPresetMs = 1; @@ -96,16 +142,52 @@ struct TonNodeConfig int presetMs = 1000; }; +struct CounterNodeConfig +{ + CounterAddress address; + CounterMode mode = CounterMode::Up; + RegisterAddress currentValueAddress{RegisterArea::D, 0}; + WordOperand preset; + RegisterAddress resetAddress{RegisterArea::M, 0}; +}; + +struct MoveNodeConfig +{ + WordOperand source; + RegisterAddress destination{RegisterArea::D, 0}; +}; + +enum class ArithmeticOperation +{ + Add, + Subtract +}; + +struct ArithmeticNodeConfig +{ + ArithmeticOperation operation = ArithmeticOperation::Add; + WordOperand left; + WordOperand right; + RegisterAddress destination{RegisterArea::D, 0}; +}; + using LogicNodeConfig = std::variant< ContactNodeConfig, EdgeContactNodeConfig, TimerContactNodeConfig, + CounterContactNodeConfig, CoilNodeConfig, CompareNodeConfig, - TonNodeConfig>; + TonNodeConfig, + CounterNodeConfig, + MoveNodeConfig, + ArithmeticNodeConfig>; std::optional registerAddressForLogicNode( const LogicNodeConfig &config); +void collectRegisterAddressesForLogicNode( + const LogicNodeConfig &config, + std::vector *addresses); struct LogicNode { @@ -183,3 +265,9 @@ struct ControlLogic bool validateTimerReferencesForRunning( const std::vector &logics, std::string *error = nullptr); +bool validateCounterReferencesForRunning( + const std::vector &logics, + std::string *error = nullptr); +bool validateLogicResourceReferencesForRunning( + 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 53bc1d2..53edaad 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -226,7 +226,7 @@ bool Project::validateForRunning(std::string *error) const return false; } } - if (!validateTimerReferencesForRunning(controlLogics, error)) + if (!validateLogicResourceReferencesForRunning(controlLogics, error)) { return false; } diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 8a8ea55..2d57cfc 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -282,6 +282,113 @@ bool parseTimerAddress( return true; } +QJsonObject serializeCounterAddress(const CounterAddress &address) +{ + QJsonObject object; + object.insert(QStringLiteral("index"), address.index()); + return object; +} + +bool parseCounterAddress( + const QJsonObject &object, + const std::string &context, + CounterAddress *address, + ParseState *state) +{ + int index = 0; + if (!readInt( + object, + "index", + context, + CounterAddress::kMinimumIndex, + CounterAddress::kMaximumIndex, + &index, + state)) + { + return false; + } + *address = CounterAddress{index}; + return true; +} + +QString wordOperandKindName(WordOperandKind kind) +{ + return kind == WordOperandKind::Constant + ? QStringLiteral("constant") : QStringLiteral("register"); +} + +QJsonObject serializeWordOperand(const WordOperand &operand) +{ + QJsonObject object; + object.insert(QStringLiteral("kind"), wordOperandKindName(operand.kind)); + if (operand.kind == WordOperandKind::Constant) + { + object.insert(QStringLiteral("value"), operand.constant); + } + else + { + object.insert(QStringLiteral("address"), serializeAddress(operand.address)); + } + return object; +} + +bool parseWordOperand( + const QJsonObject &object, + const std::string &context, + WordOperand *operand, + ParseState *state) +{ + std::string kind; + if (!readString(object, "kind", context, &kind, state)) + { + return false; + } + if (kind == "constant") + { + int value = 0; + if (!readInt( + object, + "value", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &value, + state)) + { + return false; + } + *operand = WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + static_cast(value)}; + return true; + } + if (kind != "register") + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".kind 必须是 constant 或 register"); + } + QJsonObject address_object; + RegisterAddress address{RegisterArea::D, 0}; + if (!readObject(object, "address", context, &address_object, state) + || !parseAddress(address_object, context + ".address", &address, state)) + { + return false; + } + if (address.area() != RegisterArea::D) + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".address 必须使用 D 区地址"); + } + *operand = WordOperand{ + WordOperandKind::Register, + address, + 0}; + return true; +} + // 将 HMI 控件类型枚举转换为工程文件中的稳定字符串 QString hmiControlTypeName(HmiControlType type) { @@ -988,6 +1095,15 @@ QJsonObject serializeNodeConfig(const TimerContactNodeConfig &config) return object; } +QJsonObject serializeNodeConfig(const CounterContactNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("counterContact")); + object.insert(QStringLiteral("counter"), serializeCounterAddress(config.address)); + object.insert(QStringLiteral("mode"), contactModeName(config.mode)); + return object; +} + // 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 QJsonObject serializeNodeConfig(const CoilNodeConfig &config) { @@ -1018,6 +1134,52 @@ QJsonObject serializeNodeConfig(const TonNodeConfig &config) return object; } +QString counterModeName(CounterMode mode) +{ + return mode == CounterMode::Up + ? QStringLiteral("up") : QStringLiteral("down"); +} + +QJsonObject serializeNodeConfig(const CounterNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("counter")); + object.insert(QStringLiteral("counter"), serializeCounterAddress(config.address)); + object.insert(QStringLiteral("mode"), counterModeName(config.mode)); + object.insert( + QStringLiteral("currentValueAddress"), + serializeAddress(config.currentValueAddress)); + object.insert(QStringLiteral("preset"), serializeWordOperand(config.preset)); + object.insert(QStringLiteral("resetAddress"), serializeAddress(config.resetAddress)); + return object; +} + +QJsonObject serializeNodeConfig(const MoveNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("move")); + object.insert(QStringLiteral("source"), serializeWordOperand(config.source)); + object.insert(QStringLiteral("destination"), serializeAddress(config.destination)); + return object; +} + +QString arithmeticOperationName(ArithmeticOperation operation) +{ + return operation == ArithmeticOperation::Add + ? QStringLiteral("add") : QStringLiteral("subtract"); +} + +QJsonObject serializeNodeConfig(const ArithmeticNodeConfig &config) +{ + QJsonObject object; + object.insert(QStringLiteral("type"), QStringLiteral("arithmetic")); + object.insert(QStringLiteral("operation"), arithmeticOperationName(config.operation)); + object.insert(QStringLiteral("left"), serializeWordOperand(config.left)); + object.insert(QStringLiteral("right"), serializeWordOperand(config.right)); + object.insert(QStringLiteral("destination"), serializeAddress(config.destination)); + return object; +} + // 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载 QJsonObject serializeLogicNode(const LogicNode &node) { @@ -1049,15 +1211,82 @@ bool parseNodeConfig( return false; } - if (type == "timerContact" || type == "ton") + if (type == "timerContact" || type == "ton" + || type == "counterContact" || type == "counter") { - QJsonObject timer_object; - if (!readObject(object, "timer", context, &timer_object, state)) + const char *resource_field = + type == "timerContact" || type == "ton" ? "timer" : "counter"; + QJsonObject resource_object; + if (!readObject(object, resource_field, context, &resource_object, state)) { return false; } + if (type == "counterContact" || type == "counter") + { + CounterAddress address; + if (!parseCounterAddress( + resource_object, context + ".counter", &address, state)) + { + return false; + } + if (type == "counterContact") + { + std::string mode_text; + ContactMode mode = ContactMode::NormallyOpen; + if (!readString(object, "mode", context, &mode_text, state) + || !parseContactMode(mode_text, &mode, state)) + { + return false; + } + *config = CounterContactNodeConfig{address, mode}; + return true; + } + std::string mode_text; + CounterMode mode = CounterMode::Up; + if (!readString(object, "mode", context, &mode_text, state)) + { + return false; + } + if (mode_text == "up") + { + mode = CounterMode::Up; + } + else if (mode_text == "down") + { + mode = CounterMode::Down; + } + else + { + return state->fail( + ProjectStorageError::InvalidField, + "不支持的计数器方向:" + mode_text); + } + QJsonObject current_object; + QJsonObject preset_object; + QJsonObject reset_object; + RegisterAddress current{RegisterArea::D, 0}; + RegisterAddress reset{RegisterArea::M, 0}; + WordOperand preset; + if (!readObject( + object, "currentValueAddress", context, ¤t_object, state) + || !parseAddress( + current_object, + context + ".currentValueAddress", + ¤t, + state) + || !readObject(object, "preset", context, &preset_object, state) + || !parseWordOperand(preset_object, context + ".preset", &preset, state) + || !readObject(object, "resetAddress", context, &reset_object, state) + || !parseAddress( + reset_object, context + ".resetAddress", &reset, state)) + { + return false; + } + *config = CounterNodeConfig{address, mode, current, preset, reset}; + return true; + } TimerAddress address; - if (!parseTimerAddress(timer_object, context + ".timer", &address, state)) + if (!parseTimerAddress(resource_object, context + ".timer", &address, state)) { return false; } @@ -1089,6 +1318,71 @@ bool parseNodeConfig( return true; } + if (type == "move") + { + QJsonObject source_object; + QJsonObject destination_object; + WordOperand source; + RegisterAddress destination{RegisterArea::D, 0}; + if (!readObject(object, "source", context, &source_object, state) + || !parseWordOperand(source_object, context + ".source", &source, state) + || !readObject(object, "destination", context, &destination_object, state) + || !parseAddress( + destination_object, + context + ".destination", + &destination, + state)) + { + return false; + } + *config = MoveNodeConfig{source, destination}; + return true; + } + if (type == "arithmetic") + { + std::string operation_text; + QJsonObject left_object; + QJsonObject right_object; + QJsonObject destination_object; + WordOperand left; + WordOperand right; + RegisterAddress destination{RegisterArea::D, 0}; + ArithmeticOperation operation = ArithmeticOperation::Add; + if (!readString(object, "operation", context, &operation_text, state)) + { + return false; + } + if (operation_text == "add") + { + operation = ArithmeticOperation::Add; + } + else if (operation_text == "subtract") + { + operation = ArithmeticOperation::Subtract; + } + else + { + return state->fail( + ProjectStorageError::InvalidField, + "不支持的算术运算:" + operation_text); + } + if (!readObject(object, "left", context, &left_object, state) + || !parseWordOperand(left_object, context + ".left", &left, state) + || !readObject(object, "right", context, &right_object, state) + || !parseWordOperand(right_object, context + ".right", &right, state) + || !readObject(object, "destination", context, &destination_object, state) + || !parseAddress( + destination_object, + context + ".destination", + &destination, + state)) + { + return false; + } + *config = ArithmeticNodeConfig{operation, left, right, destination}; + return true; + } + QJsonObject address_object; if (!readObject(object, "address", context, &address_object, state)) { @@ -1161,6 +1455,7 @@ bool parseNodeConfig( return true; } + return state->fail( ProjectStorageError::InvalidField, "不支持的逻辑节点类型:" + type); diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index aa62909..554e942 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -310,6 +310,123 @@ void testTimerReferencesForRunning() "disabled timer drafts must not block runtime timer validation"); } +LadderRung makeCounterRung( + const std::string &rung_id, + const std::string &output_id, + int counter_index) +{ + LogicNode condition; + condition.id = rung_id + "-input"; + condition.config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, counter_index}, + ContactMode::NormallyOpen}; + LogicNode output; + output.id = output_id; + output.config = CounterNodeConfig{ + CounterAddress{counter_index}, + CounterMode::Up, + RegisterAddress{RegisterArea::D, counter_index}, + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + 10}, + RegisterAddress{RegisterArea::M, counter_index + 1}}; + LadderRung rung; + rung.id = rung_id; + rung.name = rung_id; + rung.condition = ConditionExpression::fromNode(condition); + rung.output = output; + return rung; +} + +void testCounterAndDataInstructionBoundaries() +{ + require(CounterAddress{0}.isValid() && CounterAddress{4000}.isValid(), + "C0 and C4000 must be valid counter resources"); + require(!CounterAddress{-1}.isValid() && !CounterAddress{4001}.isValid(), + "counter resources outside 0 through 4000 must be rejected"); + + LogicNode counter; + counter.id = "counter"; + counter.config = CounterNodeConfig{ + CounterAddress{0}, + CounterMode::Up, + RegisterAddress{RegisterArea::D, 10}, + WordOperand{ + WordOperandKind::Register, + RegisterAddress{RegisterArea::D, 11}, + 0}, + RegisterAddress{RegisterArea::M, 12}}; + require(counter.validate(), + "a counter with C identity and external M/D addresses must be valid"); + + CounterNodeConfig invalid_counter = std::get(counter.config); + invalid_counter.currentValueAddress = RegisterAddress{RegisterArea::M, 10}; + counter.config = invalid_counter; + require(!counter.validate(), "counter CV must reject M addresses"); + + LogicNode move; + move.id = "move"; + move.config = MoveNodeConfig{ + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + -100}, + RegisterAddress{RegisterArea::D, 20}}; + require(move.validate() && move.isOutput(), + "MOVE with a constant source and D destination must be a valid output"); + + LogicNode add; + add.id = "add"; + add.config = ArithmeticNodeConfig{ + ArithmeticOperation::Add, + WordOperand{ + WordOperandKind::Register, + RegisterAddress{RegisterArea::D, 20}, + 0}, + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + 1}, + RegisterAddress{RegisterArea::D, 20}}; + require(add.validate() && add.isOutput(), + "ADD must allow the same D register as source and destination"); + + ControlLogic valid; + valid.id = "counter-valid"; + valid.name = "Counter valid"; + valid.rungs.push_back(makeCounterRung("counter-rung", "ctu-0", 0)); + require(validateCounterReferencesForRunning({valid}), + "a counter output without contacts must pass reference validation"); + + ControlLogic duplicate = valid; + duplicate.id = "counter-duplicate"; + duplicate.name = "Counter duplicate"; + duplicate.rungs.front().output->id = "ctu-duplicate"; + require(!validateCounterReferencesForRunning({valid, duplicate}), + "the same C resource must not have multiple enabled drivers"); + + ControlLogic missing; + missing.id = "counter-missing"; + missing.name = "Counter missing"; + LogicNode missing_contact; + missing_contact.id = "missing-counter-contact"; + missing_contact.config = CounterContactNodeConfig{ + CounterAddress{7}, ContactMode::NormallyOpen}; + LogicNode output; + output.id = "missing-counter-coil"; + output.config = CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal}; + LadderRung missing_rung; + missing_rung.id = "missing-counter-rung"; + missing_rung.name = "Missing counter rung"; + missing_rung.condition = ConditionExpression::fromNode(missing_contact); + missing_rung.output = output; + missing.rungs.push_back(missing_rung); + require(!validateCounterReferencesForRunning({missing}), + "a C contact without an enabled counter driver must be rejected"); +} + void testLadderLogicBoundaries() { LogicNode stop; @@ -470,6 +587,7 @@ int main() testLogicNodeConfigurationBoundaries(); testTimerAndCommentBoundaries(); testTimerReferencesForRunning(); + testCounterAndDataInstructionBoundaries(); testLadderLogicBoundaries(); testModelsValidateBindingsAndIdentifiers(); testMultiPageAndLogicDomainRules(); diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 0ee999e..689a6af 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace { @@ -184,6 +185,112 @@ Project makeExampleProject() timer_rung.output = timer_coil; logic.rungs.push_back(timer_rung); + LogicNode counter_input; + counter_input.id = "counter-input"; + counter_input.config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 7}, ContactMode::NormallyOpen}; + LogicNode counter_output; + counter_output.id = "counter-output"; + counter_output.config = CounterNodeConfig{ + CounterAddress{3}, + CounterMode::Up, + RegisterAddress{RegisterArea::D, 10}, + WordOperand{ + WordOperandKind::Register, + RegisterAddress{RegisterArea::D, 11}, + 0}, + RegisterAddress{RegisterArea::M, 8}}; + LadderRung counter_rung; + counter_rung.id = "counter-rung"; + counter_rung.name = "Counter network"; + counter_rung.comment = "计数器上升沿计数"; + counter_rung.condition = ConditionExpression::fromNode(counter_input); + counter_rung.output = counter_output; + logic.rungs.push_back(counter_rung); + + LogicNode counter_contact; + counter_contact.id = "counter-contact"; + counter_contact.config = CounterContactNodeConfig{ + CounterAddress{3}, ContactMode::NormallyOpen}; + LogicNode counter_coil; + counter_coil.id = "counter-coil"; + counter_coil.config = CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 9}, CoilMode::Normal}; + LadderRung counter_feedback_rung; + counter_feedback_rung.id = "counter-feedback-rung"; + counter_feedback_rung.name = "Counter feedback"; + counter_feedback_rung.condition = ConditionExpression::fromNode(counter_contact); + counter_feedback_rung.output = counter_coil; + logic.rungs.push_back(counter_feedback_rung); + + const auto addDataRung = [&logic]( + const std::string &rung_id, + const std::string &input_id, + int input_address, + LogicNode output) + { + LogicNode input; + input.id = input_id; + input.config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, input_address}, + ContactMode::NormallyOpen}; + LadderRung data_rung; + data_rung.id = rung_id; + data_rung.name = rung_id; + data_rung.condition = ConditionExpression::fromNode(input); + data_rung.output = std::move(output); + logic.rungs.push_back(std::move(data_rung)); + }; + addDataRung( + "move-rung", + "move-input", + 20, + LogicNode{ + "move-output", + MoveNodeConfig{ + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + 25}, + RegisterAddress{RegisterArea::D, 20}}, + true}); + addDataRung( + "add-rung", + "add-input", + 21, + LogicNode{ + "add-output", + ArithmeticNodeConfig{ + ArithmeticOperation::Add, + WordOperand{ + WordOperandKind::Register, + RegisterAddress{RegisterArea::D, 20}, + 0}, + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + 1}, + RegisterAddress{RegisterArea::D, 21}}, + true}); + addDataRung( + "sub-rung", + "sub-input", + 22, + LogicNode{ + "sub-output", + ArithmeticNodeConfig{ + ArithmeticOperation::Subtract, + WordOperand{ + WordOperandKind::Register, + RegisterAddress{RegisterArea::D, 21}, + 0}, + WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + 1}, + RegisterAddress{RegisterArea::D, 22}}, + true}); + Project project; project.metadata = {"example-project", "Example project", "1.0"}; project.hmiPages.push_back(page); @@ -297,6 +404,12 @@ void testExampleProjectRoundTrip() && saved_json.contains("\"type\": \"edgeContact\"") && saved_json.contains("\"type\": \"timerContact\"") && saved_json.contains("\"type\": \"ton\"") + && saved_json.contains("\"type\": \"counterContact\"") + && saved_json.contains("\"type\": \"counter\"") + && saved_json.contains("\"type\": \"move\"") + && saved_json.contains("\"type\": \"arithmetic\"") + && saved_json.contains("\"operation\": \"add\"") + && saved_json.contains("\"operation\": \"subtract\"") && saved_json.contains("\"comment\": \"启动条件与温度检查\"") && saved_json.contains("\"type\": \"alarmList\""), "version 1.0 projects must persist pages and alarm definitions"); @@ -390,6 +503,33 @@ void testExampleProjectRoundTrip() == EdgeMode::Falling, "falling edge and T contact configurations must survive round trip"); + const LadderRung &counter_rung = project.controlLogics.front().rungs.at(3); + const auto &counter = std::get(counter_rung.output->config); + require(counter.address == CounterAddress{3} + && counter.currentValueAddress == RegisterAddress{RegisterArea::D, 10} + && counter.preset.kind == WordOperandKind::Register + && counter.preset.address == RegisterAddress{RegisterArea::D, 11} + && counter.resetAddress == RegisterAddress{RegisterArea::M, 8}, + "counter resource and external M/D addresses must survive round trip"); + const LadderRung &counter_feedback = project.controlLogics.front().rungs.at(4); + require(std::holds_alternative( + counter_feedback.condition->node->config) + && std::get( + counter_feedback.condition->node->config).address + == CounterAddress{3}, + "counter contacts must survive round trip"); + require(std::get( + project.controlLogics.front().rungs.at(5).output->config) + .source.constant == 25, + "MOVE operands must survive round trip"); + require(std::get( + project.controlLogics.front().rungs.at(6).output->config) + .operation == ArithmeticOperation::Add + && std::get( + project.controlLogics.front().rungs.at(7).output->config) + .operation == ArithmeticOperation::Subtract, + "ADD and SUB operations must survive round trip"); + const auto &compare = std::get( rung.condition->children.at(1).node->config); require(compare.address.index() == 2 && compare.value == 100,