From f1af345409e2153611015f373f02ac503192243e Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Mon, 24 Aug 2026 19:09:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=A2=AF=E5=BD=A2?= =?UTF-8?q?=E5=9B=BE=E6=98=BE=E5=BC=8F=E8=BF=9E=E6=8E=A5=E6=A8=A1=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 | 95 ++- app/src/domain/control_logic_model.h | 20 +- .../infrastructure/json_project_storage.cpp | 27 +- app/src/infrastructure/json_project_storage.h | 2 +- app/src/main.cpp | 2 + app/src/services/logic_editor_service.cpp | 671 +++++++++++++++++- app/src/services/logic_editor_service.h | 31 +- app/src/services/software_logic_executor.cpp | 11 +- app/src/ui/logic_editor_widget.cpp | 210 +++++- app/src/ui/logic_editor_widget.h | 3 + app/tests/domain_tests.cpp | 87 ++- app/tests/logic_editor_service_tests.cpp | 266 ++++++- .../offline_simulation_service_tests.cpp | 50 +- app/tests/performance_tests.cpp | 29 +- app/tests/project_management_tests.cpp | 95 ++- app/tests/runtime_mode_service_tests.cpp | 25 +- 16 files changed, 1485 insertions(+), 139 deletions(-) diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 9549c3f..e755630 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -164,7 +164,8 @@ void normalizeExpression(ConditionExpression *expression) { if (expression == nullptr || expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire) + || expression->kind == ConditionExpressionKind::Wire + || expression->kind == ConditionExpressionKind::Gap) { return; } @@ -243,6 +244,7 @@ bool validateConditionExpression( if (expression.kind == ConditionExpressionKind::Node) { if (!expression.node.has_value() || expression.wire.has_value() + || expression.gap.has_value() || !expression.children.empty() || !expression.node->isCondition()) { setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); @@ -255,6 +257,7 @@ bool validateConditionExpression( if (expression.kind == ConditionExpressionKind::Wire) { if (expression.node.has_value() || !expression.wire.has_value() + || expression.gap.has_value() || !expression.children.empty()) { setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); @@ -268,6 +271,22 @@ bool validateConditionExpression( *rows = 1; return true; } + if (expression.kind == ConditionExpressionKind::Gap) + { + if (expression.node.has_value() || expression.wire.has_value() + || !expression.gap.has_value() || !expression.children.empty()) + { + setError(error, "断路叶节点必须包含断路配置且不能包含逻辑节点或子表达式"); + return false; + } + if (!expression.gap->validate(error)) + { + return false; + } + *columns = expression.gap->columnSpan; + *rows = 1; + return true; + } if (expression.kind != ConditionExpressionKind::Series && expression.kind != ConditionExpressionKind::Parallel) { @@ -275,6 +294,7 @@ bool validateConditionExpression( return false; } if (expression.node.has_value() || expression.wire.has_value() + || expression.gap.has_value() || expression.children.size() < 2U) { setError(error, "串联和并联表达式至少需要两个子表达式"); @@ -288,6 +308,7 @@ bool validateConditionExpression( int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; + int parallel_columns = -1; for (const ConditionExpression &child : expression.children) { if (child.kind == expression.kind) @@ -316,6 +337,12 @@ bool validateConditionExpression( } else { + if (parallel_columns >= 0 && child_columns != parallel_columns) + { + setError(error, "并联各支路必须使用显式横线或断路补齐到相同列宽"); + return false; + } + parallel_columns = child_columns; total_columns = std::max(total_columns, child_columns); if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) { @@ -336,6 +363,30 @@ bool validateConditionExpression( return true; } +int conditionColumnCount(const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Node) + { + return 1; + } + if (expression.kind == ConditionExpressionKind::Wire) + { + return expression.wire->columnSpan; + } + if (expression.kind == ConditionExpressionKind::Gap) + { + return expression.gap->columnSpan; + } + int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; + for (const ConditionExpression &child : expression.children) + { + const int child_columns = conditionColumnCount(child); + columns = expression.kind == ConditionExpressionKind::Series + ? columns + child_columns : std::max(columns, child_columns); + } + return columns; +} + } // namespace bool WordOperand::validate(std::string *error) const @@ -481,6 +532,16 @@ bool WireSegment::validate(std::string *error) const return true; } +bool GapSegment::validate(std::string *error) const +{ + if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) + { + setError(error, "断路跨度必须在 1~10 列范围内"); + return false; + } + return true; +} + ConditionExpression ConditionExpression::fromWire( std::string expression_id, int column_span) { @@ -491,6 +552,16 @@ ConditionExpression ConditionExpression::fromWire( return expression; } +ConditionExpression ConditionExpression::fromGap( + std::string expression_id, int column_span) +{ + ConditionExpression expression; + expression.id = std::move(expression_id); + expression.kind = ConditionExpressionKind::Gap; + expression.gap = GapSegment{column_span}; + return expression; +} + bool ConditionExpression::validate(std::string *error) const { std::size_t node_count = 0U; @@ -519,6 +590,11 @@ bool ConditionExpression::validateForRunning(std::string *error) const { return true; } + if (kind == ConditionExpressionKind::Gap) + { + setError(error, "梯形图条件区存在未连接的空白网格"); + return false; + } for (const ConditionExpression &child : children) { if (!child.validateForRunning(error)) @@ -539,6 +615,7 @@ void normalizeConditionExpression(std::optional *expression normalizeExpression(&expression->value()); if (expression->value().kind != ConditionExpressionKind::Node && expression->value().kind != ConditionExpressionKind::Wire + && expression->value().kind != ConditionExpressionKind::Gap && expression->value().children.empty()) { expression->reset(); @@ -552,6 +629,10 @@ const LogicNode *findConditionNode( { return nullptr; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return nullptr; + } if (expression.kind == ConditionExpressionKind::Node) { return expression.node->id == node_id ? &*expression.node : nullptr; @@ -614,6 +695,10 @@ void collectConditionNodes( { return; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return; + } for (const ConditionExpression &child : expression.children) { collectConditionNodes(child, nodes); @@ -707,6 +792,14 @@ bool LadderRung::validateStructure(std::string *error) const node_ids.push_back(node->id); } } + if (output.has_value() + && (!condition.has_value() + || conditionColumnCount(*condition) + != ProjectLimits::kMaximumConditionColumns)) + { + setError(error, "带输出的梯形图网络必须用条件、横线或断路显式占满前 10 列"); + return false; + } if (output.has_value()) { if (!output->validate(error) || !output->isOutput()) diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index a996a2b..784393e 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -152,6 +152,7 @@ enum class ConditionExpressionKind { Node, // 一个实际的条件节点 Wire, // 一段横线 + Gap, // 一段明确断开的空白网格 Series, // 多个条件串联,必须全部满足 Parallel // 多个条件并联,满足任意一条即可 }; @@ -168,6 +169,18 @@ struct WireSegment bool validate(std::string *error = nullptr) const; }; +// 条件区中的持久化断路;columnSpan 表示连续空白网格列数 +struct GapSegment +{ + static constexpr int kMinimumColumnSpan = 1; + static constexpr int kMaximumColumnSpan = + ProjectLimits::kMaximumConditionColumns; + + int columnSpan = 1; // 断路占用的网格列数 + + bool validate(std::string *error = nullptr) const; +}; + // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 struct ConditionExpression { @@ -175,12 +188,15 @@ struct ConditionExpression ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 std::optional node; // kind 为 Node 时保存的节点 std::optional wire; // kind 为 Wire 时保存的横线 + std::optional gap; // kind 为 Gap 时保存的断路 std::vector children; // 串联或并联的子表达式 // 把一个逻辑节点包装成条件表达式 static ConditionExpression fromNode(LogicNode node); // 创建一段指定列数的横线表达式 static ConditionExpression fromWire(std::string id, int column_span = 1); + // 创建一段指定列数的断路表达式 + static ConditionExpression fromGap(std::string id, int column_span = 1); // 编辑态校验允许空配置节点,但必须保持树结构合法 bool validate(std::string *error = nullptr) const; // 运行态校验额外要求每个节点都已经配置完成 @@ -214,13 +230,13 @@ void collectConditionNodes( void collectConditionExpressionIds( const ConditionExpression &expression, std::vector *ids); -// 条件表达式为空时表示恒真网络,输出指令固定在最右侧 +// 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满 struct LadderRung { std::string id; // 网络唯一 ID std::string name; // 网络名称 std::string comment; // 网络注释 - std::optional condition; // 条件表达式,没有时表示恒真 + std::optional condition; // 条件区表达式;空值只用于空网络草稿 std::optional output; // 网络右侧的输出指令 // 编辑态允许没有条件或输出,便于逐步搭建网络 diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 119ac08..bb77045 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -1308,6 +1308,8 @@ QString expressionKindText(ConditionExpressionKind kind) return QStringLiteral("node"); case ConditionExpressionKind::Wire: return QStringLiteral("wire"); + case ConditionExpressionKind::Gap: + return QStringLiteral("gap"); case ConditionExpressionKind::Series: return QStringLiteral("series"); case ConditionExpressionKind::Parallel: @@ -1329,6 +1331,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression) { object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); } + else if (expression.kind == ConditionExpressionKind::Gap) + { + object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); + } else { QJsonArray children; @@ -1404,11 +1410,29 @@ bool parseConditionExpression( expression->wire = WireSegment{column_span}; return true; } + if (kind == "gap") + { + int column_span = 0; + if (!readInt( + object, + "columnSpan", + context, + GapSegment::kMinimumColumnSpan, + GapSegment::kMaximumColumnSpan, + &column_span, + state)) + { + return false; + } + expression->kind = ConditionExpressionKind::Gap; + expression->gap = GapSegment{column_span}; + return true; + } if (kind != "series" && kind != "parallel") { return state->fail( ProjectStorageError::InvalidField, - context + ".kind 必须是 node、wire、series 或 parallel"); + context + ".kind 必须是 node、wire、gap、series 或 parallel"); } QJsonArray children; if (!readArray( @@ -1875,7 +1899,6 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) { return {false, {}, state.error, state.message}; } - // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 std::string validation_error; if (!project.validate(&validation_error)) diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index 5e9158f..de53af8 100644 --- a/app/src/infrastructure/json_project_storage.h +++ b/app/src/infrastructure/json_project_storage.h @@ -2,7 +2,7 @@ #include "domain/project_storage.h" -// 使用严格 1.0 JSON 文件保存和加载工程;加载成功后才替换调用方工程 +// 严格读写当前 1.0 JSON 工程格式 class JsonProjectStorage final : public ProjectStorage { public: diff --git a/app/src/main.cpp b/app/src/main.cpp index ede1187..6376930 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -29,6 +29,8 @@ // 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 int main(int argc, char *argv[]) { + // Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏 + QApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton); // 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它 QApplication application(argc, argv); // 设置程序名称,Qt 的窗口信息和配置中会用到这个名字 diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 2cdc2b3..0b79e74 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -147,6 +147,10 @@ int expressionColumns(const ConditionExpression &expression) { return expression.wire->columnSpan; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return expression.gap->columnSpan; + } int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; for (const ConditionExpression &child : expression.children) { @@ -369,7 +373,8 @@ bool addParallelForWireCellRange( return true; } if (expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire) + || expression->kind == ConditionExpressionKind::Wire + || expression->kind == ConditionExpressionKind::Gap) { return false; } @@ -405,7 +410,8 @@ bool findWireCellLocation( WireCellLocation *location) { if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire) + || expression.kind == ConditionExpressionKind::Wire + || expression.kind == ConditionExpressionKind::Gap) { return false; } @@ -517,7 +523,8 @@ bool removeExpressionRecursive( { if (expression == nullptr || expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire) + || expression->kind == ConditionExpressionKind::Wire + || expression->kind == ConditionExpressionKind::Gap) { return false; } @@ -553,7 +560,8 @@ std::optional selectedExpressionColumns( return expressionColumns(expression); } if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire) + || expression.kind == ConditionExpressionKind::Wire + || expression.kind == ConditionExpressionKind::Gap) { return std::nullopt; } @@ -620,6 +628,52 @@ LadderRung *findEditableRung( return rung == logic->rungs.end() ? nullptr : &*rung; } +bool trimTrailingGridColumns(ConditionExpression *expression, int *columns) +{ + if (expression == nullptr || columns == nullptr || *columns <= 0) + { + return true; + } + auto trim_leaf = [columns](int *span) + { + const int removed = std::min(*span, *columns); + *span -= removed; + *columns -= removed; + }; + if (expression->kind == ConditionExpressionKind::Wire) + { + trim_leaf(&expression->wire->columnSpan); + return *columns == 0 && expression->wire->columnSpan > 0; + } + if (expression->kind == ConditionExpressionKind::Gap) + { + trim_leaf(&expression->gap->columnSpan); + return *columns == 0 && expression->gap->columnSpan > 0; + } + if (expression->kind != ConditionExpressionKind::Series) + { + return false; + } + while (*columns > 0 && !expression->children.empty()) + { + ConditionExpression &last = expression->children.back(); + int *span = last.kind == ConditionExpressionKind::Wire + ? &last.wire->columnSpan + : last.kind == ConditionExpressionKind::Gap + ? &last.gap->columnSpan : nullptr; + if (span == nullptr) + { + return false; + } + trim_leaf(span); + if (*span == 0) + { + expression->children.pop_back(); + } + } + return *columns == 0 && !expression->children.empty(); +} + } // namespace LogicEditorService::LogicEditorService(ProjectService &project_service) @@ -705,6 +759,7 @@ bool LogicEditorService::expressionsEqual( if (left.id != right.id || left.kind != right.kind || left.node.has_value() != right.node.has_value() || left.wire.has_value() != right.wire.has_value() + || left.gap.has_value() != right.gap.has_value() || left.children.size() != right.children.size()) { return false; @@ -718,6 +773,11 @@ bool LogicEditorService::expressionsEqual( { return false; } + if (left.gap.has_value() + && left.gap->columnSpan != right.gap->columnSpan) + { + return false; + } for (std::size_t index = 0; index < left.children.size(); ++index) { if (!expressionsEqual(left.children[index], right.children[index])) @@ -1194,7 +1254,69 @@ LogicEditorResult LogicEditorService::appendCondition( std::nullopt}); } LadderRung *rung = findEditableRung(project, logic_id, target_rung_id); - if (!rung->condition.has_value()) + if (rung->output.has_value() && rung->condition.has_value()) + { + ConditionExpression *span = &*rung->condition; + std::vector *siblings = nullptr; + std::size_t span_index = 0U; + if (span->kind == ConditionExpressionKind::Series) + { + siblings = &span->children; + span_index = siblings->size(); + while (span_index > 0U) + { + const ConditionExpressionKind kind = + siblings->at(span_index - 1U).kind; + if (kind != ConditionExpressionKind::Wire + && kind != ConditionExpressionKind::Gap) + { + break; + } + --span_index; + } + span = span_index < siblings->size() + ? &siblings->at(span_index) : nullptr; + } + if (span == nullptr + || (span->kind != ConditionExpressionKind::Wire + && span->kind != ConditionExpressionKind::Gap)) + { + rollbackEdit(std::move(before), modified_before); + return failure( + LogicEditorError::InvalidOperation, + "输出网络的 10 列条件区已经没有可替换的横线或断路"); + } + const bool wire_span = span->kind == ConditionExpressionKind::Wire; + const int span_columns = wire_span + ? span->wire->columnSpan : span->gap->columnSpan; + const std::string span_id = span->id; + if (span_columns == 1) + { + *span = std::move(leaf); + } + else if (siblings != nullptr) + { + *span = std::move(leaf); + siblings->insert( + siblings->begin() + static_cast(span_index + 1U), + wire_span + ? ConditionExpression::fromWire(span_id, span_columns - 1) + : ConditionExpression::fromGap(span_id, span_columns - 1)); + } + else + { + const std::string container_id = makeUniqueExpressionId(*editable_logic); + ConditionExpression remaining = wire_span + ? ConditionExpression::fromWire(span_id, span_columns - 1) + : ConditionExpression::fromGap(span_id, span_columns - 1); + *span = makeContainer( + container_id, + ConditionExpressionKind::Series, + std::move(leaf), + std::move(remaining)); + } + } + else if (!rung->condition.has_value()) { rung->condition = std::move(leaf); } @@ -1205,14 +1327,15 @@ LogicEditorResult LogicEditorService::appendCondition( else { rung->condition = makeContainer( - makeUniqueExpressionId(*logic), + makeUniqueExpressionId(*editable_logic), ConditionExpressionKind::Series, std::move(*rung->condition), std::move(leaf)); } std::string validation_error; // 编辑服务修改后立即做整条网络校验;失败就恢复包括脏标记在内的完整快照 - if (!rung->validate(&validation_error)) + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, validation_error); @@ -1259,6 +1382,9 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung(project, logic_id, rung_id); if (leading_wire_columns > 0) @@ -1308,7 +1434,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( std::string validation_error; // 原子提交要求候选表达式整体通过校验,否则撤销本次所有局部拼接 - if (!rung->validate(&validation_error)) + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, validation_error); @@ -1369,8 +1496,12 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( makeNode(node_id, config)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); + project, logic_id, rung_id); ConditionExpression *editable_branch = findConditionExpression( *rung->condition, branch_expression_id); @@ -1399,7 +1530,8 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( } std::string validation_error; - if (!rung->validate(&validation_error)) + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, validation_error); @@ -1514,6 +1646,9 @@ LogicEditorResult LogicEditorService::insertConditionAfter( HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung(project, logic_id, rung_id); ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); ConditionExpression *parent = findParentExpression(*rung->condition, target_node_id); @@ -1527,16 +1662,20 @@ LogicEditorResult LogicEditorService::insertConditionAfter( }); const auto insertion_position = target_iterator + 1; if (insertion_position != parent->children.end() - && insertion_position->kind == ConditionExpressionKind::Wire) - { - // 横线代表已占用的网格位置,连续插入时先消费一格而不是扩展网络 - if (insertion_position->wire->columnSpan == 1) + && (insertion_position->kind == ConditionExpressionKind::Wire + || insertion_position->kind == ConditionExpressionKind::Gap)) + { + int &span = insertion_position->kind == ConditionExpressionKind::Wire + ? insertion_position->wire->columnSpan + : insertion_position->gap->columnSpan; + // 连续插入优先消费后方显式横线或断路的一格 + if (span == 1) { *insertion_position = std::move(leaf); } else { - --insertion_position->wire->columnSpan; + --span; parent->children.insert(insertion_position, std::move(leaf)); } } @@ -1549,13 +1688,14 @@ LogicEditorResult LogicEditorService::insertConditionAfter( { ConditionExpression original = std::move(*target); *target = makeContainer( - makeUniqueExpressionId(*logic), + makeUniqueExpressionId(*editable_logic), ConditionExpressionKind::Series, std::move(original), std::move(leaf)); } std::string validation_error; - if (!rung->validate(&validation_error)) + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, validation_error); @@ -1592,8 +1732,12 @@ LogicEditorResult LogicEditorService::insertWireAfter( ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); + project, logic_id, rung_id); ConditionExpression *editable_target = findConditionExpression( *rung->condition, target_expression_id); ConditionExpression *parent = findParentExpression( @@ -1612,13 +1756,14 @@ LogicEditorResult LogicEditorService::insertWireAfter( { ConditionExpression original = std::move(*editable_target); *editable_target = makeContainer( - makeUniqueExpressionId(*logic), + makeUniqueExpressionId(*editable_logic), ConditionExpressionKind::Series, std::move(original), std::move(leaf)); } std::string validation_error; - if (!rung->validate(&validation_error)) + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, validation_error); @@ -1645,6 +1790,11 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition( { return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点"); } + if (wire->wire->columnSpan > 1) + { + return replaceWireColumnWithCondition( + logic_id, rung_id, wire_expression_id, 0, config); + } const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); @@ -1715,12 +1865,85 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung(project, logic_id, rung_id); ConditionExpression *editable = findConditionExpression( *rung->condition, wire_expression_id); *editable = std::move(replacement); normalizeConditionExpression(&rung->condition); std::string validation_error; + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidNode, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; +} + +LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( + const std::string &logic_id, + const std::string &rung_id, + const std::string &gap_expression_id, + int column_offset, + const LogicNodeConfig &config) +{ + const ControlLogic *logic = findLogic(logic_id); + const ConditionExpression *gap = findExpression( + logic_id, rung_id, gap_expression_id); + if (logic == nullptr || gap == nullptr + || gap->kind != ConditionExpressionKind::Gap) + { + return failure(LogicEditorError::ExpressionNotFound, "未找到要填充的断路网格"); + } + if (!isConditionConfig(config)) + { + return failure(LogicEditorError::InvalidNode, "断路网格只能填入条件节点"); + } + if (column_offset < 0 || column_offset >= gap->gap->columnSpan) + { + return failure(LogicEditorError::InvalidOperation, "断路网格偏移超出有效范围"); + } + + const int leading_columns = column_offset; + const int trailing_columns = gap->gap->columnSpan - column_offset - 1; + const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); + ConditionExpression replacement; + if (leading_columns == 0 && trailing_columns == 0) + { + replacement = ConditionExpression::fromNode(makeNode(node_id, config)); + } + else + { + replacement.id = makeUniqueExpressionId(*logic); + replacement.kind = ConditionExpressionKind::Series; + if (leading_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromGap( + gap_expression_id, leading_columns)); + } + replacement.children.push_back( + ConditionExpression::fromNode(makeNode(node_id, config))); + if (trailing_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromGap( + leading_columns > 0 ? makeUniqueGapId(*logic) : gap_expression_id, + trailing_columns)); + } + } + + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + LadderRung *rung = findEditableRung( + project_service_.editProject(), logic_id, rung_id); + ConditionExpression *editable = findConditionExpression( + *rung->condition, gap_expression_id); + *editable = std::move(replacement); + normalizeConditionExpression(&rung->condition); + std::string validation_error; if (!rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); @@ -1730,6 +1953,69 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( return {true, LogicEditorError::None, {}, node_id}; } +LogicEditorResult LogicEditorService::replaceGapColumnWithWire( + const std::string &logic_id, + const std::string &rung_id, + const std::string &gap_expression_id, + int column_offset) +{ + const ControlLogic *logic = findLogic(logic_id); + const ConditionExpression *gap = findExpression( + logic_id, rung_id, gap_expression_id); + if (logic == nullptr || gap == nullptr + || gap->kind != ConditionExpressionKind::Gap) + { + return failure(LogicEditorError::ExpressionNotFound, "未找到要修复的断路网格"); + } + if (column_offset < 0 || column_offset >= gap->gap->columnSpan) + { + return failure(LogicEditorError::InvalidOperation, "断路网格偏移超出有效范围"); + } + const int leading_columns = column_offset; + const int trailing_columns = gap->gap->columnSpan - column_offset - 1; + const std::string wire_id = makeUniqueWireId(*logic); + ConditionExpression replacement; + if (leading_columns == 0 && trailing_columns == 0) + { + replacement = ConditionExpression::fromWire(wire_id, 1); + } + else + { + replacement.id = makeUniqueExpressionId(*logic); + replacement.kind = ConditionExpressionKind::Series; + if (leading_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromGap( + gap_expression_id, leading_columns)); + } + replacement.children.push_back( + ConditionExpression::fromWire(wire_id, 1)); + if (trailing_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromGap( + leading_columns > 0 ? makeUniqueGapId(*logic) : gap_expression_id, + trailing_columns)); + } + } + + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + LadderRung *rung = findEditableRung( + project_service_.editProject(), logic_id, rung_id); + ConditionExpression *editable = findConditionExpression( + *rung->condition, gap_expression_id); + *editable = std::move(replacement); + normalizeConditionExpression(&rung->condition); + std::string validation_error; + if (!rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, wire_id}; +} + LogicEditorResult LogicEditorService::addParallelBranch( const std::string &logic_id, const std::string &rung_id, @@ -1757,8 +2043,13 @@ LogicEditorResult LogicEditorService::addParallelBranch( LogicEditorError::InvalidOperation, "并联选择中存在重复节点"); } - const NodeIdSet available_ids = conditionLeafIds(*existing_rung->condition); - if (!isSubset(selected_ids, available_ids)) + const bool all_conditions = std::all_of( + selected_ids.cbegin(), selected_ids.cend(), + [&existing_rung](const std::string &node_id) + { + return findConditionNode(*existing_rung->condition, node_id) != nullptr; + }); + if (!all_conditions) { return failure( LogicEditorError::NodeNotFound, @@ -1775,6 +2066,9 @@ LogicEditorResult LogicEditorService::addParallelBranch( HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung(project, logic_id, rung_id); if (!addParallelForSelection( &*rung->condition, @@ -1789,7 +2083,8 @@ LogicEditorResult LogicEditorService::addParallelBranch( "并联选择必须是一个连续的逻辑范围"); } std::string validation_error; - if (!rung->validate(&validation_error)) + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) { rollbackEdit(std::move(before), modified_before); return failure(LogicEditorError::InvalidOperation, validation_error); @@ -2197,6 +2492,7 @@ LogicEditorResult LogicEditorService::setOutput( return {true, LogicEditorError::None, {}, node_id}; } HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); auto editable_logic = std::find_if( project.controlLogics.begin(), project.controlLogics.end(), @@ -2210,7 +2506,16 @@ LogicEditorResult LogicEditorService::setOutput( std::nullopt, std::nullopt}); } - findEditableRung(project, logic_id, target_rung_id)->output = std::move(node); + LadderRung *editable_rung = findEditableRung( + project, logic_id, target_rung_id); + editable_rung->output = std::move(node); + if (!repairExplicitLayout(*editable_logic, *editable_rung)) + { + rollbackEdit(std::move(before), modified_before); + return failure( + LogicEditorError::InvalidOperation, + "输出前的显式横线无法在 10 列条件区内完成布局"); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -2353,6 +2658,10 @@ LogicEditorResult LogicEditorService::pasteConditionNodes( current = replaceWireColumnWithCondition( logic_id, rung_id, target.expressionId, target.column, source.config); break; + case LogicConditionPasteTargetKind::ReplaceGapColumn: + current = replaceGapColumnWithCondition( + logic_id, rung_id, target.expressionId, target.column, source.config); + break; case LogicConditionPasteTargetKind::Append: default: current = appendCondition(logic_id, rung_id, source.config); @@ -2530,6 +2839,11 @@ LogicEditorResult LogicEditorService::pasteRung( clone.id = unique_wire_id(); clone.wire = source_expression.wire; } + else if (source_expression.kind == ConditionExpressionKind::Gap) + { + clone.id = unique_expression_id(); + clone.gap = source_expression.gap; + } else { clone.id = unique_expression_id(); @@ -2615,18 +2929,16 @@ LogicEditorResult LogicEditorService::removeNodes( { for (const std::string &node_id : node_ids) { - if (rung.condition->kind == ConditionExpressionKind::Node - && rung.condition->id == node_id) + ConditionExpression *expression = findConditionExpression( + *rung.condition, node_id); + if (expression != nullptr + && expression->kind == ConditionExpressionKind::Node) { - rung.condition.reset(); - break; + *expression = ConditionExpression::fromGap( + makeUniqueGapId(*editable_logic), 1); } - removeExpressionRecursive(&*rung.condition, node_id); - } - if (rung.condition.has_value()) - { - normalizeConditionExpression(&rung.condition); } + normalizeConditionExpression(&rung.condition); } } for (const LadderRung &rung : editable_logic->rungs) @@ -2652,7 +2964,11 @@ LogicEditorResult LogicEditorService::removeExpression( return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); } HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung(project, logic_id, rung_id); if (rung->condition->id == expression_id) { @@ -2663,6 +2979,13 @@ LogicEditorResult LogicEditorService::removeExpression( removeExpressionRecursive(&*rung->condition, expression_id); normalizeConditionExpression(&rung->condition); } + std::string validation_error; + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, expression_id}; } @@ -2699,8 +3022,13 @@ LogicEditorResult LogicEditorService::removeExpressions( } HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); + project, logic_id, rung_id); for (const std::string &expression_id : expression_ids) { if (!rung->condition.has_value()) @@ -2715,10 +3043,164 @@ LogicEditorResult LogicEditorService::removeExpressions( removeExpressionRecursive(&*rung->condition, expression_id); } normalizeConditionExpression(&rung->condition); + std::string validation_error; + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, expression_ids.front()}; } +LogicEditorResult LogicEditorService::disconnectWireCells( + const std::string &logic_id, + const std::string &rung_id, + const std::vector> &wire_cells) +{ + const ControlLogic *logic = findLogic(logic_id); + const LadderRung *existing_rung = findRung(logic_id, rung_id); + if (logic == nullptr || existing_rung == nullptr + || !existing_rung->condition.has_value()) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); + } + if (wire_cells.empty()) + { + return failure(LogicEditorError::InvalidOperation, "请先选择要断开的横线网格"); + } + std::vector> cells = wire_cells; + std::sort( + cells.begin(), cells.end(), + [](const auto &left, const auto &right) + { + return left.first < right.first + || (left.first == right.first && left.second > right.second); + }); + if (std::adjacent_find(cells.cbegin(), cells.cend()) != cells.cend()) + { + return failure(LogicEditorError::InvalidOperation, "横线断开列表中存在重复网格"); + } + for (const auto &cell : cells) + { + const ConditionExpression *wire = findExpression( + logic_id, rung_id, cell.first); + if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire + || cell.second < 0 || cell.second >= wire->wire->columnSpan) + { + return failure(LogicEditorError::InvalidOperation, "横线网格偏移超出有效范围"); + } + } + + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + LadderRung *rung = findEditableRung(project, logic_id, rung_id); + for (const auto &cell : cells) + { + ConditionExpression *wire = findConditionExpression( + *rung->condition, cell.first); + if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire + || cell.second < 0 || cell.second >= wire->wire->columnSpan) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, "横线网格断开位置已经失效"); + } + const int leading_columns = cell.second; + const int trailing_columns = wire->wire->columnSpan - cell.second - 1; + if (leading_columns == 0 && trailing_columns == 0) + { + *wire = ConditionExpression::fromGap( + makeUniqueGapId(*editable_logic), 1); + continue; + } + const std::string original_wire_id = wire->id; + ConditionExpression replacement; + replacement.id = makeUniqueExpressionId(*editable_logic); + replacement.kind = ConditionExpressionKind::Series; + if (leading_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromWire( + original_wire_id, leading_columns)); + } + replacement.children.push_back(ConditionExpression::fromGap( + makeUniqueGapId(*editable_logic), 1)); + if (trailing_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromWire( + leading_columns > 0 + ? makeUniqueWireId(*editable_logic) : original_wire_id, + trailing_columns)); + } + *wire = std::move(replacement); + normalizeConditionExpression(&rung->condition); + } + std::string validation_error; + if (!rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, cells.front().first}; +} + +LogicEditorResult LogicEditorService::disconnectWires( + const std::string &logic_id, + const std::string &rung_id, + const std::vector &wire_ids) +{ + const ControlLogic *logic = findLogic(logic_id); + const LadderRung *existing_rung = findRung(logic_id, rung_id); + if (logic == nullptr || existing_rung == nullptr + || !existing_rung->condition.has_value()) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); + } + if (wire_ids.empty()) + { + return failure(LogicEditorError::InvalidOperation, "请先选择要断开的横线"); + } + NodeIdSet unique_ids; + for (const std::string &wire_id : wire_ids) + { + const ConditionExpression *wire = findExpression(logic_id, rung_id, wire_id); + if (!unique_ids.insert(wire_id).second + || wire == nullptr || wire->kind != ConditionExpressionKind::Wire) + { + return failure(LogicEditorError::ExpressionNotFound, "断开列表中包含无效横线"); + } + } + + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + LadderRung *rung = findEditableRung(project, logic_id, rung_id); + for (const std::string &wire_id : wire_ids) + { + ConditionExpression *wire = findConditionExpression( + *rung->condition, wire_id); + *wire = ConditionExpression::fromGap( + makeUniqueGapId(*editable_logic), wire->wire->columnSpan); + } + normalizeConditionExpression(&rung->condition); + std::string validation_error; + if (!rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, wire_ids.front()}; +} + bool LogicEditorService::canUndo() const { return history_.canUndo(); @@ -2843,6 +3325,25 @@ std::string LogicEditorService::makeUniqueWireId(const ControlLogic &logic) } } +std::string LogicEditorService::makeUniqueGapId(const ControlLogic &logic) +{ + for (std::size_t index = 1;; ++index) + { + const std::string candidate = "gap-" + std::to_string(index); + const bool found = std::any_of( + logic.rungs.cbegin(), logic.rungs.cend(), + [&candidate](const LadderRung &rung) + { + return rung.condition.has_value() + && findConditionExpression(*rung.condition, candidate) != nullptr; + }); + if (!found) + { + return candidate; + } + } +} + std::string LogicEditorService::makeUniqueExpressionId(const ControlLogic &logic) { for (std::size_t index = 1;; ++index) @@ -2875,6 +3376,108 @@ std::string LogicEditorService::makeUniqueRungId(const ControlLogic &logic) } } +bool LogicEditorService::repairExplicitLayout( + ControlLogic &logic, LadderRung &rung, std::string *error) +{ + const auto append_wire = [&logic](ConditionExpression *expression, int columns) + { + if (expression == nullptr || columns <= 0) + { + return; + } + ConditionExpression wire = ConditionExpression::fromWire( + makeUniqueWireId(logic), columns); + if (expression->kind == ConditionExpressionKind::Series) + { + expression->children.push_back(std::move(wire)); + return; + } + const std::string container_id = makeUniqueExpressionId(logic); + ConditionExpression original = std::move(*expression); + *expression = makeContainer( + container_id, + ConditionExpressionKind::Series, + std::move(original), + std::move(wire)); + }; + + std::function materialize_parallel_wires; + materialize_parallel_wires = + [&](ConditionExpression *expression) + { + if (expression == nullptr + || expression->kind == ConditionExpressionKind::Node + || expression->kind == ConditionExpressionKind::Wire + || expression->kind == ConditionExpressionKind::Gap) + { + return; + } + for (ConditionExpression &child : expression->children) + { + materialize_parallel_wires(&child); + } + if (expression->kind != ConditionExpressionKind::Parallel) + { + return; + } + int maximum_columns = 0; + for (const ConditionExpression &child : expression->children) + { + maximum_columns = std::max( + maximum_columns, expressionColumns(child)); + } + for (ConditionExpression &child : expression->children) + { + append_wire( + &child, maximum_columns - expressionColumns(child)); + } + }; + + if (rung.condition.has_value()) + { + materialize_parallel_wires(&*rung.condition); + } + if (!rung.output.has_value()) + { + normalizeConditionExpression(&rung.condition); + return true; + } + if (!rung.condition.has_value()) + { + rung.condition = ConditionExpression::fromWire( + makeUniqueWireId(logic), ProjectLimits::kMaximumConditionColumns); + return true; + } + + int columns = expressionColumns(*rung.condition); + if (columns > ProjectLimits::kMaximumConditionColumns) + { + int overflow = columns - ProjectLimits::kMaximumConditionColumns; + if (!trimTrailingGridColumns(&*rung.condition, &overflow)) + { + if (error != nullptr) + { + *error = "显式梯形图布局无法在 10 列条件区内完成"; + } + return false; + } + normalizeConditionExpression(&rung.condition); + columns = expressionColumns(*rung.condition); + } + append_wire( + &*rung.condition, + ProjectLimits::kMaximumConditionColumns - columns); + normalizeConditionExpression(&rung.condition); + const bool valid_width = rung.condition.has_value() + && expressionColumns(*rung.condition) + == ProjectLimits::kMaximumConditionColumns; + if (!valid_width && error != nullptr) + { + *error = "显式梯形图布局无法在 10 列条件区内完成"; + } + return valid_width; +} + LogicEditorResult LogicEditorService::failure( LogicEditorError error, const std::string &message) { diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index 38b1249..d914f87 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -40,7 +40,8 @@ enum class LogicConditionPasteTargetKind BranchEmptyColumn, AfterNode, ReplaceWire, - ReplaceWireColumn + ReplaceWireColumn, + ReplaceGapColumn }; struct LogicConditionPasteTarget @@ -168,6 +169,19 @@ public: const std::string &wire_expression_id, int column_offset, const LogicNodeConfig &config); + /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ + LogicEditorResult replaceGapColumnWithCondition( + const std::string &logic_id, + const std::string &rung_id, + const std::string &gap_expression_id, + int column_offset, + const LogicNodeConfig &config); + /** @brief 用一格横线修复断路表达式中的指定网格 */ + LogicEditorResult replaceGapColumnWithWire( + const std::string &logic_id, + const std::string &rung_id, + const std::string &gap_expression_id, + int column_offset); /** * @brief 将选中的条件节点建立为并联分支 * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID @@ -255,6 +269,16 @@ public: const std::string &logic_id, const std::string &rung_id, const std::vector &expression_ids); + /** @brief 把选中的横线网格替换为断路,保留网络列位置 */ + LogicEditorResult disconnectWireCells( + const std::string &logic_id, + const std::string &rung_id, + const std::vector> &wire_cells); + /** @brief 把整段横线替换为等宽断路 */ + LogicEditorResult disconnectWires( + const std::string &logic_id, + const std::string &rung_id, + const std::vector &wire_ids); // 当前编辑会话的撤销/重做 /** @brief 判断是否存在可撤销的梯形图编辑操作 */ @@ -306,10 +330,15 @@ private: const ControlLogic &logic, const std::string &prefix); // 生成唯一横线表达式 ID static std::string makeUniqueWireId(const ControlLogic &logic); + // 生成控制逻辑内唯一的断路表达式 ID + static std::string makeUniqueGapId(const ControlLogic &logic); // 生成唯一容器表达式 ID static std::string makeUniqueExpressionId(const ControlLogic &logic); // 生成唯一网络 ID static std::string makeUniqueRungId(const ControlLogic &logic); + // 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局 + static bool repairExplicitLayout( + ControlLogic &logic, LadderRung &rung, std::string *error = nullptr); // 创建统一的失败结果 static LogicEditorResult failure( LogicEditorError error, const std::string &message); diff --git a/app/src/services/software_logic_executor.cpp b/app/src/services/software_logic_executor.cpp index 4b015ab..bf66484 100644 --- a/app/src/services/software_logic_executor.cpp +++ b/app/src/services/software_logic_executor.cpp @@ -208,7 +208,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( continue; } - // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 + // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 bool rung_value = true; LogicScanResult result = success(); if (rung.condition.has_value()) @@ -302,6 +302,15 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( } return success(); } + if (expression.kind == ConditionExpressionKind::Gap) + { + return failure( + LogicScanError::InvalidLogic, + "梯形图条件区存在未连接的空白网格", + logic_id, + {}, + expression.id); + } if (expression.kind == ConditionExpressionKind::Node) { LogicScanResult result = evaluateCondition( diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 1215d10..9f21769 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -150,6 +150,10 @@ ExpressionMetrics measureExpression(const ConditionExpression &expression) { return {expression.wire->columnSpan, 1}; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return {expression.gap->columnSpan, 1}; + } ExpressionMetrics metrics{0, 0}; if (expression.kind == ConditionExpressionKind::Series) @@ -523,6 +527,54 @@ private: int column_offset_ = 0; }; +class LogicEditorWidget::GapCellItem final : public QGraphicsItem +{ +public: + GapCellItem( + const std::string &expression_id, + const std::string &rung_id, + int column_offset, + const QPointF ¢er) + : expression_id_(expression_id), + rung_id_(rung_id), + column_offset_(column_offset) + { + setPos(center); + setFlag(ItemIsSelectable, true); + setZValue(2.2); + setToolTip(LogicEditorWidget::tr("断路网格:第 %1 格,可补横线或插入触点") + .arg(column_offset + 1)); + } + + QRectF boundingRect() const override + { + return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; + } + + void paint( + QPainter *painter, + const QStyleOptionGraphicsItem *option, + QWidget *) override + { + if ((option->state & QStyle::State_Selected) == 0) + { + return; + } + painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor); + painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); + painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); + } + + const std::string &expressionId() const { return expression_id_; } + const std::string &rungId() const { return rung_id_; } + int columnOffset() const { return column_offset_; } + +private: + std::string expression_id_; + std::string rung_id_; + int column_offset_ = 0; +}; + class LogicEditorWidget::EmptySlotItem final : public QGraphicsItem { public: @@ -790,6 +842,28 @@ RenderResult renderExpression( QPointF(top_left.x(), center.y()), QPointF(top_left.x() + width, center.y())}; } + if (expression.kind == ConditionExpressionKind::Gap) + { + const qreal width = static_cast(expression.gap->columnSpan) + * kCellWidth; + const QPointF center( + top_left.x() + width / 2.0, + top_left.y() + kCellHeight / 2.0); + for (int column = 0; column < expression.gap->columnSpan; ++column) + { + scene.addItem(new LogicEditorWidget::GapCellItem( + expression.id, + rung_id, + column, + QPointF( + top_left.x() + (static_cast(column) + 0.5) + * kCellWidth, + center.y()))); + } + return { + QPointF(top_left.x(), center.y()), + QPointF(top_left.x() + width, center.y())}; + } if (expression.kind == ConditionExpressionKind::Node) { const QPointF center( @@ -917,30 +991,6 @@ RenderResult renderExpression( scene.addLine( QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), ladderPen(branch_active)); - if (branches[index].output.x() < right_join - 0.1) - { - // 分支宽度不足时补出到右侧汇合点的结构连接,不写入隐式 Wire - scene.addLine( - QLineF( - branches[index].output, - QPointF(right_join, branches[index].output.y())), - ladderPen(branch_active)); - } - const int branch_columns = measureExpression( - expression.children[index]).columns; - for (int column = branch_columns; column < metrics.columns; ++column) - { - scene.addItem(new LogicEditorWidget::EmptySlotItem( - rung_id, - column, - QPointF( - top_left.x() - + (static_cast(column) + 0.5) * kCellWidth, - branches[index].output.y()), - false, - expression.children[index].id, - branch_active)); - } } return {QPointF(left_join, top_y), QPointF(right_join, top_y)}; } @@ -1307,6 +1357,19 @@ std::vector> LogicEditorWidget::selectedWireCells() return cells; } +std::vector> LogicEditorWidget::selectedGapCells() const +{ + std::vector> cells; + for (QGraphicsItem *item : scene_->selectedItems()) + { + if (const GapCellItem *cell = dynamic_cast(item)) + { + cells.emplace_back(cell->expressionId(), cell->columnOffset()); + } + } + return cells; +} + std::vector LogicEditorWidget::selectedBranchIds() const { std::vector ids; @@ -1355,6 +1418,15 @@ std::string LogicEditorWidget::selectedRungId() const } rung_id = cell->rungId(); } + else if (const GapCellItem *cell = + dynamic_cast(item)) + { + if (!rung_id.empty() && rung_id != cell->rungId()) + { + return {}; + } + rung_id = cell->rungId(); + } else if (const VerticalConnectorItem *connector = dynamic_cast(item)) { @@ -1418,16 +1490,23 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) selectedEmptySlots(); const std::vector> selected_wire_cells = selectedWireCells(); + const std::vector> selected_gap_cells = + selectedGapCells(); const std::vector selected_expressions = selectedExpressionIds(); const std::vector selected_wires = selectedWireIds(); const std::vector selected_ids = selectedNodeIds(); LogicEditorResult result; if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U + || selected_gap_cells.size() > 1U || (!selected_empty_slots.empty() && (!selected_wire_cells.empty() + || !selected_gap_cells.empty() || !selected_expressions.empty() || !selected_ids.empty())) || (!selected_wire_cells.empty() - && (!selected_ids.empty() || selected_expressions.size() > 1U))) + && (!selected_gap_cells.empty() + || !selected_ids.empty() || selected_expressions.size() > 1U)) + || (!selected_gap_cells.empty() + && (!selected_ids.empty() || !selected_expressions.empty()))) { result = {false, LogicEditorError::InvalidOperation, "插入条件时只能选择一个网格或条件对象", {}}; @@ -1450,6 +1529,15 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) selected_wire_cells.front().second, config); } + else if (selected_gap_cells.size() == 1U) + { + result = editor_service_.replaceGapColumnWithCondition( + logic_id_, + currentRungId(), + selected_gap_cells.front().first, + selected_gap_cells.front().second, + config); + } else if (selected_expressions.size() > 1U || selected_wires.size() > 1U) { result = {false, LogicEditorError::InvalidOperation, @@ -1493,6 +1581,8 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co selectedEmptySlots(); const std::vector> selected_wire_cells = selectedWireCells(); + const std::vector> selected_gap_cells = + selectedGapCells(); const std::vector selected_ids = selectedNodeIds(); std::vector condition_ids; for (const std::string &node_id : selected_ids) @@ -1504,7 +1594,12 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co } } LogicEditorResult result; - if (!selected_empty_slots.empty() + if (!selected_gap_cells.empty()) + { + result = {false, LogicEditorError::InvalidOperation, + "断路网格不能建立并联支路,请先补横线或插入触点", {}}; + } + else if (!selected_empty_slots.empty() && (!selected_ids.empty() || !selected_wire_cells.empty())) { result = {false, LogicEditorError::InvalidOperation, @@ -1546,12 +1641,23 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co LogicEditorResult LogicEditorWidget::addHorizontalWire() { const std::vector selected_ids = selectedExpressionIds(); + const std::vector> selected_gap_cells = + selectedGapCells(); LogicEditorResult result; - if (selected_ids.size() > 1U) + if (selected_ids.size() > 1U || selected_gap_cells.size() > 1U + || (!selected_ids.empty() && !selected_gap_cells.empty())) { result = {false, LogicEditorError::InvalidOperation, "插入横线时只能选择一个条件对象", {}}; } + else if (selected_gap_cells.size() == 1U) + { + result = editor_service_.replaceGapColumnWithWire( + logic_id_, + currentRungId(), + selected_gap_cells.front().first, + selected_gap_cells.front().second); + } else if (selected_ids.size() == 1U) { result = editor_service_.insertWireAfter( @@ -1581,10 +1687,17 @@ LogicEditorResult LogicEditorWidget::addVerticalWire() selectedEmptySlots(); const std::vector> selected_wire_cells = selectedWireCells(); + const std::vector> selected_gap_cells = + selectedGapCells(); const std::vector selected_nodes = selectedNodeIds(); const std::vector selected_ids = selectedExpressionIds(); LogicEditorResult result; - if (!selected_empty_slots.empty() + if (!selected_gap_cells.empty()) + { + result = {false, LogicEditorError::InvalidOperation, + "断路网格不能建立竖线连接,请先补横线", {}}; + } + else if (!selected_empty_slots.empty() && (!selected_wire_cells.empty() || !selected_nodes.empty())) { result = {false, LogicEditorError::InvalidOperation, @@ -1630,6 +1743,8 @@ LogicEditorResult LogicEditorWidget::addVerticalWire() LogicEditorResult LogicEditorWidget::deleteHorizontalWire() { + const std::vector> wire_cells = + selectedWireCells(); const std::vector wire_ids = selectedWireIds(); const std::string rung_id = selectedRungId(); if (wire_ids.empty() || rung_id.empty()) @@ -1642,8 +1757,9 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire() reportFailure(result); return result; } - const LogicEditorResult result = editor_service_.removeExpressions( - logic_id_, rung_id, wire_ids); + const LogicEditorResult result = !wire_cells.empty() + ? editor_service_.disconnectWireCells(logic_id_, rung_id, wire_cells) + : editor_service_.disconnectWires(logic_id_, rung_id, wire_ids); if (result.succeeded) { reloadLogic(); @@ -1720,17 +1836,24 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( selectedEmptySlots(); const std::vector> selected_wire_cells = selectedWireCells(); + const std::vector> selected_gap_cells = + selectedGapCells(); const std::vector selected_expressions = selectedExpressionIds(); const std::vector selected_wires = selectedWireIds(); const std::vector selected_ids = selectedNodeIds(); LogicEditorResult result; LogicConditionPasteTarget target; if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U + || selected_gap_cells.size() > 1U || (!selected_empty_slots.empty() && (!selected_wire_cells.empty() + || !selected_gap_cells.empty() || !selected_expressions.empty() || !selected_ids.empty())) || (!selected_wire_cells.empty() - && (!selected_ids.empty() || selected_expressions.size() > 1U)) + && (!selected_gap_cells.empty() + || !selected_ids.empty() || selected_expressions.size() > 1U)) + || (!selected_gap_cells.empty() + && (!selected_ids.empty() || !selected_expressions.empty())) || selected_expressions.size() > 1U || selected_wires.size() > 1U || selected_ids.size() > 1U) { @@ -1754,6 +1877,12 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( target.expressionId = selected_wire_cells.front().first; target.column = selected_wire_cells.front().second; } + else if (selected_gap_cells.size() == 1U) + { + target.kind = LogicConditionPasteTargetKind::ReplaceGapColumn; + target.expressionId = selected_gap_cells.front().first; + target.column = selected_gap_cells.front().second; + } else if (selected_wires.size() == 1U) { target.kind = LogicConditionPasteTargetKind::ReplaceWire; @@ -1787,12 +1916,20 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( LogicEditorResult LogicEditorWidget::deleteSelected() { - const std::vector expression_ids = selectedExpressionIds(); const std::vector wire_ids = selectedWireIds(); + const std::vector> wire_cells = + selectedWireCells(); + const std::vector> gap_cells = + selectedGapCells(); const std::vector branch_ids = selectedBranchIds(); const std::vector node_ids = selectedNodeIds(); LogicEditorResult result; - if (!branch_ids.empty()) + if (!gap_cells.empty()) + { + result = {false, LogicEditorError::InvalidOperation, + "所选网格已经是断路,不需要重复删除", {}}; + } + else if (!branch_ids.empty()) { result = editor_service_.removeExpressions( logic_id_, selectedRungId(), branch_ids); @@ -1814,8 +1951,11 @@ LogicEditorResult LogicEditorWidget::deleteSelected() } else { - result = editor_service_.removeExpressions( - logic_id_, selectedRungId(), expression_ids); + result = !wire_cells.empty() + ? editor_service_.disconnectWireCells( + logic_id_, selectedRungId(), wire_cells) + : editor_service_.disconnectWires( + logic_id_, selectedRungId(), wire_ids); } } else if (!node_ids.empty()) diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 610d4e1..0915290 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -31,6 +31,7 @@ public: class NodeItem; class WireItem; class WireCellItem; + class GapCellItem; class VerticalConnectorItem; class RungItem; class EmptySlotItem; @@ -116,6 +117,8 @@ private: std::vector> selectedEmptySlots() const; /** 收集当前选中的横线网格位置 */ std::vector> selectedWireCells() const; + /** 收集当前选中的显式断路网格位置 */ + std::vector> selectedGapCells() const; /** 判断当前是否选中了网络内的图元 */ bool hasSelectedRungItem() const; diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index 953215e..efac0bf 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -9,6 +9,7 @@ #include "domain/virtual_register_repository.h" #include "support/test_support.h" +#include #include #include #include @@ -21,6 +22,30 @@ namespace { using TestSupport::require; +int expressionColumns(const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Node) + { + return 1; + } + if (expression.kind == ConditionExpressionKind::Wire) + { + return expression.wire->columnSpan; + } + if (expression.kind == ConditionExpressionKind::Gap) + { + return expression.gap->columnSpan; + } + int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; + for (const ConditionExpression &child : expression.children) + { + const int child_columns = expressionColumns(child); + columns = expression.kind == ConditionExpressionKind::Series + ? columns + child_columns : std::max(columns, child_columns); + } + return columns; +} + void testRegisterAddressBoundaries() { // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 @@ -268,7 +293,13 @@ Project makeValidProject() LadderRung rung; rung.id = "rung-1"; rung.name = "Network 1"; - rung.condition = ConditionExpression::fromNode(contact); + ConditionExpression condition; + condition.id = "start-series"; + condition.kind = ConditionExpressionKind::Series; + condition.children = { + ConditionExpression::fromNode(contact), + ConditionExpression::fromWire("start-wire", 9)}; + rung.condition = std::move(condition); rung.output = coil; logic.rungs.push_back(rung); @@ -463,17 +494,30 @@ void testQuantityBoundaries() true}); } const int address = (*next_address)++; + ConditionExpression nested = makeNested(depth - 1, next_address); + ConditionExpression sibling = ConditionExpression::fromNode({ + "depth-node-" + std::to_string(address), + ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, + true}); 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})}; + if (expression.kind == ConditionExpressionKind::Parallel + && expressionColumns(nested) > 1) + { + ConditionExpression padded_sibling; + padded_sibling.id = "depth-padding-" + std::to_string(address); + padded_sibling.kind = ConditionExpressionKind::Series; + padded_sibling.children = { + std::move(sibling), + ConditionExpression::fromWire( + "depth-wire-" + std::to_string(address), + expressionColumns(nested) - 1)}; + sibling = std::move(padded_sibling); + } + expression.children = {std::move(nested), std::move(sibling)}; return expression; }; int next_address = 1; @@ -625,7 +669,8 @@ void testLadderLogicBoundaries() root.kind = ConditionExpressionKind::Series; root.children = { ConditionExpression::fromNode(stop), - start_parallel}; + start_parallel, + ConditionExpression::fromWire("hold-output-wire", 8)}; LadderRung rung; rung.id = "rung-1"; rung.name = "Self hold"; @@ -644,6 +689,10 @@ void testLadderLogicBoundaries() "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); + ConditionExpression gap = ConditionExpression::fromGap("gap-1", 1); + require(gap.validate() && !gap.validateForRunning(), + "a gap must be a valid editing draft but must block runtime validation"); + ConditionExpression maximum_columns; maximum_columns.id = "maximum-columns"; maximum_columns.kind = ConditionExpressionKind::Series; @@ -696,9 +745,14 @@ void testLadderLogicBoundaries() require(empty_rung.validate(), "an empty editing network must be valid"); empty_rung.output = coil; - require(empty_rung.validate(), "output-only network may remain in an editable draft"); - require(empty_rung.validateForRunning(), - "an output-only network must be valid as an unconditional rung"); + require(!empty_rung.validate(), + "an output without an explicit ten-column condition path must be rejected"); + empty_rung.condition = ConditionExpression::fromWire("unconditional-wire", 10); + require(empty_rung.validate() && empty_rung.validateForRunning(), + "a ten-column wire path must represent a runnable unconditional rung"); + empty_rung.condition = ConditionExpression::fromGap("unconditional-gap", 10); + require(empty_rung.validate() && !empty_rung.validateForRunning(), + "a full-width gap draft must remain saved but disconnected"); logic.rungs.front().output = coil; logic.rungs.front().condition = root; @@ -711,8 +765,17 @@ void testLadderLogicBoundaries() nested_parallel.children = { ConditionExpression::fromNode(start), root}; + require(!nested_parallel.validate(), + "parallel branches with unequal explicit widths must be rejected"); + ConditionExpression padded_start; + padded_start.id = "padded-start"; + padded_start.kind = ConditionExpressionKind::Series; + padded_start.children = { + ConditionExpression::fromNode(start), + ConditionExpression::fromWire("nested-parallel-wire", 9)}; + nested_parallel.children.front() = std::move(padded_start); require(nested_parallel.validate(), - "nested series and parallel expressions must be valid"); + "parallel branches padded with explicit wires must be valid"); } void testModelsValidateBindingsAndIdentifiers() diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 1536c10..13ced0b 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -36,6 +36,10 @@ int conditionColumns(const ConditionExpression &expression) { return expression.wire->columnSpan; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return expression.gap->columnSpan; + } int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; for (const ConditionExpression &child : expression.children) { @@ -56,6 +60,10 @@ int wireColumns(const ConditionExpression &expression) { return expression.wire->columnSpan; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return 0; + } int columns = 0; for (const ConditionExpression &child : expression.children) { @@ -64,6 +72,25 @@ int wireColumns(const ConditionExpression &expression) return columns; } +int gapColumns(const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Gap) + { + return expression.gap->columnSpan; + } + if (expression.kind == ConditionExpressionKind::Node + || expression.kind == ConditionExpressionKind::Wire) + { + return 0; + } + int columns = 0; + for (const ConditionExpression &child : expression.children) + { + columns += gapColumns(child); + } + return columns; +} + int conditionNodes(const ConditionExpression &expression) { if (expression.kind == ConditionExpressionKind::Node) @@ -74,6 +101,10 @@ int conditionNodes(const ConditionExpression &expression) { return 0; } + if (expression.kind == ConditionExpressionKind::Gap) + { + return 0; + } int count = 0; for (const ConditionExpression &child : expression.children) { @@ -82,6 +113,23 @@ int conditionNodes(const ConditionExpression &expression) return count; } +const ConditionExpression *firstExpressionOfKind( + const ConditionExpression &expression, ConditionExpressionKind kind) +{ + if (expression.kind == kind) + { + return &expression; + } + for (const ConditionExpression &child : expression.children) + { + if (const ConditionExpression *found = firstExpressionOfKind(child, kind)) + { + return found; + } + } + return nullptr; +} + void testEmptyLogicCreatesNetworksOnFirstEdit() { TestProjectStorage storage; @@ -121,9 +169,13 @@ void testEmptyLogicCreatesNetworksOnFirstEdit() CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}); require(first_output.succeeded && service.findLogic(logic_id)->rungs.size() == 1U - && !service.findLogic(logic_id)->rungs.front().condition.has_value() + && service.findLogic(logic_id)->rungs.front().condition.has_value() + && service.findLogic(logic_id)->rungs.front().condition->kind + == ConditionExpressionKind::Wire + && service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan + == ProjectLimits::kMaximumConditionColumns && service.findLogic(logic_id)->rungs.front().output.has_value(), - "the first output must create an unconditional network"); + "the first output must create a full-width explicit wire network"); } void testAppendingWireMovesToNextNetworkAfterTenColumns() @@ -199,15 +251,20 @@ void testStructuredEditingAndNormalization() rung = service.findRung(logic_id, rung_id); require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel && rung->condition->children.at(1).children.at(1).kind - == ConditionExpressionKind::Node, - "single-child series container must collapse after deletion"); + == ConditionExpressionKind::Series + && gapColumns(*rung->condition) == 1 + && conditionColumns(*rung->condition) == 3, + "deleting a nested node must preserve its column as an explicit gap"); require(service.removeNode(logic_id, parallel.id).succeeded, "parallel leaf deletion must succeed"); rung = service.findRung(logic_id, rung_id); require(rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.size() == 2U, - "single-child parallel container must collapse after deletion"); + "deleting a parallel leaf must preserve the surrounding topology"); + require(gapColumns(*rung->condition) == 2 + && conditionColumns(*rung->condition) == 3, + "each deleted condition must remain as a one-column gap"); require(service.setOutput( logic_id, @@ -268,10 +325,21 @@ void testParallelBranchGridInsertion() "parallel grid insertion setup must create a short lower branch"); service.clearHistory(); - const LogicEditorResult inserted = service.insertConditionInBranchAtColumn( - logic_id, rung_id, branch.id, 2, contact(12)); + const LadderRung *padded_rung = service.findRung(logic_id, rung_id); + const ConditionExpression &padded_lower = padded_rung->condition->children.at(1); + require(padded_lower.kind == ConditionExpressionKind::Series + && padded_lower.children.size() == 2U + && padded_lower.children.back().kind == ConditionExpressionKind::Wire + && padded_lower.children.back().wire->columnSpan == 2, + "a short parallel branch must persist its two padding cells as a wire"); + const LogicEditorResult inserted = service.replaceWireColumnWithCondition( + logic_id, + rung_id, + padded_lower.children.back().id, + 1, + contact(12)); require(inserted.succeeded, - "a visible parallel branch padding cell must accept a condition"); + "a persisted parallel branch wire cell must accept a condition"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Parallel, @@ -288,9 +356,12 @@ void testParallelBranchGridInsertion() require(service.undo().succeeded, "parallel branch grid insertion must be one undoable edit"); rung = service.findRung(logic_id, rung_id); - require(rung->condition->children.at(1).kind == ConditionExpressionKind::Node - && rung->condition->children.at(1).node->id == branch.id, - "undo must restore the original short parallel branch"); + require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series + && rung->condition->children.at(1).children.front().node->id == branch.id + && rung->condition->children.at(1).children.back().kind + == ConditionExpressionKind::Wire + && rung->condition->children.at(1).children.back().wire->columnSpan == 2, + "undo must restore the original branch and its explicit padding wire"); service.clearHistory(); const bool modified_before = project_service.isModified(); @@ -334,8 +405,8 @@ void testStructuredWireEditing() "a selected wire must be replaceable by a configured node type"); const LogicEditorResult extension = service.insertWireAfter( logic_id, rung_id, replacement.id); - require(extension.succeeded && extension.id == "wire-1", - "a wire id must become reusable after its expression is replaced"); + require(extension.succeeded && extension.id == "wire-2", + "replacing one cell of a multi-cell wire must retain the original id on the remaining cell"); rung = service.findRung(logic_id, rung_id); const ConditionExpression *extended_branch = service.findExpression( @@ -354,9 +425,12 @@ void testStructuredWireEditing() logic_id, rung_id, {extended_branch_id}).succeeded, "deleting a selected vertical connection branch must succeed atomically"); rung = service.findRung(logic_id, rung_id); - require(rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 3U, - "removing a bypass branch must normalize back to the original series"); + require(rung->condition.has_value() + && conditionNodes(*rung->condition) == 3 + && conditionColumns(*rung->condition) == 4 + && wireColumns(*rung->condition) == 1 + && service.findNode(logic_id, replacement.id) == nullptr, + "removing a bypass branch must keep the materialized padding wire without reconnecting positions"); require(service.undo().succeeded && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, "wire branch deletion must participate in ladder undo history"); @@ -487,8 +561,11 @@ void testBatchDeleteAllNodesInParallelBranch() && rung->condition->children.at(0).kind == ConditionExpressionKind::Node && rung->condition->children.at(1).kind - == ConditionExpressionKind::Node, - "an empty parallel branch must collapse into the remaining branch"); + == ConditionExpressionKind::Parallel + && conditionNodes(*rung->condition) == 2 + && gapColumns(*rung->condition) == 3 + && conditionColumns(*rung->condition) == 4, + "deleted parallel conditions must remain as gaps without collapsing the branch"); require(service.findNode(logic_id, second.id) == nullptr && service.findNode(logic_id, third.id) == nullptr && service.findNode(logic_id, fourth.id) == nullptr, @@ -567,9 +644,131 @@ void testUnconditionalOutputEditing() .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() + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Wire + && rung->condition->wire->columnSpan + == ProjectLimits::kMaximumConditionColumns && rung->output.has_value() && rung->validateForRunning(), - "an editor-created output-only network must be runnable as unconditional"); + "an editor-created unconditional network must use a full-width wire"); + + const std::string contact_rung_id = makeEmptyRung(service, logic_id); + const LogicEditorResult contact_result = service.appendCondition( + logic_id, contact_rung_id, contact(0)); + require(contact_result.succeeded + && service.updateNodeConfig( + logic_id, contact_result.id, contact(0)).succeeded + && service.setOutput( + logic_id, + contact_rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal}, + true).succeeded, + "a contact followed by an output must be editable"); + const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id); + require(contact_rung != nullptr && contact_rung->condition.has_value() + && conditionNodes(*contact_rung->condition) == 1 + && wireColumns(*contact_rung->condition) == 9 + && conditionColumns(*contact_rung->condition) == 10 + && contact_rung->validateForRunning(), + "a contact followed by an output must persist the remaining nine wire cells"); +} + +void testExplicitGapEditing() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(service, logic_id); + require(service.setOutput( + logic_id, + rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal}, + true).succeeded, + "gap editing setup must create an explicit full-width wire"); + + const LadderRung *rung = service.findRung(logic_id, rung_id); + const std::string full_wire_id = rung->condition->id; + require(service.disconnectWireCells( + logic_id, rung_id, {{full_wire_id, 4}}).succeeded, + "deleting one wire cell must create a gap"); + rung = service.findRung(logic_id, rung_id); + require(rung->validate() && !rung->validateForRunning() + && conditionColumns(*rung->condition) == 10 + && wireColumns(*rung->condition) == 9 + && gapColumns(*rung->condition) == 1, + "a disconnected wire cell must preserve width and block runtime"); + + const ConditionExpression *gap = firstExpressionOfKind( + *rung->condition, ConditionExpressionKind::Gap); + require(gap != nullptr, "the disconnected cell must expose a gap expression"); + const std::string first_gap_id = gap->id; + require(service.replaceGapColumnWithWire( + logic_id, rung_id, first_gap_id, 0).succeeded, + "a gap cell must be repairable with a real wire"); + rung = service.findRung(logic_id, rung_id); + require(gapColumns(*rung->condition) == 0 + && wireColumns(*rung->condition) == 10 + && rung->validateForRunning(), + "repairing the gap with a wire must restore a runnable path"); + + const ConditionExpression *wire = firstExpressionOfKind( + *rung->condition, ConditionExpressionKind::Wire); + require(wire != nullptr, "the repaired path must expose a wire expression"); + const std::string repaired_wire_id = wire->id; + require(service.disconnectWireCells( + logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded, + "a repaired wire cell must be disconnectable again"); + rung = service.findRung(logic_id, rung_id); + gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap); + require(gap != nullptr, "the second disconnection must expose a gap expression"); + const std::string second_gap_id = gap->id; + const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition( + logic_id, rung_id, second_gap_id, 0, contact(31)); + require(inserted_result.succeeded + && service.updateNodeConfig( + logic_id, inserted_result.id, contact(31)).succeeded, + "a gap cell must be replaceable with a contact"); + rung = service.findRung(logic_id, rung_id); + const ConditionExpression *inserted = firstExpressionOfKind( + *rung->condition, ConditionExpressionKind::Node); + require(inserted != nullptr && gapColumns(*rung->condition) == 0 + && conditionNodes(*rung->condition) == 1 + && conditionColumns(*rung->condition) == 10 + && rung->validateForRunning(), + "a contact inserted into a gap must preserve the ten-column path"); + const std::string inserted_node_id = inserted->node->id; + require(service.removeNode(logic_id, inserted_node_id).succeeded, + "the inserted contact must be removable"); + rung = service.findRung(logic_id, rung_id); + require(conditionNodes(*rung->condition) == 0 + && gapColumns(*rung->condition) == 1 + && conditionColumns(*rung->condition) == 10 + && !rung->validateForRunning(), + "deleting a contact must restore a gap instead of reconnecting the path"); + + const std::string multi_rung_id = makeEmptyRung(service, logic_id); + require(service.setOutput( + logic_id, + multi_rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal}, + true).succeeded, + "multi-gap setup must create a full-width wire"); + const std::string multi_wire_id = service.findRung( + logic_id, multi_rung_id)->condition->id; + require(service.disconnectWireCells( + logic_id, + multi_rung_id, + {{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded, + "multiple cells in one wire must disconnect atomically"); + const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id); + require(conditionColumns(*multi_rung->condition) == 10 + && wireColumns(*multi_rung->condition) == 8 + && gapColumns(*multi_rung->condition) == 2 + && !multi_rung->validateForRunning(), + "multiple disconnected cells must retain both explicit gaps"); } void testColumnTargetedConditionInsertion() @@ -627,12 +826,17 @@ void testColumnTargetedConditionInsertion() true) .succeeded, "an output-only network must be configurable before adding a condition"); - require(service.insertConditionAtColumn( - logic_id, second_rung.id, 0, contact(0)).succeeded, - "a selected first grid slot must work on an output-only network"); + const std::string output_wire_id = service.findRung( + logic_id, second_rung.id)->condition->id; + require(service.replaceWireColumnWithCondition( + logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded, + "the first wire cell must accept a contact on an unconditional network"); const LadderRung *output_rung = service.findRung(logic_id, second_rung.id); require(output_rung != nullptr && output_rung->condition.has_value() - && output_rung->condition->kind == ConditionExpressionKind::Node + && output_rung->condition->kind == ConditionExpressionKind::Series + && conditionNodes(*output_rung->condition) == 1 + && wireColumns(*output_rung->condition) == 9 + && conditionColumns(*output_rung->condition) == 10 && output_rung->output.has_value() && output_rung->validate(), "condition insertion must keep the independent output slot intact"); @@ -1149,13 +1353,18 @@ void testConditionPasteTargetsAndValidation() {branch_source.id, branch_source_two.id, branch_source_three.id}, contact(4)); require(branch.succeeded, "branch paste setup must create a parallel branch"); + const ConditionExpression &paste_lower = service.findRung( + logic_id, branch_rung)->condition->children.at(1); + require(paste_lower.kind == ConditionExpressionKind::Series + && paste_lower.children.back().kind == ConditionExpressionKind::Wire, + "branch paste setup must persist the short branch padding wire"); LogicConditionPasteTarget branch_target; - branch_target.kind = LogicConditionPasteTargetKind::BranchEmptyColumn; - branch_target.expressionId = branch.id; - branch_target.column = 2; + branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; + branch_target.expressionId = paste_lower.children.back().id; + branch_target.column = 1; require(service.pasteConditionNodes( logic_id, branch_rung, {source}, branch_target).succeeded, - "condition paste must support a visible empty cell in a branch"); + "condition paste must support a persisted wire cell in a branch"); const std::string after_node_rung = makeEmptyRung(service, logic_id); const LogicEditorResult after_source = service.appendCondition( @@ -1248,6 +1457,7 @@ int main() testBatchDeleteAllNodesInParallelBranch(); testConditionColumnLimit(); testUnconditionalOutputEditing(); + testExplicitGapEditing(); testColumnTargetedConditionInsertion(); testWireColumnReplacement(); testSequentialConditionInsertionConsumesFollowingWire(); diff --git a/app/tests/offline_simulation_service_tests.cpp b/app/tests/offline_simulation_service_tests.cpp index 092e518..2477564 100644 --- a/app/tests/offline_simulation_service_tests.cpp +++ b/app/tests/offline_simulation_service_tests.cpp @@ -1,4 +1,5 @@ #include "domain/hmi_model.h" +#include "domain/project_limits.h" #include "domain/register_repository.h" #include "domain/virtual_register_repository.h" #include "services/hmi_runtime_service.h" @@ -132,6 +133,26 @@ LadderRung rung(const std::string &id, series.children = std::move(series_children); result.condition = std::move(series); } + const int occupied_columns = static_cast(stages.size()); + if (occupied_columns < ProjectLimits::kMaximumConditionColumns) + { + ConditionExpression wire = ConditionExpression::fromWire( + id + "-output-wire", + ProjectLimits::kMaximumConditionColumns - occupied_columns); + if (result.condition->kind == ConditionExpressionKind::Series) + { + result.condition->children.push_back(std::move(wire)); + } + else + { + ConditionExpression series; + series.id = id + "-explicit-series"; + series.kind = ConditionExpressionKind::Series; + series.children.push_back(std::move(*result.condition)); + series.children.push_back(std::move(wire)); + result.condition = std::move(series); + } + } result.output = output; return result; } @@ -179,12 +200,22 @@ void testNestedSeriesParallelExpression() nested_series.children = { ConditionExpression::fromNode(contact("b", 1)), ConditionExpression::fromNode(contact("c", 2))}; + ConditionExpression padded_a; + padded_a.id = "padded-a"; + padded_a.kind = ConditionExpressionKind::Series; + padded_a.children = { + ConditionExpression::fromNode(contact("a", 0)), + ConditionExpression::fromWire("a-branch-wire", 1)}; + ConditionExpression parallel; + parallel.id = "root-parallel"; + parallel.kind = ConditionExpressionKind::Parallel; + parallel.children = {std::move(padded_a), nested_series}; ConditionExpression root; - root.id = "root-parallel"; - root.kind = ConditionExpressionKind::Parallel; + root.id = "nested-output-series"; + root.kind = ConditionExpressionKind::Series; root.children = { - ConditionExpression::fromNode(contact("a", 0)), - nested_series}; + std::move(parallel), + ConditionExpression::fromWire("nested-output-wire", 8)}; LadderRung nested_rung; nested_rung.id = "nested-rung"; nested_rung.name = "nested-rung"; @@ -220,16 +251,18 @@ void testUnconditionalCoil() LadderRung unconditional; unconditional.id = "unconditional-rung"; unconditional.name = "unconditional-rung"; + unconditional.condition = ConditionExpression::fromWire( + "unconditional-wire", ProjectLimits::kMaximumConditionColumns); 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"); + "a full-width wire network must pass runtime validation"); require(executor.executeScan({program}, repository, &trace).succeeded, - "an output-only network scan must succeed"); + "a full-width wire network scan must succeed"); require(readBit(repository, 10), - "an output-only network must energize its coil as a constant-true rung"); + "a full-width wire 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"); @@ -245,7 +278,8 @@ void testWirePassThroughAndPowerTrace() root.children = { ConditionExpression::fromNode(contact("wire-input", 0)), ConditionExpression::fromWire("wire-segment", 2), - ConditionExpression::fromNode(contact("wire-output", 1))}; + ConditionExpression::fromNode(contact("wire-output", 1)), + ConditionExpression::fromWire("wire-output-padding", 6)}; LadderRung wired_rung; wired_rung.id = "wired-rung"; wired_rung.name = "wired-rung"; diff --git a/app/tests/performance_tests.cpp b/app/tests/performance_tests.cpp index c779925..280c98d 100644 --- a/app/tests/performance_tests.cpp +++ b/app/tests/performance_tests.cpp @@ -3,9 +3,11 @@ #include "domain/virtual_register_repository.h" #include "services/software_logic_executor.h" +#include #include #include +#include #include namespace @@ -34,8 +36,15 @@ ControlLogic makeLogic(int index) LadderRung rung; rung.id = "rung-" + std::to_string(index); rung.name = rung.id; - rung.condition = ConditionExpression::fromNode( - contact("contact-" + std::to_string(index), index)); + ConditionExpression condition; + condition.id = "series-" + std::to_string(index); + condition.kind = ConditionExpressionKind::Series; + condition.children = { + ConditionExpression::fromNode( + contact("contact-" + std::to_string(index), index)), + ConditionExpression::fromWire( + "wire-" + std::to_string(index), 9)}; + rung.condition = std::move(condition); rung.output = coil("coil-" + std::to_string(index), index + 100); return {"logic-" + std::to_string(index), "logic-" + std::to_string(index), @@ -48,6 +57,16 @@ class PerformanceTests final : public QObject Q_OBJECT private slots: + void initTestCase() + { + qInfo().noquote() + << QStringLiteral("性能测试开始:下面两项 RESULT 的耗时均为平均每次执行时间"); + qInfo().noquote() + << QStringLiteral("测试项目 1:100 个控制逻辑的软件扫描"); + qInfo().noquote() + << QStringLiteral("测试项目 2:M0 到 M4000 共 4001 个虚拟 M 地址的完整写入和读取"); + } + void softwareExecutor_scansRepresentativeProject() { std::vector logics; @@ -89,6 +108,12 @@ private slots: } } } + + void cleanupTestCase() + { + qInfo().noquote() + << QStringLiteral("性能测试结束:请结合上方两项 RESULT 和最后的 Totals 判断结果"); + } }; } // namespace diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index dbcdbb7..9d70af0 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -150,7 +150,8 @@ Project makeExampleProject() series.children = { std::move(parallel), ConditionExpression::fromWire("start-wire", 2), - ConditionExpression::fromNode(compare)}; + ConditionExpression::fromNode(compare), + ConditionExpression::fromWire("start-output-wire", 6)}; rung.condition = std::move(series); rung.output = coil; logic.rungs.push_back(rung); @@ -167,7 +168,13 @@ Project makeExampleProject() edge_rung.id = "edge-rung"; edge_rung.name = "Edge network"; edge_rung.comment = "上升沿输出"; - edge_rung.condition = ConditionExpression::fromNode(rising_edge); + ConditionExpression edge_series; + edge_series.id = "edge-series"; + edge_series.kind = ConditionExpressionKind::Series; + edge_series.children = { + ConditionExpression::fromNode(rising_edge), + ConditionExpression::fromWire("edge-output-wire", 9)}; + edge_rung.condition = std::move(edge_series); edge_rung.output = edge_output; logic.rungs.push_back(edge_rung); @@ -185,7 +192,13 @@ Project makeExampleProject() LadderRung data_rung; data_rung.id = rung_id; data_rung.name = rung_id; - data_rung.condition = ConditionExpression::fromNode(input); + ConditionExpression data_series; + data_series.id = rung_id + "-series"; + data_series.kind = ConditionExpressionKind::Series; + data_series.children = { + ConditionExpression::fromNode(input), + ConditionExpression::fromWire(rung_id + "-output-wire", 9)}; + data_rung.condition = std::move(data_series); data_rung.output = std::move(output); logic.rungs.push_back(std::move(data_rung)); }; @@ -328,11 +341,13 @@ void testUnconditionalOutputRoundTrip() coil.id = "unconditional-coil"; coil.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; + rung.condition = ConditionExpression::fromWire( + "unconditional-wire", ProjectLimits::kMaximumConditionColumns); 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"); + "an explicit full-width wire project must be runnable before saving"); const QString path = directory.filePath("unconditional-output.json"); require(service.saveAs(path.toStdString()).succeeded, @@ -340,9 +355,71 @@ void testUnconditionalOutputRoundTrip() 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() + require(loaded.condition.has_value() + && loaded.condition->kind == ConditionExpressionKind::Wire + && loaded.condition->wire->columnSpan + == ProjectLimits::kMaximumConditionColumns + && loaded.output.has_value() && loaded.validateForRunning(), - "JSON round trip must preserve unconditional output semantics"); + "JSON round trip must preserve explicit unconditional wire semantics"); + + QJsonObject implicit_root = QJsonDocument::fromJson(readBytes(path)).object(); + QJsonArray logics = implicit_root.value(QStringLiteral("controlLogics")).toArray(); + QJsonObject logic = logics.at(0).toObject(); + QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); + QJsonObject implicit_rung = rungs.at(0).toObject(); + implicit_rung.insert(QStringLiteral("condition"), QJsonValue::Null); + rungs.replace(0, implicit_rung); + logic.insert(QStringLiteral("rungs"), rungs); + logics.replace(0, logic); + implicit_root.insert(QStringLiteral("controlLogics"), logics); + const QString implicit_path = directory.filePath("implicit-output.json"); + writeText( + implicit_path, + QJsonDocument(implicit_root).toJson(QJsonDocument::Indented)); + require(!service.load(implicit_path.toStdString()).succeeded, + "version 1.0 implicit output wiring must not be migrated on load"); +} + +void testGapRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + JsonProjectStorage storage; + ProjectService service(storage); + require(service.createNewProject("Gap draft").succeeded, + "gap draft project creation must succeed"); + LadderRung gap_rung; + gap_rung.id = "gap-rung"; + gap_rung.name = "Gap rung"; + gap_rung.condition = ConditionExpression::fromGap( + "gap-full-width", ProjectLimits::kMaximumConditionColumns); + gap_rung.output = LogicNode{ + "gap-output", + CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, + true}; + service.editProject().controlLogics.push_back( + ControlLogic{"gap-logic", "Gap logic", {gap_rung}, true}); + require(service.project().validate() && !service.project().validateForRunning(), + "a gap network must be saveable but not runnable"); + + const QString gap_path = directory.filePath("gap-project.json"); + require(service.saveAs(gap_path.toStdString()).succeeded, + "a gap draft must save successfully"); + require(readBytes(gap_path).contains("\"kind\": \"gap\""), + "version 1.0 JSON must persist gap expressions explicitly"); + require(service.load(gap_path.toStdString()).succeeded, + "a version 1.0 gap draft must load successfully"); + const LadderRung &loaded_gap = + service.project().controlLogics.front().rungs.front(); + require(service.project().metadata.formatVersion == "1.0" + && loaded_gap.condition.has_value() + && loaded_gap.condition->kind == ConditionExpressionKind::Gap + && loaded_gap.condition->gap->columnSpan + == ProjectLimits::kMaximumConditionColumns + && !loaded_gap.validateForRunning(), + "gap JSON round trip must preserve the disconnected runtime state"); } void testExampleProjectRoundTrip() @@ -490,8 +567,9 @@ void testExampleProjectRoundTrip() == RegisterAddress{RegisterArea::M, 6}, "edge network output must survive round trip"); require(std::holds_alternative( - edge_rung.condition->node->config) - && std::get(edge_rung.condition->node->config).mode + edge_rung.condition->children.front().node->config) + && std::get( + edge_rung.condition->children.front().node->config).mode == EdgeMode::Rising, "rising edge configuration must survive round trip"); require(std::get( @@ -935,6 +1013,7 @@ int main() // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 testEmptyProjectRoundTrip(); testUnconditionalOutputRoundTrip(); + testGapRoundTrip(); testExampleProjectRoundTrip(); testRegisterCommentService(); testInvalidFiles(); diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 8282fca..9eec390 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -89,6 +89,19 @@ private: using TestSupport::require; +ConditionExpression conditionWithOutputWire( + LogicNode node, + const std::string &wire_id) +{ + ConditionExpression series; + series.id = wire_id + "-series"; + series.kind = ConditionExpressionKind::Series; + series.children = { + ConditionExpression::fromNode(std::move(node)), + ConditionExpression::fromWire(wire_id, 9)}; + return series; +} + void testModeTransitions() { // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 @@ -126,7 +139,8 @@ void testModeTransitions() LadderRung edge_rung; edge_rung.id = "poll-edge-rung"; edge_rung.name = "Poll edge"; - edge_rung.condition = ConditionExpression::fromNode(edge); + edge_rung.condition = conditionWithOutputWire( + edge, "poll-edge-output-wire"); edge_rung.output = edge_coil; logic.rungs.push_back(edge_rung); LogicNode comparison; @@ -140,7 +154,8 @@ void testModeTransitions() LadderRung comparison_rung; comparison_rung.id = "poll-comparison-rung"; comparison_rung.name = "Poll comparison"; - comparison_rung.condition = ConditionExpression::fromNode(comparison); + comparison_rung.condition = conditionWithOutputWire( + comparison, "poll-comparison-output-wire"); comparison_rung.output = comparison_coil; logic.rungs.push_back(comparison_rung); LogicNode move_input; @@ -158,7 +173,8 @@ void testModeTransitions() LadderRung move_rung; move_rung.id = "poll-move-rung"; move_rung.name = "Poll MOVE"; - move_rung.condition = ConditionExpression::fromNode(move_input); + move_rung.condition = conditionWithOutputWire( + move_input, "poll-move-output-wire"); move_rung.output = move_output; logic.rungs.push_back(move_rung); LogicNode add_input; @@ -181,7 +197,8 @@ void testModeTransitions() LadderRung add_rung; add_rung.id = "poll-add-rung"; add_rung.name = "Poll ADD"; - add_rung.condition = ConditionExpression::fromNode(add_input); + add_rung.condition = conditionWithOutputWire( + add_input, "poll-add-output-wire"); add_rung.output = add_output; logic.rungs.push_back(add_rung); project.controlLogics.push_back(logic);