From f33976b5da3135c02788231fae514562f7ebda93 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Thu, 20 Aug 2026 12:45:30 +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=E7=BD=91=E6=A0=BC=E8=BF=9E=E7=BB=AD=E6=8F=92=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/services/logic_editor_service.cpp | 179 +++++++++++- app/src/services/logic_editor_service.h | 11 + app/src/ui/logic_editor_widget.cpp | 245 ++++++++++++++-- app/src/ui/logic_editor_widget.h | 5 + app/src/ui/main_window.ui | 14 +- app/tests/logic_editor_service_tests.cpp | 339 ++++++++++++++++++++++ app/tests/main_window_tests.cpp | 241 +++++++++++++++ 7 files changed, 1008 insertions(+), 26 deletions(-) diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index d880f5c..800643e 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -930,6 +930,99 @@ LogicEditorResult LogicEditorService::appendCondition( return {true, LogicEditorError::None, {}, node_id}; } +LogicEditorResult LogicEditorService::insertConditionAtColumn( + const std::string &logic_id, + const std::string &rung_id, + int column, + const LogicNodeConfig &config) +{ + const ControlLogic *logic = findLogic(logic_id); + const LadderRung *existing_rung = findRung(logic_id, rung_id); + if (logic == nullptr || existing_rung == nullptr) + { + return failure( + logic == nullptr ? LogicEditorError::LogicNotFound + : LogicEditorError::RungNotFound, + logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络"); + } + if (!isConditionConfig(config)) + { + return failure(LogicEditorError::InvalidNode, "梯形图条件不能使用输出节点"); + } + if (column < 0 || column >= ProjectLimits::kMaximumConditionColumns) + { + return failure(LogicEditorError::InvalidOperation, "条件插入列必须位于第 1~10 列"); + } + + const int occupied_columns = existing_rung->condition.has_value() + ? expressionColumns(*existing_rung->condition) : 0; + if (column < occupied_columns) + { + return failure(LogicEditorError::InvalidOperation, "所选网格已被条件或横线占用"); + } + + const int leading_wire_columns = column - occupied_columns; + const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); + ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); + HistoryState before = captureState(); + Project &project = project_service_.editProject(); + LadderRung *rung = findEditableRung(project, logic_id, rung_id); + + if (leading_wire_columns > 0) + { + ConditionExpression wire = ConditionExpression::fromWire( + makeUniqueWireId(*logic), leading_wire_columns); + if (!rung->condition.has_value()) + { + rung->condition = makeContainer( + makeUniqueExpressionId(*logic), + ConditionExpressionKind::Series, + std::move(wire), + std::move(leaf)); + } + else if (rung->condition->kind == ConditionExpressionKind::Series) + { + rung->condition->children.push_back(std::move(wire)); + rung->condition->children.push_back(std::move(leaf)); + } + else + { + ConditionExpression series; + series.id = makeUniqueExpressionId(*logic); + series.kind = ConditionExpressionKind::Series; + series.children.push_back(std::move(*rung->condition)); + series.children.push_back(std::move(wire)); + series.children.push_back(std::move(leaf)); + rung->condition = std::move(series); + } + } + else if (!rung->condition.has_value()) + { + rung->condition = std::move(leaf); + } + else if (rung->condition->kind == ConditionExpressionKind::Series) + { + rung->condition->children.push_back(std::move(leaf)); + } + else + { + rung->condition = makeContainer( + makeUniqueExpressionId(*logic), + ConditionExpressionKind::Series, + std::move(*rung->condition), + std::move(leaf)); + } + + std::string validation_error; + if (!rung->validate(&validation_error)) + { + project.controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; +} + LogicEditorResult LogicEditorService::appendWire( const std::string &logic_id, const std::string &rung_id, @@ -1014,7 +1107,25 @@ LogicEditorResult LogicEditorService::insertConditionAfter( { return child.id == target_node_id; }); - parent->children.insert(target_iterator + 1, std::move(leaf)); + 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 = std::move(leaf); + } + else + { + --insertion_position->wire->columnSpan; + parent->children.insert(insertion_position, std::move(leaf)); + } + } + else + { + parent->children.insert(insertion_position, std::move(leaf)); + } } else { @@ -1132,6 +1243,72 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition( return {true, LogicEditorError::None, {}, node_id}; } +LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( + const std::string &logic_id, + const std::string &rung_id, + const std::string &wire_expression_id, + int column_offset, + const LogicNodeConfig &config) +{ + const ControlLogic *logic = findLogic(logic_id); + const ConditionExpression *wire = findExpression( + logic_id, rung_id, wire_expression_id); + if (logic == nullptr || wire == nullptr + || wire->kind != ConditionExpressionKind::Wire) + { + return failure(LogicEditorError::ExpressionNotFound, "未找到要替换的横线"); + } + if (!isConditionConfig(config)) + { + return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点"); + } + if (column_offset < 0 || column_offset >= wire->wire->columnSpan) + { + return failure(LogicEditorError::InvalidOperation, "横线网格偏移超出有效范围"); + } + if (wire->wire->columnSpan == 1) + { + return replaceWireWithCondition( + logic_id, rung_id, wire_expression_id, config); + } + + const int leading_columns = column_offset; + const int trailing_columns = wire->wire->columnSpan - column_offset - 1; + const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); + ConditionExpression replacement; + replacement.id = makeUniqueExpressionId(*logic); + replacement.kind = ConditionExpressionKind::Series; + if (leading_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromWire( + wire_expression_id, leading_columns)); + } + replacement.children.push_back( + ConditionExpression::fromNode(makeNode(node_id, config))); + if (trailing_columns > 0) + { + replacement.children.push_back(ConditionExpression::fromWire( + leading_columns > 0 ? makeUniqueWireId(*logic) : wire_expression_id, + trailing_columns)); + } + + HistoryState before = captureState(); + Project &project = project_service_.editProject(); + 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 (!rung->validate(&validation_error)) + { + project.controlLogics = std::move(before.logics); + return failure(LogicEditorError::InvalidNode, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; +} + LogicEditorResult LogicEditorService::addParallelBranch( const std::string &logic_id, const std::string &rung_id, diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index 82c01e3..ef46fff 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -68,6 +68,11 @@ public: const std::string &logic_id, const std::string &rung_id, const LogicNodeConfig &config); + LogicEditorResult insertConditionAtColumn( + const std::string &logic_id, + const std::string &rung_id, + int column, + const LogicNodeConfig &config); LogicEditorResult appendWire( const std::string &logic_id, const std::string &rung_id, @@ -87,6 +92,12 @@ public: const std::string &rung_id, const std::string &wire_expression_id, const LogicNodeConfig &config); + LogicEditorResult replaceWireColumnWithCondition( + const std::string &logic_id, + const std::string &rung_id, + const std::string &wire_expression_id, + int column_offset, + const LogicNodeConfig &config); LogicEditorResult addParallelBranch( const std::string &logic_id, const std::string &rung_id, diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index d77f0e7..7cc17f4 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -586,6 +586,113 @@ private: bool active_ = false; }; +class LogicEditorWidget::WireCellItem final : public QGraphicsItem +{ +public: + WireCellItem( + 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)); + painter->setPen(ladderPen(false)); + painter->drawLine( + QPointF(-kCellWidth / 2.0, 0), QPointF(kCellWidth / 2.0, 0)); + } + + 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: + EmptySlotItem( + const std::string &rung_id, + int column, + const QPointF ¢er, + bool show_label) + : rung_id_(rung_id), column_(column), show_label_(show_label) + { + setPos(center); + setFlag(ItemIsSelectable, true); + setZValue(2.0); + setToolTip(LogicEditorWidget::tr("空条件网格:第 %1 列,点击后可插入触点") + .arg(column + 1)); + } + + QRectF boundingRect() const override + { + return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; + } + + void paint( + QPainter *painter, + const QStyleOptionGraphicsItem *option, + QWidget *) override + { + const bool selected = (option->state & QStyle::State_Selected) != 0; + if (selected) + { + 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)); + } + painter->setPen(ladderPen(false)); + painter->drawLine( + QPointF(-kCellWidth / 2.0, 0), QPointF(kCellWidth / 2.0, 0)); + if (show_label_) + { + painter->setPen(kPlaceholderColor); + painter->drawText( + QRectF(-kCellWidth / 2.0, -20, kCellWidth, 40), + Qt::AlignCenter, + LogicEditorWidget::tr("添加条件")); + } + } + + const std::string &rungId() const { return rung_id_; } + int column() const { return column_; } + +private: + std::string rung_id_; + int column_ = 0; + bool show_label_ = false; +}; + class LogicEditorWidget::VerticalConnectorItem final : public QGraphicsItem { public: @@ -752,6 +859,17 @@ RenderResult renderExpression( center, expression.wire->columnSpan, active)); + for (int column = 0; column < expression.wire->columnSpan; ++column) + { + scene.addItem(new LogicEditorWidget::WireCellItem( + 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())}; @@ -953,16 +1071,7 @@ void LogicEditorWidget::reloadLogic() current_rung_id_ = logic->rungs.front().id; } - int condition_columns = 1; - for (const LadderRung &rung : logic->rungs) - { - if (rung.condition.has_value()) - { - condition_columns = std::max( - condition_columns, - measureExpression(*rung.condition).columns); - } - } + const int condition_columns = ProjectLimits::kMaximumConditionColumns; const int grid_columns = std::max(kMinimumLogicColumns, condition_columns + 1); const qreal left_rail_x = kSceneMargin + kRailInset; const qreal right_rail_x = left_rail_x + static_cast(grid_columns) * kCellWidth; @@ -974,6 +1083,7 @@ void LogicEditorWidget::reloadLogic() { const ExpressionMetrics metrics = rung.condition.has_value() ? measureExpression(*rung.condition) : ExpressionMetrics{}; + const int occupied_columns = rung.condition.has_value() ? metrics.columns : 0; const int grid_rows = std::max(1, metrics.rows); const qreal grid_top = top + kRungHeaderHeight; const qreal rung_height = kRungHeaderHeight @@ -1011,12 +1121,18 @@ void LogicEditorWidget::reloadLogic() fault_node_id_); expression_output = rendered.output; } - else + for (int column = occupied_columns; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - addPlaceholder( - *scene_, - QRectF(left_rail_x, grid_top, kCellWidth, kCellHeight), - rung.output.has_value() ? tr("无条件") : tr("添加条件")); + scene_->addItem(new EmptySlotItem( + rung.id, + column, + QPointF( + left_rail_x + (static_cast(column) + 0.5) + * kCellWidth, + main_y), + column == 0 && !rung.output.has_value())); } const bool rung_active = runtime_trace_enabled_ && traceValue( @@ -1178,6 +1294,11 @@ std::vector LogicEditorWidget::selectedExpressionIds() const { positioned_ids.emplace_back(wire->scenePos(), wire->expressionId()); } + else if (const WireCellItem *cell = + dynamic_cast(item)) + { + positioned_ids.emplace_back(cell->scenePos(), cell->expressionId()); + } } std::sort( positioned_ids.begin(), positioned_ids.end(), @@ -1193,7 +1314,10 @@ std::vector LogicEditorWidget::selectedExpressionIds() const ids.reserve(positioned_ids.size()); for (const auto &positioned_id : positioned_ids) { - ids.push_back(positioned_id.second); + if (std::find(ids.cbegin(), ids.cend(), positioned_id.second) == ids.cend()) + { + ids.push_back(positioned_id.second); + } } return ids; } @@ -1205,12 +1329,49 @@ std::vector LogicEditorWidget::selectedWireIds() const { if (const WireItem *wire = dynamic_cast(item)) { - ids.push_back(wire->expressionId()); + if (std::find(ids.cbegin(), ids.cend(), wire->expressionId()) == ids.cend()) + { + ids.push_back(wire->expressionId()); + } + } + else if (const WireCellItem *cell = + dynamic_cast(item)) + { + if (std::find(ids.cbegin(), ids.cend(), cell->expressionId()) == ids.cend()) + { + ids.push_back(cell->expressionId()); + } } } return ids; } +std::vector LogicEditorWidget::selectedEmptySlotColumns() const +{ + std::vector columns; + for (QGraphicsItem *item : scene_->selectedItems()) + { + if (const EmptySlotItem *slot = dynamic_cast(item)) + { + columns.push_back(slot->column()); + } + } + return columns; +} + +std::vector> LogicEditorWidget::selectedWireCells() const +{ + std::vector> cells; + for (QGraphicsItem *item : scene_->selectedItems()) + { + if (const WireCellItem *cell = dynamic_cast(item)) + { + cells.emplace_back(cell->expressionId(), cell->columnOffset()); + } + } + return cells; +} + std::vector LogicEditorWidget::selectedBranchIds() const { std::vector ids; @@ -1250,6 +1411,15 @@ std::string LogicEditorWidget::selectedRungId() const } rung_id = wire->rungId(); } + else if (const WireCellItem *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)) { @@ -1266,6 +1436,15 @@ std::string LogicEditorWidget::selectedRungId() const rung_id = rung->rungId(); } } + else if (const EmptySlotItem *slot = + dynamic_cast(item)) + { + if (!rung_id.empty() && rung_id != slot->rungId()) + { + return {}; + } + rung_id = slot->rungId(); + } } return rung_id; } @@ -1288,11 +1467,41 @@ LogicEditorResult LogicEditorWidget::addRung() LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) { + const std::vector selected_empty_columns = selectedEmptySlotColumns(); + const std::vector> selected_wire_cells = + selectedWireCells(); const std::vector selected_expressions = selectedExpressionIds(); const std::vector selected_wires = selectedWireIds(); const std::vector selected_ids = selectedNodeIds(); LogicEditorResult result; - if (selected_expressions.size() > 1U || selected_wires.size() > 1U) + if (selected_empty_columns.size() > 1U || selected_wire_cells.size() > 1U + || (!selected_empty_columns.empty() + && (!selected_wire_cells.empty() + || !selected_expressions.empty() || !selected_ids.empty())) + || (!selected_wire_cells.empty() + && (!selected_ids.empty() || selected_expressions.size() > 1U))) + { + result = {false, LogicEditorError::InvalidOperation, + "插入条件时只能选择一个网格或条件对象", {}}; + } + else if (selected_empty_columns.size() == 1U) + { + result = editor_service_.insertConditionAtColumn( + logic_id_, + currentRungId(), + selected_empty_columns.front(), + config); + } + else if (selected_wire_cells.size() == 1U) + { + result = editor_service_.replaceWireColumnWithCondition( + logic_id_, + currentRungId(), + selected_wire_cells.front().first, + selected_wire_cells.front().second, + config); + } + else if (selected_expressions.size() > 1U || selected_wires.size() > 1U) { result = {false, LogicEditorError::InvalidOperation, "串联插入或替换横线时只能选择一个条件对象", {}}; diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 60774cb..26629d0 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -7,6 +7,7 @@ #include #include +#include #include class QGraphicsScene; @@ -19,8 +20,10 @@ class LogicEditorWidget final : public QGraphicsView public: class NodeItem; class WireItem; + class WireCellItem; class VerticalConnectorItem; class RungItem; + class EmptySlotItem; explicit LogicEditorWidget( LogicEditorService &editor_service, @@ -66,6 +69,8 @@ private: std::vector selectedExpressionIds() const; std::vector selectedWireIds() const; std::vector selectedBranchIds() const; + std::vector selectedEmptySlotColumns() const; + std::vector> selectedWireCells() const; LogicEditorService &editor_service_; QGraphicsScene *scene_ = nullptr; diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index daa00f0..a9e76c2 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -1188,7 +1188,7 @@ 常开 - 向当前网络添加常开触点 + 在选中的空网格或横线处添加常开触点;选中条件时在其后插入 @@ -1196,7 +1196,7 @@ 常闭 - 向当前网络添加常闭触点 + 在选中的空网格或横线处添加常闭触点;选中条件时在其后插入 @@ -1204,7 +1204,7 @@ 上升沿 - 添加一个扫描周期的上升沿触点 + 在选中的空网格或横线处添加一个扫描周期的上升沿触点 @@ -1212,7 +1212,7 @@ 下降沿 - 添加一个扫描周期的下降沿触点 + 在选中的空网格或横线处添加一个扫描周期的下降沿触点 @@ -1220,12 +1220,12 @@ T 触点 - 添加离线仿真定时器触点 + 在选中的空网格或横线处添加离线仿真定时器触点 C 触点 - 添加离线仿真计数器完成触点 + 在选中的空网格或横线处添加离线仿真计数器完成触点 @@ -1284,7 +1284,7 @@ D 比较 - 向当前网络添加 D 值与常量比较 + 在选中的空网格或横线处添加 D 值与常量比较条件 diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 5519223..5379b55 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -2,6 +2,7 @@ #include "services/logic_editor_service.h" #include "services/project_service.h" +#include #include #include @@ -34,6 +35,44 @@ ContactNodeConfig contact(int address) return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; } +int conditionColumns(const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Node) + { + return 1; + } + if (expression.kind == ConditionExpressionKind::Wire) + { + return expression.wire->columnSpan; + } + int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; + for (const ConditionExpression &child : expression.children) + { + const int child_columns = conditionColumns(child); + columns = expression.kind == ConditionExpressionKind::Series + ? columns + child_columns : std::max(columns, child_columns); + } + return columns; +} + +int wireColumns(const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Node) + { + return 0; + } + if (expression.kind == ConditionExpressionKind::Wire) + { + return expression.wire->columnSpan; + } + int columns = 0; + for (const ConditionExpression &child : expression.children) + { + columns += wireColumns(child); + } + return columns; +} + void testStructuredEditingAndNormalization() { TestProjectStorage storage; @@ -295,6 +334,303 @@ void testUnconditionalOutputEditing() "an editor-created output-only network must be runnable as unconditional"); } +void testColumnTargetedConditionInsertion() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string first_rung_id = service.firstRungId(logic_id); + + require(service.insertConditionAtColumn( + logic_id, first_rung_id, 4, contact(4)).succeeded, + "an empty network must accept a condition at the selected fifth column"); + const LadderRung *rung = service.findRung(logic_id, first_rung_id); + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Series + && rung->condition->children.size() == 2U + && rung->condition->children.front().kind + == ConditionExpressionKind::Wire + && rung->condition->children.front().wire->columnSpan == 4 + && rung->condition->children.back().kind + == ConditionExpressionKind::Node + && rung->validate() + && conditionColumns(*rung->condition) == 5, + "column insertion must preserve the requested horizontal position"); + + require(service.insertConditionAtColumn( + logic_id, first_rung_id, 2, contact(2)).error + == LogicEditorError::InvalidOperation, + "inserting into an already occupied column must be rejected atomically"); + require(service.insertConditionAtColumn( + logic_id, first_rung_id, 10, contact(10)).error + == LogicEditorError::InvalidOperation, + "the eleventh condition column must be rejected"); + require(service.insertConditionAtColumn( + logic_id, first_rung_id, -1, contact(10)).error + == LogicEditorError::InvalidOperation, + "a negative grid column must be rejected"); + require(service.insertConditionAtColumn( + logic_id, + first_rung_id, + 5, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}) + .error == LogicEditorError::InvalidNode, + "a condition grid slot must reject output instructions"); + + const LogicEditorResult second_rung = service.addRung(logic_id); + require(second_rung.succeeded, + "column insertion test must create a second empty network"); + require(service.setOutput( + logic_id, + second_rung.id, + CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, + 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 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->output.has_value() + && output_rung->validate(), + "condition insertion must keep the independent output slot intact"); + + const LogicEditorResult last_column_rung = service.addRung(logic_id); + require(last_column_rung.succeeded + && service.insertConditionAtColumn( + logic_id, last_column_rung.id, 9, contact(9)).succeeded, + "the tenth condition column must remain a valid insertion target"); + const LadderRung *full_width = service.findRung( + logic_id, last_column_rung.id); + require(full_width != nullptr && full_width->condition.has_value() + && full_width->condition->kind == ConditionExpressionKind::Series + && full_width->condition->children.front().wire->columnSpan == 9 + && conditionColumns(*full_width->condition) == 10, + "last-column insertion must fill exactly the ten-column condition area"); + require(!service.appendCondition( + logic_id, last_column_rung.id, contact(10)).succeeded + && conditionColumns(*service.findRung( + logic_id, last_column_rung.id)->condition) == 10, + "a full-width grid must reject another condition without partial changes"); +} + +void testWireColumnReplacement() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = service.firstRungId(logic_id); + const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4); + require(wire.succeeded, + "wire-column replacement test must create a four-column wire"); + + require(service.replaceWireColumnWithCondition( + logic_id, rung_id, wire.id, 1, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 1}, + ContactMode::NormallyClosed}) + .succeeded, + "a selected wire cell must be replaceable without removing adjacent cells"); + const LadderRung *rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Series + && rung->condition->children.size() == 3U + && rung->condition->children.front().wire->columnSpan == 1 + && rung->condition->children.at(1).kind + == ConditionExpressionKind::Node + && std::get( + rung->condition->children.at(1).node->config).mode + == ContactMode::NormallyClosed + && rung->condition->children.back().wire->columnSpan == 2 + && conditionColumns(*rung->condition) == 4, + "wire-cell replacement must split the wire around the new contact"); + + const std::vector before_invalid = + project_service.project().controlLogics; + require(service.replaceWireColumnWithCondition( + logic_id, + rung_id, + rung->condition->children.front().id, + 1, + contact(2)) + .error == LogicEditorError::InvalidOperation, + "an out-of-range wire-cell offset must be rejected"); + require(project_service.project().controlLogics.size() == before_invalid.size() + && conditionColumns(*service.findRung( + logic_id, rung_id)->condition) == 4, + "a rejected wire-cell replacement must leave the network width unchanged"); + + const LogicEditorResult first_cell_rung = service.addRung(logic_id); + const LogicEditorResult first_cell_wire = service.appendWire( + logic_id, first_cell_rung.id, 4); + require(first_cell_rung.succeeded && first_cell_wire.succeeded + && service.replaceWireColumnWithCondition( + logic_id, + first_cell_rung.id, + first_cell_wire.id, + 0, + contact(10)) + .succeeded, + "the first cell of a multi-column wire must be replaceable"); + const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id); + require(first_cell != nullptr && first_cell->condition.has_value() + && first_cell->condition->kind == ConditionExpressionKind::Series + && first_cell->condition->children.size() == 2U + && first_cell->condition->children.front().kind + == ConditionExpressionKind::Node + && first_cell->condition->children.back().kind + == ConditionExpressionKind::Wire + && first_cell->condition->children.back().wire->columnSpan == 3, + "first-cell replacement must preserve the trailing wire cells"); + require(service.undo().succeeded, + "wire-cell replacement must be one undoable edit"); + const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id); + require(undone != nullptr && undone->condition.has_value() + && undone->condition->kind == ConditionExpressionKind::Wire + && undone->condition->wire->columnSpan == 4, + "undo must restore the original unsplit wire"); + require(service.redo().succeeded, + "wire-cell replacement must be redoable"); + const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id); + require(redone != nullptr && redone->condition.has_value() + && redone->condition->kind == ConditionExpressionKind::Series + && conditionColumns(*redone->condition) == 4, + "redo must restore the split wire without changing its width"); + const std::string redone_trailing_wire_id = + redone->condition->children.back().id; + + const LogicEditorResult last_cell_rung = service.addRung(logic_id); + const LogicEditorResult last_cell_wire = service.appendWire( + logic_id, last_cell_rung.id, 4); + require(last_cell_rung.succeeded && last_cell_wire.succeeded + && service.replaceWireColumnWithCondition( + logic_id, + last_cell_rung.id, + last_cell_wire.id, + 3, + contact(11)) + .succeeded, + "the last cell of a multi-column wire must be replaceable"); + const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id); + require(last_cell != nullptr && last_cell->condition.has_value() + && last_cell->condition->kind == ConditionExpressionKind::Series + && last_cell->condition->children.size() == 2U + && last_cell->condition->children.front().kind + == ConditionExpressionKind::Wire + && last_cell->condition->children.front().wire->columnSpan == 3 + && last_cell->condition->children.back().kind + == ConditionExpressionKind::Node, + "last-cell replacement must preserve the leading wire cells"); + + const LogicEditorResult single_cell_rung = service.addRung(logic_id); + const LogicEditorResult single_cell_wire = service.appendWire( + logic_id, single_cell_rung.id, 1); + require(single_cell_rung.succeeded && single_cell_wire.succeeded + && service.replaceWireColumnWithCondition( + logic_id, + single_cell_rung.id, + single_cell_wire.id, + 0, + contact(12)) + .succeeded, + "a one-column wire must use the same grid-cell replacement API"); + const LadderRung *single_cell = service.findRung( + logic_id, single_cell_rung.id); + require(single_cell != nullptr && single_cell->condition.has_value() + && single_cell->condition->kind == ConditionExpressionKind::Node, + "one-column wire replacement must normalize directly to a condition node"); + + require(service.replaceWireColumnWithCondition( + logic_id, + first_cell_rung.id, + redone_trailing_wire_id, + 0, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 13}, CoilMode::Set}) + .error == LogicEditorError::InvalidNode, + "wire grid cells must reject output instructions"); + require(service.replaceWireColumnWithCondition( + logic_id, + first_cell_rung.id, + "missing-wire", + 0, + contact(13)) + .error == LogicEditorError::ExpressionNotFound, + "wire grid replacement must reject an unknown wire without mutation"); +} + +void testSequentialConditionInsertionConsumesFollowingWire() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = service.firstRungId(logic_id); + const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10); + require(wire.succeeded, + "sequential wire replacement must start with a full-width wire"); + + LogicEditorResult inserted = service.replaceWireColumnWithCondition( + logic_id, rung_id, wire.id, 0, contact(0)); + require(inserted.succeeded, + "the first condition must replace the first full-wire cell"); + std::string selected_node_id = inserted.id; + for (int address = 1; address < 10; ++address) + { + inserted = service.insertConditionAfter( + logic_id, rung_id, selected_node_id, contact(address)); + require(inserted.succeeded, + "continuous condition insertion must consume the following wire cell"); + selected_node_id = inserted.id; + + const LadderRung *rung = service.findRung(logic_id, rung_id); + std::vector nodes; + collectConditionNodes(*rung->condition, &nodes); + require(rung != nullptr && rung->condition.has_value() + && conditionColumns(*rung->condition) == 10 + && nodes.size() == static_cast(address + 1) + && wireColumns(*rung->condition) == 9 - address, + "each continuous insertion must preserve width while consuming one wire cell"); + } + + const LadderRung *full = service.findRung(logic_id, rung_id); + require(full != nullptr && full->condition.has_value() + && conditionColumns(*full->condition) == 10 + && wireColumns(*full->condition) == 0, + "ten continuous insertions must replace the entire wire without expanding it"); + require(service.insertConditionAfter( + logic_id, rung_id, selected_node_id, contact(10)) + .error == LogicEditorError::InvalidOperation, + "the eleventh condition must still be rejected after all wire cells are consumed"); + require(conditionColumns(*service.findRung( + logic_id, rung_id)->condition) == 10, + "a rejected eleventh insertion must leave the full network unchanged"); + + require(service.undo().succeeded, + "a failed eleventh insertion must not displace the last successful undo step"); + const LadderRung *undone = service.findRung(logic_id, rung_id); + std::vector undone_nodes; + collectConditionNodes(*undone->condition, &undone_nodes); + require(undone_nodes.size() == 9U + && wireColumns(*undone->condition) == 1 + && conditionColumns(*undone->condition) == 10, + "undo must restore nine contacts followed by one wire cell"); + require(service.redo().succeeded, + "the final wire-consuming insertion must be redoable"); + const LadderRung *redone = service.findRung(logic_id, rung_id); + std::vector redone_nodes; + collectConditionNodes(*redone->condition, &redone_nodes); + require(redone_nodes.size() == 10U + && wireColumns(*redone->condition) == 0 + && conditionColumns(*redone->condition) == 10, + "redo must restore all ten contacts without wire cells"); +} + void testLogicLifecycleAndOrdering() { TestProjectStorage storage; @@ -472,6 +808,9 @@ int main() testBatchDeleteAllNodesInParallelBranch(); testConditionColumnLimit(); testUnconditionalOutputEditing(); + testColumnTargetedConditionInsertion(); + testWireColumnReplacement(); + testSequentialConditionInsertionConsumesFollowingWire(); testLogicLifecycleAndOrdering(); testEdgeTimerNodesAndRungComments(); testHistoryAndAtomicBatchDelete(); diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index ebcd495..8815972 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -1,5 +1,6 @@ #include "domain/active_register_repository.h" #include "domain/project_storage.h" +#include "domain/project_limits.h" #include "domain/register_repository.h" #include "services/alarm_editor_service.h" #include "services/alarm_service.h" @@ -661,6 +662,183 @@ void testRuntimeAlarmListInteraction() "AlarmList must become visible again after returning to editing"); } +void testLogicEmptyGridSlotInsertion() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = service.firstRungId(logic_id); + LogicEditorWidget editor(service); + editor.setLogicId(logic_id); + editor.resize(1400, 360); + editor.show(); + QApplication::processEvents(); + + auto insertionGridItems = [&editor] + { + QList items; + for (QGraphicsItem *item : editor.scene()->items()) + { + if (item->toolTip().startsWith(QStringLiteral("空条件网格")) + || item->toolTip().startsWith(QStringLiteral("横线网格"))) + { + items.push_back(item); + } + } + std::sort( + items.begin(), items.end(), + [](const QGraphicsItem *left, const QGraphicsItem *right) + { + return left->scenePos().x() < right->scenePos().x(); + }); + return items; + }; + + QList empty_items = insertionGridItems(); + require(empty_items.size() == ProjectLimits::kMaximumConditionColumns + && !service.findRung(logic_id, rung_id)->condition.has_value(), + "a new network must expose ten selectable grid slots without persisting placeholders"); + + const QPoint fifth_slot = editor.mapFromScene(empty_items.at(4)->scenePos()); + QTest::mouseClick( + editor.viewport(), Qt::LeftButton, Qt::NoModifier, fifth_slot); + require(editor.scene()->selectedItems().size() == 1, + "clicking an empty grid cell must select one insertion position"); + require(editor.addCondition(ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 4}, + ContactMode::NormallyOpen}) + .succeeded, + "the toolbar condition path must insert at the selected fifth column"); + + const LadderRung *rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Series + && rung->condition->children.front().kind + == ConditionExpressionKind::Wire + && rung->condition->children.front().wire->columnSpan == 4, + "a fifth-column click must become four wire columns followed by the contact"); + + empty_items = insertionGridItems(); + require(empty_items.size() == 9, + "every unoccupied column must stay clickable after the first insertion"); + const QPoint second_slot = editor.mapFromScene(empty_items.at(1)->scenePos()); + QTest::mouseClick( + editor.viewport(), Qt::LeftButton, Qt::NoModifier, second_slot); + require(editor.addCondition(ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 1}, + ContactMode::NormallyClosed}) + .succeeded, + "a grid cell inside a multi-column wire must accept a contact"); + rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Series + && rung->condition->children.size() == 4U + && rung->condition->children.front().kind + == ConditionExpressionKind::Wire + && rung->condition->children.front().wire->columnSpan == 1 + && rung->condition->children.at(2).kind + == ConditionExpressionKind::Wire + && rung->condition->children.at(2).wire->columnSpan == 2 + && std::get( + rung->condition->children.at(1).node->config).mode + == ContactMode::NormallyClosed, + "wire splitting must preserve both surrounding gaps and contact order"); + + empty_items = insertionGridItems(); + require(empty_items.size() == 8, + "two occupied columns must leave eight selectable grid positions"); + const QPoint eighth_slot = editor.mapFromScene(empty_items.at(5)->scenePos()); + QTest::mouseClick( + editor.viewport(), Qt::LeftButton, Qt::NoModifier, eighth_slot); + require(editor.addCondition(ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 7}, + ContactMode::NormallyOpen}) + .succeeded, + "a trailing empty grid cell must remain a valid insertion target"); + + require(service.undo().succeeded, + "a grid-targeted insertion must remain one undoable edit"); + editor.reloadLogic(); + require(insertionGridItems().size() == 8, + "undo must restore the prior condition layout and empty grid slots"); + + const LogicEditorResult added_rung = service.addRung(logic_id); + require(added_rung.succeeded, + "the grid-slot UI test must create another network"); + editor.reloadLogic(); + require(insertionGridItems().size() == 18 + && !service.findRung(logic_id, added_rung.id)->condition.has_value(), + "every newly added network must expose its own ten empty insertion slots"); +} + +void testLogicContinuousInsertionConsumesFullWire() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = service.firstRungId(logic_id); + LogicEditorWidget editor(service); + editor.setLogicId(logic_id); + editor.resize(1400, 360); + editor.show(); + QApplication::processEvents(); + + for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) + { + require(editor.addHorizontalWire().succeeded, + "the UI fixture must fill all ten condition columns with wires"); + } + + QList wire_cells; + for (QGraphicsItem *item : editor.scene()->items()) + { + if (item->toolTip().startsWith(QStringLiteral("横线网格"))) + { + wire_cells.push_back(item); + } + } + std::sort( + wire_cells.begin(), wire_cells.end(), + [](const QGraphicsItem *left, const QGraphicsItem *right) + { + return left->scenePos().x() < right->scenePos().x(); + }); + require(wire_cells.size() == ProjectLimits::kMaximumConditionColumns, + "a full wire network must expose ten replaceable wire cells"); + QTest::mouseClick( + editor.viewport(), + Qt::LeftButton, + Qt::NoModifier, + editor.mapFromScene(wire_cells.front()->scenePos())); + + for (int address = 0; address < ProjectLimits::kMaximumConditionColumns; ++address) + { + require(editor.addCondition(logicContact(address)).succeeded, + "repeated toolbar conditions must consume the next wire cell"); + int remaining_wire_cells = 0; + for (QGraphicsItem *item : editor.scene()->items()) + { + remaining_wire_cells += item->toolTip().startsWith( + QStringLiteral("横线网格")) ? 1 : 0; + } + require(remaining_wire_cells + == ProjectLimits::kMaximumConditionColumns - address - 1, + "each toolbar condition must consume exactly one visible wire cell"); + } + + const LadderRung *rung = service.findRung(logic_id, rung_id); + std::vector nodes; + collectConditionNodes(*rung->condition, &nodes); + require(rung != nullptr && rung->condition.has_value() + && nodes.size() == static_cast( + ProjectLimits::kMaximumConditionColumns), + "ten repeated toolbar conditions must replace the full wire with ten contacts"); + require(!editor.addCondition(logicContact(10)).succeeded, + "the toolbar must reject only the eleventh condition after all wires are consumed"); +} + void testWindowTitleTracksUnsavedProjectChanges() { TestProjectStorage storage; @@ -769,6 +947,10 @@ void testModeActionsControlEditingAvailability() window, "addNormallyOpenAction"); QAction *add_normal_coil_action = requiredChild( window, "addNormalCoilAction"); + QAction *add_set_coil_action = requiredChild( + window, "addSetCoilAction"); + QAction *add_reset_coil_action = requiredChild( + window, "addResetCoilAction"); QAction *parallel_insert_action = requiredChild( window, "parallelInsertAction"); QAction *insert_horizontal_wire_action = requiredChild( @@ -1080,6 +1262,41 @@ void testModeActionsControlEditingAvailability() require(restored_nodes.size() == 1U, "logic redo must restore the original condition node"); const std::string restored_node_id = restored_nodes.front()->id; + + QGraphicsItem *fifth_column_slot = nullptr; + for (QGraphicsItem *item : logic_editor->scene()->items()) + { + if (item->toolTip().startsWith(QStringLiteral("空条件网格")) + && item->toolTip().contains(QStringLiteral("第 5 列"))) + { + fifth_column_slot = item; + break; + } + } + require(fifth_column_slot != nullptr, + "the main-window ladder must expose the fifth condition-grid slot"); + QTest::mouseClick( + logic_editor->viewport(), + Qt::LeftButton, + Qt::NoModifier, + logic_editor->mapFromScene(fifth_column_slot->scenePos())); + add_normally_open_action->trigger(); + const LadderRung &action_inserted_rung = logic_editor_service.findLogic( + logic_editor_service.firstLogicId())->rungs.front(); + require(action_inserted_rung.condition.has_value() + && action_inserted_rung.condition->kind + == ConditionExpressionKind::Series + && action_inserted_rung.condition->children.size() == 3U + && action_inserted_rung.condition->children.at(1).kind + == ConditionExpressionKind::Wire + && action_inserted_rung.condition->children.at(1).wire->columnSpan == 3 + && action_inserted_rung.condition->children.at(2).kind + == ConditionExpressionKind::Node, + "the real contact QAction must insert at the clicked fifth grid column"); + undo_action->trigger(); + require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) + ->rungs.front().condition->kind == ConditionExpressionKind::Node, + "undo after a grid-targeted QAction must restore the prior network"); logic_editor->selectNode(restored_node_id); insert_horizontal_wire_action->trigger(); @@ -1140,6 +1357,28 @@ void testModeActionsControlEditingAvailability() "parallel branch action must create a parallel expression"); require(logic->rungs.front().output.has_value(), "coil action must set the fixed ladder output"); + const std::string output_node_id = logic->rungs.front().output->id; + add_set_coil_action->trigger(); + logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); + require(logic->rungs.front().output.has_value() + && logic->rungs.front().output->id == output_node_id + && std::get( + logic->rungs.front().output->config).mode == CoilMode::Set, + "the set-coil action must switch the existing fixed output in place"); + add_reset_coil_action->trigger(); + logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); + require(logic->rungs.front().output.has_value() + && logic->rungs.front().output->id == output_node_id + && std::get( + logic->rungs.front().output->config).mode == CoilMode::Reset, + "the reset-coil action must reuse the same fixed output slot"); + add_normal_coil_action->trigger(); + logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); + require(logic->rungs.front().output.has_value() + && logic->rungs.front().output->id == output_node_id + && std::get( + logic->rungs.front().output->config).mode == CoilMode::Normal, + "switching back to a normal coil must preserve the output node identity"); offline_action->trigger(); require(mode_service.mode() == ApplicationMode::Editing, "unconfigured ladder nodes must block offline running"); @@ -1898,6 +2137,8 @@ int main(int argc, char *argv[]) testRuntimeProgressBarRendering(); testRuntimePageJumpDoesNotRequireRegisterWritePermission(); testRuntimeAlarmListInteraction(); + testLogicEmptyGridSlotInsertion(); + testLogicContinuousInsertionConsumesFullWire(); testWindowTitleTracksUnsavedProjectChanges(); testModeActionsControlEditingAvailability(); testIndependentHmiRuntimeWindowLifecycle();