From 6a2b79b477105eb715ddffc56da955c7bf9fc8a3 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Wed, 19 Aug 2026 12:51:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=A2=AF=E5=BD=A2?= =?UTF-8?q?=E5=9B=BE=E8=BF=90=E8=A1=8C=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrated_platform.pro | 1 + app/src/domain/alarm_model.cpp | 12 + app/src/domain/control_logic_model.cpp | 225 ++++++++++++++---- app/src/domain/control_logic_model.h | 6 +- app/src/domain/hmi_model.cpp | 58 ++++- app/src/domain/project_limits.h | 52 ++++ app/src/domain/project_model.cpp | 94 ++++++++ .../infrastructure/json_project_storage.cpp | 181 +++++++++++--- .../plc_communication_service.cpp | 110 ++++++++- .../plc_communication_service.h | 4 +- app/src/services/alarm_editor_service.cpp | 5 + app/src/services/hmi_editor_service.cpp | 17 ++ app/src/services/logic_editor_service.cpp | 72 ++++++ app/src/services/plc_communication_gateway.h | 60 ++++- app/src/services/runtime_mode_service.cpp | 17 +- app/src/services/runtime_mode_service.h | 2 +- app/src/services/software_logic_executor.cpp | 21 +- app/tests/domain_tests.cpp | 128 +++++++++- app/tests/domain_tests.pro | 1 + app/tests/logic_editor_service_tests.cpp | 51 ++++ .../offline_simulation_service_tests.cpp | 23 ++ app/tests/plc_runtime_tests.cpp | 70 +++++- app/tests/plc_runtime_tests.pro | 9 +- app/tests/project_management_tests.cpp | 126 ++++++++++ app/tests/project_management_tests.pro | 1 + app/tests/runtime_mode_service_tests.cpp | 3 +- 26 files changed, 1226 insertions(+), 123 deletions(-) create mode 100644 app/src/domain/project_limits.h diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 4936aa4..4c8ffa9 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -76,6 +76,7 @@ HEADERS += \ src/domain/hmi_model.h \ src/domain/hmi_control_registry.h \ src/domain/control_logic_model.h \ + src/domain/project_limits.h \ src/domain/project_model.h \ src/domain/runtime_state.h \ src/domain/project_storage.h \ diff --git a/app/src/domain/alarm_model.cpp b/app/src/domain/alarm_model.cpp index 536836f..58ce351 100644 --- a/app/src/domain/alarm_model.cpp +++ b/app/src/domain/alarm_model.cpp @@ -1,5 +1,7 @@ #include "alarm_model.h" +#include "project_limits.h" + namespace { void setError(std::string *error, const std::string &message) @@ -19,11 +21,21 @@ bool AlarmDefinition::validate(std::string *error) const setError(error, "报警定义 ID 不能为空"); return false; } + if (id.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "报警定义 ID 不能超过 128 个 UTF-8 字节"); + return false; + } if (message.empty()) { setError(error, "报警文本不能为空"); return false; } + if (message.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "报警文本不能超过 4096 个 UTF-8 字节"); + return false; + } if (!address.isValid()) { setError(error, "报警定义使用了无效地址"); diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 5ebb5df..cfcd19c 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -1,5 +1,7 @@ #include "control_logic_model.h" +#include "project_limits.h" + #include #include #include @@ -161,6 +163,12 @@ bool validateConfig(const CounterNodeConfig &config, std::string *error) { return false; } + if (config.preset.kind == WordOperandKind::Constant + && config.preset.constant < 0) + { + setError(error, "计数器常量预设值必须在 0~32767 范围内"); + return false; + } if (!config.resetAddress.isValid() || config.resetAddress.area() != RegisterArea::M) { @@ -272,6 +280,131 @@ void appendValidAddress( } } +bool validateConditionExpression( + const ConditionExpression &expression, + std::size_t depth, + std::size_t *node_count, + int *columns, + int *rows, + std::string *error) +{ + if (depth > ProjectLimits::kMaximumExpressionDepth) + { + setError(error, "条件表达式最多嵌套 20 层"); + return false; + } + ++*node_count; + if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) + { + setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子"); + return false; + } + if (expression.id.empty()) + { + setError(error, "条件表达式 ID 不能为空"); + return false; + } + if (expression.id.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "条件表达式 ID 不能超过 128 个 UTF-8 字节"); + return false; + } + if (expression.kind == ConditionExpressionKind::Node) + { + if (!expression.node.has_value() || expression.wire.has_value() + || !expression.children.empty() || !expression.node->isCondition()) + { + setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); + return false; + } + *columns = 1; + *rows = 1; + return expression.node->validate(error); + } + if (expression.kind == ConditionExpressionKind::Wire) + { + if (expression.node.has_value() || !expression.wire.has_value() + || !expression.children.empty()) + { + setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); + return false; + } + if (!expression.wire->validate(error)) + { + return false; + } + *columns = expression.wire->columnSpan; + *rows = 1; + return true; + } + if (expression.kind != ConditionExpressionKind::Series + && expression.kind != ConditionExpressionKind::Parallel) + { + setError(error, "条件表达式使用了不支持的类型"); + return false; + } + if (expression.node.has_value() || expression.wire.has_value() + || expression.children.size() < 2U) + { + setError(error, "串联和并联表达式至少需要两个子表达式"); + return false; + } + if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) + { + setError(error, "单个串联或并联容器最多包含 256 个子表达式"); + return false; + } + + int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; + int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; + for (const ConditionExpression &child : expression.children) + { + if (child.kind == expression.kind) + { + setError(error, "同类型嵌套表达式必须先完成归一化"); + return false; + } + int child_columns = 0; + int child_rows = 0; + if (!validateConditionExpression( + child, depth + 1U, node_count, + &child_columns, &child_rows, error)) + { + return false; + } + if (expression.kind == ConditionExpressionKind::Series) + { + if (total_columns + > ProjectLimits::kMaximumConditionColumns - child_columns) + { + setError(error, "单个网络的条件区最多为 10 列,第 11 列固定用于输出指令"); + return false; + } + total_columns += child_columns; + total_rows = std::max(total_rows, child_rows); + } + else + { + total_columns = std::max(total_columns, child_columns); + if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) + { + setError(error, "单个网络的逻辑总行数最多为 256 行"); + return false; + } + total_rows += child_rows; + } + } + if (total_columns > ProjectLimits::kMaximumConditionColumns + || total_rows > ProjectLimits::kMaximumLogicRows) + { + setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格"); + return false; + } + *columns = total_columns; + *rows = total_rows; + return true; +} + } // namespace TimerAddress::TimerAddress(int index) @@ -444,6 +577,11 @@ bool LogicNode::validate(std::string *error) const setError(error, "逻辑节点 ID 不能为空"); return false; } + if (id.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "逻辑节点 ID 不能超过 128 个 UTF-8 字节"); + return false; + } return std::visit( [error](const auto &config) { return validateConfig(config, error); }, config); @@ -485,7 +623,7 @@ bool WireSegment::validate(std::string *error) const { if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) { - setError(error, "横线跨度必须在 1~256 列范围内"); + setError(error, "横线跨度必须在 1~10 列范围内"); return false; } return true; @@ -503,54 +641,11 @@ ConditionExpression ConditionExpression::fromWire( bool ConditionExpression::validate(std::string *error) const { - if (id.empty()) - { - setError(error, "条件表达式 ID 不能为空"); - return false; - } - if (kind == ConditionExpressionKind::Node) - { - if (!node.has_value() || wire.has_value() - || !children.empty() || !node->isCondition()) - { - setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); - return false; - } - return node->validate(error); - } - if (kind == ConditionExpressionKind::Wire) - { - if (node.has_value() || !wire.has_value() || !children.empty()) - { - setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); - return false; - } - return wire->validate(error); - } - if (kind != ConditionExpressionKind::Series - && kind != ConditionExpressionKind::Parallel) - { - setError(error, "条件表达式使用了不支持的类型"); - return false; - } - if (node.has_value() || wire.has_value() || children.size() < 2U) - { - setError(error, "串联和并联表达式至少需要两个子表达式"); - return false; - } - for (const ConditionExpression &child : children) - { - if (child.kind == kind) - { - setError(error, "同类型嵌套表达式必须先完成归一化"); - return false; - } - if (!child.validate(error)) - { - return false; - } - } - return true; + std::size_t node_count = 0U; + int columns = 0; + int rows = 0; + return validateConditionExpression( + *this, 1U, &node_count, &columns, &rows, error); } bool ConditionExpression::validateForRunning(std::string *error) const @@ -701,12 +796,12 @@ bool LadderRung::validateForRunning(std::string *error) const { return true; } - if (!condition.has_value() || !output.has_value()) + if (!output.has_value()) { - setError(error, "未完成的梯形图网络必须同时包含条件和输出节点"); + setError(error, "梯形图网络必须包含输出指令"); return false; } - if (!condition->validateForRunning(error)) + if (condition.has_value() && !condition->validateForRunning(error)) { return false; } @@ -725,6 +820,17 @@ bool LadderRung::validateStructure(std::string *error) const setError(error, "梯形图网络 ID 和名称不能为空"); return false; } + if (id.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "梯形图网络 ID 不能超过 128 个 UTF-8 字节"); + return false; + } + if (name.size() > ProjectLimits::kMaximumTextBytes + || comment.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "梯形图网络名称和注释不能超过 4096 个 UTF-8 字节"); + return false; + } std::vector node_ids; if (condition.has_value()) { @@ -780,6 +886,21 @@ bool ControlLogic::validateStructure(std::string *error) const setError(error, "控制逻辑 ID 和名称不能为空"); return false; } + if (id.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "控制逻辑 ID 不能超过 128 个 UTF-8 字节"); + return false; + } + if (name.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); + return false; + } + if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic) + { + setError(error, "单组控制逻辑最多包含 1024 个网络"); + return false; + } if (hasDuplicateId(rungs)) { setError(error, "同一控制逻辑内的网络 ID 必须唯一"); diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index 092c9f2..b1aab8f 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -1,5 +1,6 @@ #pragma once +#include "project_limits.h" #include "register_address.h" #include @@ -212,7 +213,8 @@ enum class ConditionExpressionKind struct WireSegment { static constexpr int kMinimumColumnSpan = 1; - static constexpr int kMaximumColumnSpan = 256; + static constexpr int kMaximumColumnSpan = + ProjectLimits::kMaximumConditionColumns; int columnSpan = 1; @@ -249,7 +251,7 @@ void collectConditionNodes( void collectConditionExpressionIds( const ConditionExpression &expression, std::vector *ids); -// 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧 +// 条件表达式为空时表示恒真网络,输出指令固定在最右侧 struct LadderRung { std::string id; diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index 55e737e..495d202 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -1,6 +1,7 @@ #include "hmi_model.h" #include "hmi_control_registry.h" +#include "project_limits.h" #include @@ -47,9 +48,26 @@ bool HmiControl::validate(std::string *error) const setError(error, "HMI 控件 ID 不能为空"); return false; } - if (bounds.width <= 0 || bounds.height <= 0) + if (id.size() > ProjectLimits::kMaximumIdBytes) { - setError(error, "HMI 控件的宽度和高度必须大于 0"); + setError(error, "HMI 控件 ID 不能超过 128 个 UTF-8 字节"); + return false; + } + if (text.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节"); + return false; + } + if (bounds.width <= 0 || bounds.height <= 0 + || bounds.width > ProjectLimits::kMaximumHmiControlWidth + || bounds.height > ProjectLimits::kMaximumHmiControlHeight) + { + setError(error, "HMI 控件宽度和高度必须在 1~8192 范围内"); + return false; + } + if (properties.size() > ProjectLimits::kMaximumHmiProperties) + { + setError(error, "单个 HMI 控件最多保存 64 对扩展属性"); return false; } for (const auto &property : properties) @@ -59,6 +77,16 @@ bool HmiControl::validate(std::string *error) const setError(error, "HMI 控件属性名称不能为空"); return false; } + if (property.first.size() > ProjectLimits::kMaximumPropertyKeyBytes) + { + setError(error, "HMI 控件属性名称不能超过 128 个 UTF-8 字节"); + return false; + } + if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) + { + setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节"); + return false; + } } const std::optional binding_area = hmiBindingArea(descriptor->bindingKind); @@ -92,6 +120,11 @@ bool HmiControl::validate(std::string *error) const setError(error, "页面跳转控件缺少跳转配置"); return false; } + if (pageJump->targetPageId.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "页面跳转目标 ID 不能超过 128 个 UTF-8 字节"); + return false; + } } else if (pageJump.has_value()) { @@ -153,9 +186,26 @@ bool HmiPage::validate(std::string *error) const setError(error, "HMI 页面 ID 和名称不能为空"); return false; } - if (width <= 0 || height <= 0) + if (id.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "HMI 页面 ID 不能超过 128 个 UTF-8 字节"); + return false; + } + if (name.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); + return false; + } + if (width <= 0 || height <= 0 + || width > ProjectLimits::kMaximumHmiPageWidth + || height > ProjectLimits::kMaximumHmiPageHeight) + { + setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内"); + return false; + } + if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage) { - setError(error, "HMI 页面尺寸必须大于 0"); + setError(error, "单个 HMI 页面最多包含 512 个控件"); return false; } diff --git a/app/src/domain/project_limits.h b/app/src/domain/project_limits.h new file mode 100644 index 0000000..4dc60cf --- /dev/null +++ b/app/src/domain/project_limits.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +namespace ProjectLimits { + +constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; + +constexpr std::size_t kMaximumHmiPages = 128U; +constexpr std::size_t kMaximumHmiControlsPerPage = 512U; +constexpr std::size_t kMaximumAlarmDefinitions = 512U; +constexpr std::size_t kMaximumRegisterComments = 8002U; +constexpr std::size_t kMaximumControlLogics = 128U; +constexpr std::size_t kMaximumRungsPerLogic = 1024U; + +constexpr std::size_t kMaximumExpressionNodesPerRung = 4096U; +constexpr std::size_t kMaximumExpressionDepth = 20U; +constexpr std::size_t kMaximumExpressionChildren = 256U; +constexpr int kMaximumConditionColumns = 10; +constexpr int kMaximumLadderColumns = 11; +constexpr int kMaximumLogicRows = 256; + +static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); + +constexpr std::size_t kMaximumHmiProperties = 64U; +constexpr std::size_t kMaximumIdBytes = 128U; +constexpr std::size_t kMaximumTextBytes = 4096U; +constexpr std::size_t kMaximumPropertyKeyBytes = 128U; +constexpr std::size_t kMaximumPropertyValueBytes = 4096U; + +constexpr int kMaximumHmiPageWidth = 8192; +constexpr int kMaximumHmiPageHeight = 8192; +constexpr int kMaximumHmiControlWidth = 8192; +constexpr int kMaximumHmiControlHeight = 8192; + +constexpr std::size_t kMaximumPollAddresses = 1024U; +constexpr std::size_t kMaximumPollBlocks = 64U; +constexpr int kMaximumModbusReadCount = 120; + +constexpr int kMinimumPlcServerAddress = 1; +constexpr int kMaximumPlcServerAddress = 247; +constexpr int kMinimumResponseTimeoutMs = 100; +constexpr int kMaximumResponseTimeoutMs = 30000; +constexpr int kMinimumRetries = 0; +constexpr int kMaximumRetries = 5; +constexpr int kMinimumPollIntervalMs = 50; +constexpr int kMaximumPollIntervalMs = 10000; + +constexpr std::size_t kMaximumPendingWrites = 1U; +constexpr int kMaximumOutputMessages = 1000; + +} // namespace ProjectLimits diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 53edaad..3361fab 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -1,5 +1,7 @@ #include "project_model.h" +#include "project_limits.h" + #include #include @@ -76,6 +78,11 @@ bool RegisterComment::validate(std::string *error) const setError(error, "软元件注释内容不能为空"); return false; } + if (text.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "软元件注释不能超过 4096 个 UTF-8 字节"); + return false; + } return true; } @@ -98,6 +105,42 @@ bool Project::validate(std::string *error) const setError(error, "工程 ID、名称和格式版本不能为空"); return false; } + if (metadata.id.size() > ProjectLimits::kMaximumIdBytes + || metadata.formatVersion.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "工程 ID 和格式版本不能超过 128 个 UTF-8 字节"); + return false; + } + if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) + { + setError(error, "工程名称不能超过 4096 个 UTF-8 字节"); + return false; + } + if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) + { + setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); + return false; + } + if (hmiPages.size() > ProjectLimits::kMaximumHmiPages) + { + setError(error, "单个工程最多包含 128 个 HMI 页面"); + return false; + } + if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions) + { + setError(error, "单个工程最多包含 512 条报警定义"); + return false; + } + if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) + { + setError(error, "单个工程最多包含 8002 条软元件注释"); + return false; + } + if (controlLogics.size() > ProjectLimits::kMaximumControlLogics) + { + setError(error, "单个工程最多包含 128 组控制逻辑"); + return false; + } if (containsDuplicateId(hmiPages)) { setError(error, "工程内的 HMI 页面 ID 必须唯一"); @@ -203,6 +246,57 @@ bool Project::validate(std::string *error) const return false; } } + + std::vector poll_addresses; + for (const HmiPage &page : hmiPages) + { + for (const HmiControl &control : page.controls) + { + if (control.binding.has_value()) + { + poll_addresses.push_back(*control.binding); + } + } + } + for (const AlarmDefinition &definition : alarmDefinitions) + { + poll_addresses.push_back(definition.address); + } + for (const ControlLogic &logic : controlLogics) + { + for (const LadderRung &rung : logic.rungs) + { + std::vector nodes; + if (rung.condition.has_value()) + { + collectConditionNodes(*rung.condition, &nodes); + } + if (rung.output.has_value()) + { + nodes.push_back(&*rung.output); + } + for (const LogicNode *node : nodes) + { + collectRegisterAddressesForLogicNode(node->config, &poll_addresses); + } + } + } + std::sort( + poll_addresses.begin(), poll_addresses.end(), + [](const RegisterAddress &left, const RegisterAddress &right) + { + return left.area() == right.area() + ? left.index() < right.index() + : left.area() == RegisterArea::M; + }); + poll_addresses.erase( + std::unique(poll_addresses.begin(), poll_addresses.end()), + poll_addresses.end()); + if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) + { + setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个"); + return false; + } return true; } diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 907c7b2..f2f8068 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -1,6 +1,7 @@ #include "json_project_storage.h" #include "domain/hmi_control_registry.h" +#include "domain/project_limits.h" #include #include @@ -81,7 +82,8 @@ bool readString( const char *field, const std::string &context, std::string *value, - ParseState *state) + ParseState *state, + std::size_t maximum_bytes = ProjectLimits::kMaximumTextBytes) { QJsonValue json_value; if (!readValue(object, field, context, &json_value, state)) @@ -95,6 +97,12 @@ bool readString( fieldPath(context, field) + " 必须是字符串"); } *value = toUtf8(json_value.toString()); + if (value->size() > maximum_bytes) + { + return state->fail( + ProjectStorageError::InvalidField, + fieldPath(context, field) + " 超出允许的 UTF-8 字节长度"); + } return true; } @@ -185,7 +193,8 @@ bool readArray( const char *field, const std::string &context, QJsonArray *value, - ParseState *state) + ParseState *state, + int maximum_count = std::numeric_limits::max()) { QJsonValue json_value; if (!readValue(object, field, context, &json_value, state)) @@ -199,6 +208,12 @@ bool readArray( fieldPath(context, field) + " 必须是数组"); } *value = json_value.toArray(); + if (value->size() > maximum_count) + { + return state->fail( + ProjectStorageError::InvalidField, + fieldPath(context, field) + " 的元素数量超过上限"); + } return true; } @@ -535,7 +550,9 @@ bool parseAlarmDefinition( QJsonObject address; std::string condition; int threshold = 0; - if (!readString(object, "id", context, &definition->id, state) + if (!readString( + object, "id", context, &definition->id, state, + ProjectLimits::kMaximumIdBytes) || !readObject(object, "address", context, &address, state) || !readString(object, "condition", context, &condition, state) || !readInt( @@ -604,16 +621,16 @@ bool parseBounds( object, "x", context, - std::numeric_limits::min(), - std::numeric_limits::max(), + 0, + ProjectLimits::kMaximumHmiPageWidth, &bounds->x, state) && readInt( object, "y", context, - std::numeric_limits::min(), - std::numeric_limits::max(), + 0, + ProjectLimits::kMaximumHmiPageHeight, &bounds->y, state) && readInt( @@ -621,7 +638,7 @@ bool parseBounds( "width", context, 1, - std::numeric_limits::max(), + ProjectLimits::kMaximumHmiControlWidth, &bounds->width, state) && readInt( @@ -629,7 +646,7 @@ bool parseBounds( "height", context, 1, - std::numeric_limits::max(), + ProjectLimits::kMaximumHmiControlHeight, &bounds->height, state); } @@ -651,6 +668,12 @@ bool parseProperties( std::map *properties, ParseState *state) { + if (object.size() > static_cast(ProjectLimits::kMaximumHmiProperties)) + { + return state->fail( + ProjectStorageError::InvalidField, + "单个 HMI 控件最多保存 64 对扩展属性"); + } for (auto current = object.constBegin(); current != object.constEnd(); ++current) { if (!current.value().isString()) @@ -659,7 +682,16 @@ bool parseProperties( ProjectStorageError::InvalidField, "HMI 控件属性值必须是字符串"); } - properties->emplace(toUtf8(current.key()), toUtf8(current.value().toString())); + const std::string key = toUtf8(current.key()); + const std::string value = toUtf8(current.value().toString()); + if (key.empty() || key.size() > ProjectLimits::kMaximumPropertyKeyBytes + || value.size() > ProjectLimits::kMaximumPropertyValueBytes) + { + return state->fail( + ProjectStorageError::InvalidField, + "HMI 控件属性名称最多 128 字节,属性值最多 4096 字节"); + } + properties->emplace(key, value); } return true; } @@ -718,7 +750,9 @@ bool parseHmiControl( QJsonObject bounds; QJsonObject properties; QJsonValue binding; - if (!readString(object, "id", context, &control->id, state) + if (!readString( + object, "id", context, &control->id, state, + ProjectLimits::kMaximumIdBytes) || !readString(object, "type", context, &type_text, state) || !readObject(object, "bounds", context, &bounds, state) || !readString(object, "text", context, &control->text, state) @@ -751,7 +785,9 @@ bool parseHmiControl( if (control->type == HmiControlType::PageJump) { std::string target_page_id; - if (!readString(object, "targetPageId", context, &target_page_id, state)) + if (!readString( + object, "targetPageId", context, &target_page_id, state, + ProjectLimits::kMaximumIdBytes)) { return false; } @@ -836,13 +872,19 @@ bool parseHmiPage( ParseState *state) { QJsonArray controls; - if (!readString(object, "id", context, &page->id, state) + if (!readString( + object, "id", context, &page->id, state, + ProjectLimits::kMaximumIdBytes) || !readString(object, "name", context, &page->name, state) - || !readInt(object, "width", context, 1, std::numeric_limits::max(), + || !readInt(object, "width", context, 1, + ProjectLimits::kMaximumHmiPageWidth, &page->width, state) - || !readInt(object, "height", context, 1, std::numeric_limits::max(), + || !readInt(object, "height", context, 1, + ProjectLimits::kMaximumHmiPageHeight, &page->height, state) - || !readArray(object, "controls", context, &controls, state)) + || !readArray( + object, "controls", context, &controls, state, + static_cast(ProjectLimits::kMaximumHmiControlsPerPage))) { return false; } @@ -1452,7 +1494,9 @@ bool parseLogicNode( ParseState *state) { QJsonObject config; - if (!readString(object, "id", context, &node->id, state) + if (!readString( + object, "id", context, &node->id, state, + ProjectLimits::kMaximumIdBytes) || !readBool(object, "configured", context, &node->configured, state) || !readObject(object, "config", context, &config, state) || !parseNodeConfig(config, context + ".config", &node->config, state)) @@ -1507,10 +1551,27 @@ bool parseConditionExpression( const QJsonObject &object, const std::string &context, ConditionExpression *expression, - ParseState *state) + ParseState *state, + std::size_t depth, + std::size_t *node_count) { + if (depth > ProjectLimits::kMaximumExpressionDepth) + { + return state->fail( + ProjectStorageError::InvalidField, + context + " 的嵌套深度超过 20 层"); + } + ++*node_count; + if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) + { + return state->fail( + ProjectStorageError::InvalidField, + context + " 的表达式节点总数超过 4096 个"); + } std::string kind; - if (!readString(object, "id", context, &expression->id, state) + if (!readString( + object, "id", context, &expression->id, state, + ProjectLimits::kMaximumIdBytes) || !readString(object, "kind", context, &kind, state)) { return false; @@ -1556,7 +1617,9 @@ bool parseConditionExpression( context + ".kind 必须是 node、wire、series 或 parallel"); } QJsonArray children; - if (!readArray(object, "children", context, &children, state)) + if (!readArray( + object, "children", context, &children, state, + static_cast(ProjectLimits::kMaximumExpressionChildren))) { return false; } @@ -1576,7 +1639,9 @@ bool parseConditionExpression( children.at(index).toObject(), context + ".children[" + std::to_string(index) + ']', &child, - state)) + state, + depth + 1U, + node_count)) { return false; } @@ -1611,7 +1676,9 @@ bool parseLadderRung( { QJsonValue condition; QJsonValue output; - if (!readString(object, "id", context, &rung->id, state) + if (!readString( + object, "id", context, &rung->id, state, + ProjectLimits::kMaximumIdBytes) || !readString(object, "name", context, &rung->name, state) || !readString(object, "comment", context, &rung->comment, state) || !readValue(object, "output", context, &output, state)) @@ -1635,8 +1702,10 @@ bool parseLadderRung( else { ConditionExpression parsed_condition; + std::size_t expression_node_count = 0U; if (!parseConditionExpression( - condition.toObject(), context + ".condition", &parsed_condition, state)) + condition.toObject(), context + ".condition", &parsed_condition, + state, 1U, &expression_node_count)) { return false; } @@ -1684,10 +1753,14 @@ bool parseControlLogic( ParseState *state) { QJsonArray rungs; - if (!readString(object, "id", context, &logic->id, state) + if (!readString( + object, "id", context, &logic->id, state, + ProjectLimits::kMaximumIdBytes) || !readString(object, "name", context, &logic->name, state) || !readBool(object, "enabled", context, &logic->enabled, state) - || !readArray(object, "rungs", context, &rungs, state)) + || !readArray( + object, "rungs", context, &rungs, state, + static_cast(ProjectLimits::kMaximumRungsPerLogic))) { return false; } @@ -1767,7 +1840,8 @@ bool parseProject( "formatVersion", "project", &project->metadata.formatVersion, - state)) + state, + ProjectLimits::kMaximumIdBytes)) { return false; } @@ -1779,14 +1853,26 @@ bool parseProject( "不支持的工程格式版本:" + project->metadata.formatVersion); } - if (!readString(object, "id", "project", &project->metadata.id, state) + if (!readString( + object, "id", "project", &project->metadata.id, state, + ProjectLimits::kMaximumIdBytes) || !readString(object, "name", "project", &project->metadata.name, state) - || !readArray(object, "hmiPages", "project", &pages, state) - || !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)) + || !readArray( + object, "hmiPages", "project", &pages, state, + static_cast(ProjectLimits::kMaximumHmiPages)) + || !readString( + object, "initialHmiPageId", "project", + &project->initialHmiPageId, state, + ProjectLimits::kMaximumIdBytes) + || !readArray( + object, "alarmDefinitions", "project", &alarms, state, + static_cast(ProjectLimits::kMaximumAlarmDefinitions)) + || !readArray( + object, "registerComments", "project", ®ister_comments, state, + static_cast(ProjectLimits::kMaximumRegisterComments)) + || !readArray( + object, "controlLogics", "project", &logics, state, + static_cast(ProjectLimits::kMaximumControlLogics))) { return false; } @@ -1913,6 +1999,14 @@ ProjectSaveResult JsonProjectStorage::save( const QByteArray data = QJsonDocument(serializeProject(project)).toJson( QJsonDocument::Indented); + if (static_cast(data.size()) + > ProjectLimits::kMaximumProjectFileBytes) + { + file.cancelWriting(); + return {false, + ProjectStorageError::InvalidProject, + "工程 JSON 文件不能超过 16 MiB"}; + } // 短写入也视为失败,并取消临时文件提交 if (file.write(data) != data.size()) { @@ -1937,8 +2031,19 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) ProjectStorageError::FileOpenFailed, toUtf8(file.errorString())}; } + if (file.size() < 0 + || static_cast(file.size()) + > static_cast(ProjectLimits::kMaximumProjectFileBytes)) + { + return {false, + {}, + ProjectStorageError::InvalidJson, + "工程 JSON 文件不能超过 16 MiB"}; + } - const QByteArray data = file.readAll(); + // 最多只读到上限加 1 字节,避免文件属性检查后文件变大导致无限制分配 + const QByteArray data = file.read( + static_cast(ProjectLimits::kMaximumProjectFileBytes) + 1); if (file.error() != QFileDevice::NoError) { return {false, @@ -1946,6 +2051,14 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) ProjectStorageError::FileReadFailed, toUtf8(file.errorString())}; } + if (static_cast(data.size()) + > ProjectLimits::kMaximumProjectFileBytes) + { + return {false, + {}, + ProjectStorageError::InvalidJson, + "工程 JSON 文件不能超过 16 MiB"}; + } // 顶层必须是 JSON 对象,数组或标量不能表示完整工程 QJsonParseError parse_error; diff --git a/app/src/infrastructure/plc_communication_service.cpp b/app/src/infrastructure/plc_communication_service.cpp index c84743f..5479728 100644 --- a/app/src/infrastructure/plc_communication_service.cpp +++ b/app/src/infrastructure/plc_communication_service.cpp @@ -15,7 +15,6 @@ namespace { -constexpr int kMaximumReadCount = 120; constexpr int kRecoveryProbeIntervalMs = 2000; std::string toUtf8(const QString &value) @@ -42,6 +41,67 @@ bool isReadingState(PlcConnectionState state) || state == PlcConnectionState::Recovering; } +bool normalizePollAddresses( + const std::vector &addresses, + std::vector *normalized, + std::string *error) +{ + *normalized = addresses; + if (std::any_of( + normalized->cbegin(), normalized->cend(), + [](const RegisterAddress &address) { return !address.isValid(); })) + { + *error = "PLC 轮询地址中包含无效的 M/D 地址"; + return false; + } + std::sort( + normalized->begin(), normalized->end(), + [](const RegisterAddress &left, const RegisterAddress &right) + { + return left.area() == right.area() + ? left.index() < right.index() + : left.area() == RegisterArea::M; + }); + normalized->erase( + std::unique(normalized->begin(), normalized->end()), normalized->end()); + if (normalized->size() > ProjectLimits::kMaximumPollAddresses) + { + *error = "PLC 轮询的去重 M/D 地址最多为 1024 个"; + return false; + } + return true; +} + +std::size_t pollBlockCount(const std::vector &addresses) +{ + if (addresses.empty()) + { + return 2U; + } + std::size_t blocks = 0U; + RegisterArea current_area = RegisterArea::M; + int start_address = 0; + int count = 0; + for (const RegisterAddress &address : addresses) + { + if (count == 0 + || address.area() != current_area + || address.index() > start_address + count + || count >= ProjectLimits::kMaximumModbusReadCount) + { + ++blocks; + current_area = address.area(); + start_address = address.index(); + count = 1; + } + else + { + count = address.index() - start_address + 1; + } + } + return blocks; +} + } // namespace PlcCommunicationService::PlcCommunicationService( @@ -115,10 +175,11 @@ PlcCommunicationService::~PlcCommunicationService() = default; PlcCommunicationResult PlcCommunicationService::connectDevice( const PlcSerialConfiguration &configuration) { - if (QString::fromStdString(configuration.portName).trimmed().isEmpty() - || configuration.serverAddress < 1 || configuration.serverAddress > 247) + const PlcCommunicationResult validation = + validatePlcSerialConfiguration(configuration); + if (!validation.succeeded) { - return {false, "必须填写串口端口,并将 PLC 站号设置为 1~247"}; + return validation; } if (state_ == PlcConnectionState::Disconnected && master_->state() != QModbusDevice::UnconnectedState) @@ -179,16 +240,27 @@ void PlcCommunicationService::disconnectDevice() setState(PlcConnectionState::Disconnected); } -void PlcCommunicationService::setPollAddresses( +PlcCommunicationResult PlcCommunicationService::setPollAddresses( const std::vector &addresses) { + std::vector normalized; + std::string error; + if (!normalizePollAddresses(addresses, &normalized, &error)) + { + return {false, error}; + } + if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks) + { + return {false, "PLC 轮询地址拆分后最多允许 64 个读块"}; + } if (pending_reply_ != nullptr) { - pending_poll_addresses_ = addresses; + pending_poll_addresses_ = std::move(normalized); poll_update_pending_ = true; - return; + return {true, {}}; } - applyPollAddresses(addresses); + applyPollAddresses(normalized); + return {true, {}}; } PlcConnectionState PlcCommunicationService::state() const @@ -259,7 +331,8 @@ void PlcCommunicationService::rebuildPollBlocks() || poll_blocks_.back().area != address.area() || address.index() > poll_blocks_.back().startAddress + poll_blocks_.back().count - || poll_blocks_.back().count >= kMaximumReadCount) + || poll_blocks_.back().count + >= ProjectLimits::kMaximumModbusReadCount) { poll_blocks_.push_back({address.area(), address.index(), 1}); } @@ -491,6 +564,10 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( { return {false, RegisterError::Unavailable}; } + if (pending_write_reply_ != nullptr) + { + return {false, RegisterError::WriteRejected}; + } QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); unit.setValue(0, value ? 1U : 0U); QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); @@ -499,10 +576,15 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( handleModbusError(master_->error()); return {false, RegisterError::WriteRejected}; } + pending_write_reply_ = reply; connect(reply, &QModbusReply::finished, this, [this, reply, generation = connection_generation_] { + if (pending_write_reply_ == reply) + { + pending_write_reply_ = nullptr; + } if (generation != connection_generation_) { reply->deleteLater(); @@ -524,6 +606,10 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( { return {false, RegisterError::Unavailable}; } + if (pending_write_reply_ != nullptr) + { + return {false, RegisterError::WriteRejected}; + } QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); unit.setValue(0, static_cast(value)); QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); @@ -532,10 +618,15 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( handleModbusError(master_->error()); return {false, RegisterError::WriteRejected}; } + pending_write_reply_ = reply; connect(reply, &QModbusReply::finished, this, [this, reply, generation = connection_generation_] { + if (pending_write_reply_ == reply) + { + pending_write_reply_ = nullptr; + } if (generation != connection_generation_) { reply->deleteLater(); @@ -601,6 +692,7 @@ void PlcCommunicationService::closeSerialSession() poll_timer_.stop(); recovery_timer_.stop(); pending_reply_ = nullptr; + pending_write_reply_ = nullptr; poll_update_pending_ = false; pending_poll_addresses_.clear(); if (master_->state() != QModbusDevice::UnconnectedState) diff --git a/app/src/infrastructure/plc_communication_service.h b/app/src/infrastructure/plc_communication_service.h index 1afa37d..657a406 100644 --- a/app/src/infrastructure/plc_communication_service.h +++ b/app/src/infrastructure/plc_communication_service.h @@ -30,7 +30,8 @@ public: PlcCommunicationResult connectDevice( const PlcSerialConfiguration &configuration) override; void disconnectDevice() override; - void setPollAddresses(const std::vector &addresses) override; + PlcCommunicationResult setPollAddresses( + const std::vector &addresses) override; PlcConnectionState state() const override; bool initialReadCompleted() const override; @@ -84,6 +85,7 @@ private: std::vector poll_blocks_; std::size_t next_poll_block_ = 0; QModbusReply *pending_reply_ = nullptr; + QModbusReply *pending_write_reply_ = nullptr; bool poll_update_pending_ = false; bool disconnecting_ = false; bool serial_session_opened_ = false; diff --git a/app/src/services/alarm_editor_service.cpp b/app/src/services/alarm_editor_service.cpp index a19471f..d973125 100644 --- a/app/src/services/alarm_editor_service.cpp +++ b/app/src/services/alarm_editor_service.cpp @@ -1,6 +1,7 @@ #include "alarm_editor_service.h" #include "project_service.h" +#include "domain/project_limits.h" #include @@ -36,6 +37,10 @@ const AlarmDefinition *AlarmEditorService::findDefinition( AlarmEditorResult AlarmEditorService::addDefinition( const AlarmDefinition &definition) { + if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions) + { + return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义"); + } AlarmDefinition candidate = definition; candidate.id = makeUniqueId(); std::string error; diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 1185aff..9a5a58c 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -2,6 +2,7 @@ #include "domain/hmi_control_registry.h" #include "project_service.h" +#include "domain/project_limits.h" #include #include @@ -210,6 +211,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); } const Project ¤t = project_service_.project(); + if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages) + { + return failure(HmiEditorError::InvalidPage, "单个工程最多包含 128 个 HMI 页面"); + } + if (name.size() > ProjectLimits::kMaximumTextBytes) + { + return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); + } const bool duplicate = std::any_of( current.hmiPages.cbegin(), current.hmiPages.cend(), [&name](const HmiPage &page) { return page.name == name; }); @@ -239,6 +248,10 @@ HmiEditorResult HmiEditorService::renamePage( { return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); } + if (name.size() > ProjectLimits::kMaximumTextBytes) + { + return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); + } const Project ¤t = project_service_.project(); const auto existing = std::find_if( current.hmiPages.cbegin(), current.hmiPages.cend(), @@ -376,6 +389,10 @@ HmiEditorResult HmiEditorService::addControl( { return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); } + if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage) + { + return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); + } const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); if (descriptor == nullptr) { diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 1d629d8..0608707 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -1,6 +1,7 @@ #include "logic_editor_service.h" #include "project_service.h" +#include "domain/project_limits.h" #include #include @@ -651,6 +652,14 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); } const Project ¤t = project_service_.project(); + if (current.controlLogics.size() >= ProjectLimits::kMaximumControlLogics) + { + return failure(LogicEditorError::InvalidOperation, "单个工程最多包含 128 组控制逻辑"); + } + if (name.size() > ProjectLimits::kMaximumTextBytes) + { + return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); + } const bool duplicate = std::any_of( current.controlLogics.cbegin(), current.controlLogics.cend(), [&name](const ControlLogic &logic) { return logic.name == name; }); @@ -677,6 +686,10 @@ LogicEditorResult LogicEditorService::renameLogic( { return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); } + if (name.size() > ProjectLimits::kMaximumTextBytes) + { + return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); + } const Project ¤t = project_service_.project(); const auto logic = std::find_if( current.controlLogics.cbegin(), current.controlLogics.cend(), @@ -795,6 +808,10 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } + if (logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) + { + return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); + } LadderRung rung; rung.id = makeUniqueRungId(*logic); rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); @@ -848,6 +865,12 @@ LogicEditorResult LogicEditorService::updateRungComment( { return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } + if (comment.size() > ProjectLimits::kMaximumTextBytes) + { + return failure( + LogicEditorError::InvalidOperation, + "梯形图网络注释不能超过 4096 个 UTF-8 字节"); + } if (rung->comment == comment) { return {true, LogicEditorError::None, {}, rung_id}; @@ -897,6 +920,12 @@ LogicEditorResult LogicEditorService::appendCondition( std::move(*rung->condition), std::move(leaf)); } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project.controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -941,6 +970,12 @@ LogicEditorResult LogicEditorService::appendWire( std::move(*rung->condition), std::move(leaf)); } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project_service_.editProject().controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, wire_id}; } @@ -990,6 +1025,12 @@ LogicEditorResult LogicEditorService::insertConditionAfter( std::move(original), std::move(leaf)); } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project.controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -1046,6 +1087,12 @@ LogicEditorResult LogicEditorService::insertWireAfter( std::move(original), std::move(leaf)); } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project_service_.editProject().controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, wire_id}; } @@ -1075,6 +1122,12 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition( ConditionExpression *editable = findConditionExpression( *rung->condition, wire_expression_id); *editable = ConditionExpression::fromNode(makeNode(node_id, config)); + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project_service_.editProject().controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidNode, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -1131,10 +1184,17 @@ LogicEditorResult LogicEditorService::addParallelBranch( parallel_id, series_id)) { + project.controlLogics = before.logics; return failure( LogicEditorError::InvalidOperation, "并联选择必须是一个连续的逻辑范围"); } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project.controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -1197,10 +1257,17 @@ LogicEditorResult LogicEditorService::addParallelWireBranch( parallel_id, series_id)) { + project_service_.editProject().controlLogics = before.logics; return failure( LogicEditorError::InvalidOperation, "竖线连接只能围绕同一支路中的连续逻辑范围"); } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project_service_.editProject().controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, wire_id}; } @@ -1223,6 +1290,11 @@ LogicEditorResult LogicEditorService::setOutput( ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); LogicNode node = makeNode(node_id, config); node.configured = configured; + std::string validation_error; + if (!node.validate(&validation_error)) + { + return failure(LogicEditorError::InvalidNode, validation_error); + } if (existing_rung->output.has_value() && nodesEqual(*existing_rung->output, node)) { diff --git a/app/src/services/plc_communication_gateway.h b/app/src/services/plc_communication_gateway.h index 37c0d45..c97cd18 100644 --- a/app/src/services/plc_communication_gateway.h +++ b/app/src/services/plc_communication_gateway.h @@ -1,7 +1,10 @@ #pragma once #include "domain/register_address.h" +#include "domain/project_limits.h" +#include +#include #include #include #include @@ -50,6 +53,61 @@ struct PlcCommunicationResult std::string message; }; +inline PlcCommunicationResult validatePlcSerialConfiguration( + const PlcSerialConfiguration &configuration) +{ + const bool has_port_name = std::any_of( + configuration.portName.cbegin(), configuration.portName.cend(), + [](unsigned char character) { return std::isspace(character) == 0; }); + if (!has_port_name) + { + return {false, "必须填写串口端口"}; + } + if (configuration.serverAddress < ProjectLimits::kMinimumPlcServerAddress + || configuration.serverAddress > ProjectLimits::kMaximumPlcServerAddress) + { + return {false, "PLC 站号必须在 1~247 范围内"}; + } + if (configuration.baudRate != 9600 + && configuration.baudRate != 19200 + && configuration.baudRate != 38400 + && configuration.baudRate != 57600 + && configuration.baudRate != 115200) + { + return {false, "波特率只支持 9600、19200、38400、57600 或 115200"}; + } + if (configuration.dataBits != 7 && configuration.dataBits != 8) + { + return {false, "数据位只支持 7 或 8"}; + } + if (configuration.parity != 0 + && configuration.parity != 2 + && configuration.parity != 3) + { + return {false, "校验方式只支持无校验、偶校验或奇校验"}; + } + if (configuration.stopBits != 1 && configuration.stopBits != 2) + { + return {false, "停止位只支持 1 或 2"}; + } + if (configuration.responseTimeoutMs < ProjectLimits::kMinimumResponseTimeoutMs + || configuration.responseTimeoutMs > ProjectLimits::kMaximumResponseTimeoutMs) + { + return {false, "PLC 响应超时必须在 100~30000 ms 范围内"}; + } + if (configuration.retries < ProjectLimits::kMinimumRetries + || configuration.retries > ProjectLimits::kMaximumRetries) + { + return {false, "PLC 失败重试次数必须在 0~5 范围内"}; + } + if (configuration.pollIntervalMs < ProjectLimits::kMinimumPollIntervalMs + || configuration.pollIntervalMs > ProjectLimits::kMaximumPollIntervalMs) + { + return {false, "PLC 轮询周期必须在 50~10000 ms 范围内"}; + } + return {true, {}}; +} + class PlcCommunicationGateway { public: @@ -58,7 +116,7 @@ public: virtual PlcCommunicationResult connectDevice( const PlcSerialConfiguration &configuration) = 0; virtual void disconnectDevice() = 0; - virtual void setPollAddresses( + virtual PlcCommunicationResult setPollAddresses( const std::vector &addresses) = 0; virtual PlcConnectionState state() const = 0; virtual bool initialReadCompleted() const = 0; diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index da7c169..9fe511f 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -10,6 +10,7 @@ #include "project_service.h" #include "domain/active_register_repository.h" +#include "domain/project_limits.h" #include @@ -189,7 +190,11 @@ PlcCommunicationResult RuntimeModeService::connectPlc( { plc_gateway_->disconnectDevice(); } - refreshPlcPollAddresses(); + const PlcCommunicationResult poll_result = refreshPlcPollAddresses(); + if (!poll_result.succeeded) + { + return poll_result; + } setInitialPlcReadCompleted(false); return plc_gateway_->connectDevice(configuration); } @@ -201,11 +206,11 @@ void RuntimeModeService::setMonitorAddresses( refreshPlcPollAddresses(); } -void RuntimeModeService::refreshPlcPollAddresses() +PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() { if (plc_gateway_ == nullptr) { - return; + return {false, "PLC 通信服务尚未配置"}; } std::vector addresses = monitor_addresses_; const Project &project = project_service_.project(); @@ -258,7 +263,11 @@ void RuntimeModeService::refreshPlcPollAddresses() return left.index() < right.index(); }); addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); - plc_gateway_->setPollAddresses(addresses); + if (addresses.size() > ProjectLimits::kMaximumPollAddresses) + { + return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"}; + } + return plc_gateway_->setPollAddresses(addresses); } void RuntimeModeService::disconnectPlc() diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index eed98fc..ac01a07 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -76,7 +76,7 @@ public: RegisterRepository &plc_repository); PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); void setMonitorAddresses(const std::vector &addresses); - void refreshPlcPollAddresses(); + PlcCommunicationResult refreshPlcPollAddresses(); void disconnectPlc(); PlcConnectionState plcConnectionState() const; const std::string &plcError() const; diff --git a/app/src/services/software_logic_executor.cpp b/app/src/services/software_logic_executor.cpp index 122d2b5..6f6f033 100644 --- a/app/src/services/software_logic_executor.cpp +++ b/app/src/services/software_logic_executor.cpp @@ -228,14 +228,19 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( continue; } - bool rung_value = false; - LogicScanResult result = evaluateExpression( - logic.id, - *rung.condition, - repository, - logic_trace, - true, - &rung_value); + // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 + bool rung_value = true; + LogicScanResult result = success(); + if (rung.condition.has_value()) + { + result = evaluateExpression( + logic.id, + *rung.condition, + repository, + logic_trace, + true, + &rung_value); + } if (!result.succeeded) { result.logicId = logic.id; diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index a6f5fca..5916bdf 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -2,12 +2,14 @@ #include "domain/hmi_control_registry.h" #include "domain/hmi_model.h" #include "domain/project_model.h" +#include "domain/project_limits.h" #include "domain/register_address.h" #include "domain/register_repository.h" #include "domain/runtime_state.h" #include #include +#include #include #include #include @@ -311,6 +313,102 @@ void testMultiPageAndLogicDomainRules() require(!project.validate(), "unknown HMI control types must be rejected"); } +void testQuantityBoundaries() +{ + Project project = makeValidProject(); + for (std::size_t index = 1U; index < ProjectLimits::kMaximumHmiPages; ++index) + { + project.hmiPages.push_back({ + "page-" + std::to_string(index), + "Page " + std::to_string(index), + 800, + 480, + {}}); + } + require(project.validate(), "an HMI page count of 128 must be accepted"); + project.hmiPages.push_back({"page-over", "Page over", 800, 480, {}}); + require(!project.validate(), "an HMI page count of 129 must be rejected"); + + project = makeValidProject(); + project.hmiPages.front().controls.clear(); + for (std::size_t index = 0U; + index < ProjectLimits::kMaximumHmiControlsPerPage; + ++index) + { + HmiControl label; + label.id = "label-" + std::to_string(index); + label.type = HmiControlType::Label; + label.bounds = {0, 0, 1, 1}; + label.text = "label"; + project.hmiPages.front().controls.push_back(std::move(label)); + } + require(project.validate(), "a page control count of 512 must be accepted"); + HmiControl extra_label; + extra_label.id = "label-over"; + extra_label.type = HmiControlType::Label; + extra_label.bounds = {0, 0, 1, 1}; + extra_label.text = "label"; + project.hmiPages.front().controls.push_back(std::move(extra_label)); + require(!project.validate(), "a page control count of 513 must be rejected"); + + project = makeValidProject(); + project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; + project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight; + require(project.validate(), "an HMI page size of 8192 by 8192 must be accepted"); + project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1; + require(!project.validate(), "an HMI page width of 8193 must be rejected"); + + ConditionExpression leaf = ConditionExpression::fromNode({ + "depth-node-0", + ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}}, + true}); + std::function makeNested = + [&makeNested](int depth, int *next_address) + { + if (depth == 1) + { + const int address = (*next_address)++; + return ConditionExpression::fromNode({ + "depth-node-" + std::to_string(address), + ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, + true}); + } + const int address = (*next_address)++; + ConditionExpression expression; + expression.id = "depth-expression-" + std::to_string(address); + expression.kind = depth % 2 == 0 + ? ConditionExpressionKind::Parallel + : ConditionExpressionKind::Series; + expression.children = { + makeNested(depth - 1, next_address), + ConditionExpression::fromNode({ + "depth-node-" + std::to_string(address), + ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, + true})}; + return expression; + }; + int next_address = 1; + ConditionExpression maximum_depth = makeNested( + static_cast(ProjectLimits::kMaximumExpressionDepth), &next_address); + require(maximum_depth.validate(), + "an expression depth of 20 must be accepted"); + ConditionExpression excessive_depth = makeNested( + static_cast(ProjectLimits::kMaximumExpressionDepth) + 1, + &next_address); + require(!excessive_depth.validate(), + "an expression depth of 21 must be rejected"); + + CounterNodeConfig counter_config{ + CounterAddress{0}, + CounterMode::Up, + RegisterAddress{RegisterArea::D, 0}, + WordOperand{WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, -1}, + RegisterAddress{RegisterArea::M, 0}}; + LogicNode counter{"counter-negative-preset", counter_config, true}; + require(!counter.validate(), "a negative constant counter preset must be rejected"); + (void)leaf; +} + void testLogicNodeConfigurationBoundaries() { // 触点只能绑定 M 区,数值比较只能绑定 D 区 @@ -613,6 +711,31 @@ void testLadderLogicBoundaries() "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); + ConditionExpression maximum_columns; + maximum_columns.id = "maximum-columns"; + maximum_columns.kind = ConditionExpressionKind::Series; + for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) + { + LogicNode node; + node.id = "column-" + std::to_string(column + 1); + node.config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, column}, + ContactMode::NormallyOpen}; + maximum_columns.children.push_back( + ConditionExpression::fromNode(std::move(node))); + } + require(maximum_columns.validate(), + "ten condition columns must be accepted"); + LogicNode extra_column; + extra_column.id = "column-11"; + extra_column.config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 10}, + ContactMode::NormallyOpen}; + maximum_columns.children.push_back( + ConditionExpression::fromNode(std::move(extra_column))); + require(!maximum_columns.validate(), + "an eleventh condition column must be rejected"); + ConditionExpression wired_series; wired_series.id = "wired-series"; wired_series.kind = ConditionExpressionKind::Series; @@ -641,8 +764,8 @@ void testLadderLogicBoundaries() empty_rung.output = coil; require(empty_rung.validate(), "output-only network may remain in an editable draft"); - require(!empty_rung.validateForRunning(), - "an output without conditions must block runtime validation"); + require(empty_rung.validateForRunning(), + "an output-only network must be valid as an unconditional rung"); logic.rungs.front().output = coil; logic.rungs.front().condition = root; @@ -740,6 +863,7 @@ int main() testLadderLogicBoundaries(); testModelsValidateBindingsAndIdentifiers(); testMultiPageAndLogicDomainRules(); + testQuantityBoundaries(); testRuntimeStateBoundaries(); } catch (const std::exception &error) diff --git a/app/tests/domain_tests.pro b/app/tests/domain_tests.pro index 92b73d9..24204d9 100644 --- a/app/tests/domain_tests.pro +++ b/app/tests/domain_tests.pro @@ -24,5 +24,6 @@ HEADERS += \ ../src/domain/hmi_model.h \ ../src/domain/hmi_control_registry.h \ ../src/domain/control_logic_model.h \ + ../src/domain/project_limits.h \ ../src/domain/project_model.h \ ../src/domain/runtime_state.h diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index a2d71b8..b118380 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -192,6 +192,55 @@ void testStructuredWireEditing() "wire branch deletion must participate in ladder undo history"); } +void testConditionColumnLimit() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = service.firstRungId(logic_id); + + for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) + { + require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded, + "the first ten condition columns must be editable"); + } + const LogicEditorResult overflow = service.appendCondition( + logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns)); + require(!overflow.succeeded + && overflow.error == LogicEditorError::InvalidOperation, + "the eleventh condition column must be rejected by the editor service"); + const LadderRung *rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Series + && rung->condition->children.size() + == static_cast( + ProjectLimits::kMaximumConditionColumns), + "a rejected eleventh column must leave the ten-column network unchanged"); +} + +void testUnconditionalOutputEditing() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = service.firstRungId(logic_id); + + require(service.setOutput( + logic_id, + rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, + true) + .succeeded, + "the editor must allow a coil before any condition is added"); + const LadderRung *rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && !rung->condition.has_value() + && rung->output.has_value() && rung->validateForRunning(), + "an editor-created output-only network must be runnable as unconditional"); +} + void testLogicLifecycleAndOrdering() { TestProjectStorage storage; @@ -366,6 +415,8 @@ int main() testStructuredEditingAndNormalization(); testRangeParallelInsertion(); testStructuredWireEditing(); + testConditionColumnLimit(); + testUnconditionalOutputEditing(); testLogicLifecycleAndOrdering(); testEdgeTimerNodesAndRungComments(); testHistoryAndAtomicBatchDelete(); diff --git a/app/tests/offline_simulation_service_tests.cpp b/app/tests/offline_simulation_service_tests.cpp index 4b51014..bd327ff 100644 --- a/app/tests/offline_simulation_service_tests.cpp +++ b/app/tests/offline_simulation_service_tests.cpp @@ -257,6 +257,28 @@ void testNestedSeriesParallelExpression() require(readBit(repository, 10), "A branch must independently energize output"); } +void testUnconditionalCoil() +{ + VirtualRegisterRepository repository; + SoftwareLogicExecutor executor; + LadderRung unconditional; + unconditional.id = "unconditional-rung"; + unconditional.name = "unconditional-rung"; + unconditional.output = coil("unconditional-coil", 10); + const ControlLogic program = logic({unconditional}); + + LogicTraceSnapshot trace; + require(executor.validate({program}).succeeded, + "an output-only network must pass runtime validation"); + require(executor.executeScan({program}, repository, &trace).succeeded, + "an output-only network scan must succeed"); + require(readBit(repository, 10), + "an output-only network must energize its coil as a constant-true rung"); + require(trace.rungValues.at("unconditional-rung") + && trace.nodePowerValues.at("unconditional-coil"), + "an unconditional rung must report energized power flow"); +} + void testWirePassThroughAndPowerTrace() { VirtualRegisterRepository repository; @@ -900,6 +922,7 @@ int main(int argc, char *argv[]) { testSeriesParallelContactsAndSequentialVisibility(); testNestedSeriesParallelExpression(); + testUnconditionalCoil(); testWirePassThroughAndPowerTrace(); testAllComparisons(); testSetResetAndDisabledLogic(); diff --git a/app/tests/plc_runtime_tests.cpp b/app/tests/plc_runtime_tests.cpp index 2d83b44..4d621cf 100644 --- a/app/tests/plc_runtime_tests.cpp +++ b/app/tests/plc_runtime_tests.cpp @@ -1,6 +1,8 @@ #include "domain/active_register_repository.h" +#include "domain/project_limits.h" #include "domain/project_storage.h" #include "infrastructure/plc_communication_error_classifier.h" +#include "infrastructure/plc_communication_service.h" #include "infrastructure/plc_register_repository.h" #include "services/offline_simulation_service.h" #include "services/plc_communication_gateway.h" @@ -61,9 +63,11 @@ public: } } - void setPollAddresses(const std::vector &addresses) override + PlcCommunicationResult setPollAddresses( + const std::vector &addresses) override { poll_addresses = addresses; + return {true, {}}; } PlcConnectionState state() const override { return connection_state; } @@ -311,6 +315,68 @@ void testPlcCommunicationErrorClassification() "protocol errors must remain distinct from timeouts and disconnections"); } +void testPlcConfigurationBoundaries() +{ + PlcSerialConfiguration configuration; + configuration.portName = "COM3"; + require(validatePlcSerialConfiguration(configuration).succeeded, + "the standard COM3 9600 8E1 configuration must be accepted"); + + configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress; + require(validatePlcSerialConfiguration(configuration).succeeded, + "PLC station 247 must be accepted"); + configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress + 1; + require(!validatePlcSerialConfiguration(configuration).succeeded, + "PLC station 248 must be rejected"); + + configuration.serverAddress = 1; + configuration.retries = ProjectLimits::kMaximumRetries; + require(validatePlcSerialConfiguration(configuration).succeeded, + "five retries must be accepted"); + configuration.retries = ProjectLimits::kMaximumRetries + 1; + require(!validatePlcSerialConfiguration(configuration).succeeded, + "six retries must be rejected"); + + configuration.retries = 0; + configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs - 1; + require(!validatePlcSerialConfiguration(configuration).succeeded, + "a response timeout below 100 ms must be rejected"); + configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs; + require(validatePlcSerialConfiguration(configuration).succeeded, + "a response timeout of 100 ms must be accepted"); +} + +void testPlcPollQuantityBoundaries() +{ + PlcRegisterRepository repository; + PlcCommunicationService service(repository); + + std::vector too_many_addresses; + for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index) + { + too_many_addresses.push_back({RegisterArea::M, index}); + } + too_many_addresses.push_back({RegisterArea::D, 0}); + require(!service.setPollAddresses(too_many_addresses).succeeded, + "more than 1024 poll addresses must be rejected"); + + std::vector too_many_blocks; + for (int index = 0; index < 65; ++index) + { + too_many_blocks.push_back({RegisterArea::M, index * 2}); + } + require(!service.setPollAddresses(too_many_blocks).succeeded, + "more than 64 Modbus read blocks must be rejected"); + + std::vector maximum_contiguous_block; + for (int index = 0; index < ProjectLimits::kMaximumModbusReadCount; ++index) + { + maximum_contiguous_block.push_back({RegisterArea::M, index}); + } + require(service.setPollAddresses(maximum_contiguous_block).succeeded, + "a contiguous Modbus read block of 120 values must be accepted"); +} + } // namespace int main() @@ -321,6 +387,8 @@ int main() testRuntimeRepositorySwitchingAndDisconnect(); testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); testPlcCommunicationErrorClassification(); + testPlcConfigurationBoundaries(); + testPlcPollQuantityBoundaries(); } catch (const std::exception &error) { diff --git a/app/tests/plc_runtime_tests.pro b/app/tests/plc_runtime_tests.pro index 6bf19cf..d2e771b 100644 --- a/app/tests/plc_runtime_tests.pro +++ b/app/tests/plc_runtime_tests.pro @@ -1,4 +1,4 @@ -QT += core serialbus +QT += core serialbus serialport TEMPLATE = app TARGET = plc_runtime_tests @@ -25,7 +25,8 @@ SOURCES += \ ../src/services/offline_simulation_service.cpp \ ../src/services/runtime_mode_service.cpp \ ../src/infrastructure/plc_communication_error_classifier.cpp \ - ../src/infrastructure/plc_register_repository.cpp + ../src/infrastructure/plc_register_repository.cpp \ + ../src/infrastructure/plc_communication_service.cpp HEADERS += \ ../src/domain/register_address.h \ @@ -36,6 +37,7 @@ HEADERS += \ ../src/domain/hmi_model.h \ ../src/domain/hmi_control_registry.h \ ../src/domain/control_logic_model.h \ + ../src/domain/project_limits.h \ ../src/domain/project_model.h \ ../src/domain/project_storage.h \ ../src/domain/runtime_state.h \ @@ -45,4 +47,5 @@ HEADERS += \ ../src/services/runtime_mode_service.h \ ../src/services/plc_communication_gateway.h \ ../src/infrastructure/plc_communication_error_classifier.h \ - ../src/infrastructure/plc_register_repository.h + ../src/infrastructure/plc_register_repository.h \ + ../src/infrastructure/plc_communication_service.h diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index ec9b4ec..abda382 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -2,6 +2,7 @@ #include "infrastructure/json_project_storage.h" #include "services/register_comment_service.h" #include "services/project_service.h" +#include "domain/project_limits.h" #include #include @@ -374,6 +375,39 @@ void testEmptyProjectRoundTrip() "empty project must have no alarm definitions"); } +void testUnconditionalOutputRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + JsonProjectStorage storage; + ProjectService service(storage); + require(service.createNewProject("Unconditional output").succeeded, + "unconditional output project creation must succeed"); + LadderRung rung; + rung.id = "unconditional-rung"; + rung.name = "Unconditional rung"; + LogicNode coil; + coil.id = "unconditional-coil"; + coil.config = CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; + rung.output = coil; + service.editProject().controlLogics.push_back( + ControlLogic{"logic-1", "Logic 1", {rung}, true}); + require(service.project().validateForRunning(), + "an output-only project must be runnable before saving"); + + const QString path = directory.filePath("unconditional-output.json"); + require(service.saveAs(path.toStdString()).succeeded, + "an output-only project must save successfully"); + require(service.load(path.toStdString()).succeeded, + "an output-only project must load successfully"); + const LadderRung &loaded = service.project().controlLogics.front().rungs.front(); + require(!loaded.condition.has_value() && loaded.output.has_value() + && loaded.validateForRunning(), + "JSON round trip must preserve unconditional output semantics"); +} + void testExampleProjectRoundTrip() { // 验证各层嵌套字段往返后保持不变且序列化结果稳定 @@ -694,6 +728,96 @@ void testInvalidFiles() "unsupported versions must be rejected"); } +void testQuantityFileLimits() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + JsonProjectStorage storage; + ProjectService service(storage); + require(service.createNewProject("Quantity limits").succeeded, + "the quantity-limit fixture project must be created"); + + const QString oversized_path = directory.filePath("oversized.json"); + const QByteArray oversized( + static_cast(ProjectLimits::kMaximumProjectFileBytes + 1U), ' '); + writeText(oversized_path, oversized); + const ProjectLoadResult oversized_result = + storage.load(oversized_path.toStdString()); + require(!oversized_result.succeeded + && oversized_result.error == ProjectStorageError::InvalidJson, + "a JSON file over 16 MiB must be rejected before parsing"); + + const QString valid_path = directory.filePath("fixture.json"); + require(service.saveAs(valid_path.toStdString()).succeeded, + "the quantity-limit fixture must be saved"); + QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object(); + QJsonArray too_many_pages; + for (int index = 0; + index <= static_cast(ProjectLimits::kMaximumHmiPages); + ++index) + { + too_many_pages.append(QJsonObject{}); + } + root.insert(QStringLiteral("hmiPages"), too_many_pages); + const QString too_many_pages_path = directory.filePath("too-many-pages.json"); + writeText( + too_many_pages_path, + QJsonDocument(root).toJson(QJsonDocument::Compact)); + const ProjectLoadResult too_many_pages_result = + storage.load(too_many_pages_path.toStdString()); + require(!too_many_pages_result.succeeded + && too_many_pages_result.error == ProjectStorageError::InvalidField, + "a JSON page array over 128 items must be rejected"); + + service.editProject() = makeExampleProject(); + require(service.saveAs(valid_path.toStdString()).succeeded, + "the nested-expression fixture must be saved"); + root = QJsonDocument::fromJson(readBytes(valid_path)).object(); + QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); + QJsonObject logic = logics.at(0).toObject(); + QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); + QJsonObject rung = rungs.at(0).toObject(); + QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject(); + while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node")) + { + leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject(); + } + QJsonObject nested = leaf; + for (int depth = 1; + depth <= static_cast(ProjectLimits::kMaximumExpressionDepth) + 1; + ++depth) + { + QJsonObject sibling = leaf; + sibling.insert( + QStringLiteral("id"), + QStringLiteral("json-depth-sibling-") + QString::number(depth)); + QJsonArray children; + children.append(nested); + children.append(sibling); + QJsonObject parent; + parent.insert( + QStringLiteral("id"), + QStringLiteral("json-depth-") + QString::number(depth)); + parent.insert( + QStringLiteral("kind"), + depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series")); + parent.insert(QStringLiteral("children"), children); + nested = parent; + } + rung.insert(QStringLiteral("condition"), nested); + rungs[0] = rung; + logic.insert(QStringLiteral("rungs"), rungs); + logics[0] = logic; + root.insert(QStringLiteral("controlLogics"), logics); + const QString deep_path = directory.filePath("too-deep.json"); + writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact)); + const ProjectLoadResult deep_result = storage.load(deep_path.toStdString()); + require(!deep_result.succeeded + && deep_result.error == ProjectStorageError::InvalidField, + "a condition expression deeper than 20 levels must be rejected while parsing"); +} + void testServiceStateAndSaveErrors() { // 保存路径和修改标记只在成功持久化后更新 @@ -764,9 +888,11 @@ int main() { // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 testEmptyProjectRoundTrip(); + testUnconditionalOutputRoundTrip(); testExampleProjectRoundTrip(); testRegisterCommentService(); testInvalidFiles(); + testQuantityFileLimits(); testServiceStateAndSaveErrors(); } catch (const std::exception &error) diff --git a/app/tests/project_management_tests.pro b/app/tests/project_management_tests.pro index f7b1811..70269c1 100644 --- a/app/tests/project_management_tests.pro +++ b/app/tests/project_management_tests.pro @@ -29,6 +29,7 @@ HEADERS += \ ../src/domain/hmi_model.h \ ../src/domain/hmi_control_registry.h \ ../src/domain/control_logic_model.h \ + ../src/domain/project_limits.h \ ../src/domain/project_model.h \ ../src/domain/runtime_state.h \ ../src/domain/project_storage.h \ diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index a152800..b02a687 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -46,10 +46,11 @@ public: initial_read = false; } - void setPollAddresses( + PlcCommunicationResult setPollAddresses( const std::vector &addresses) override { poll_addresses = addresses; + return {true, {}}; } PlcConnectionState state() const override { return connection_state; } bool initialReadCompleted() const override { return initial_read; }