| @@ -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, | |||
| @@ -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, | |||
| @@ -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<qreal>(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<qreal>(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<qreal>(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<std::string> LogicEditorWidget::selectedExpressionIds() const | |||
| { | |||
| positioned_ids.emplace_back(wire->scenePos(), wire->expressionId()); | |||
| } | |||
| else if (const WireCellItem *cell = | |||
| dynamic_cast<const WireCellItem *>(item)) | |||
| { | |||
| positioned_ids.emplace_back(cell->scenePos(), cell->expressionId()); | |||
| } | |||
| } | |||
| std::sort( | |||
| positioned_ids.begin(), positioned_ids.end(), | |||
| @@ -1193,7 +1314,10 @@ std::vector<std::string> 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<std::string> LogicEditorWidget::selectedWireIds() const | |||
| { | |||
| if (const WireItem *wire = dynamic_cast<const WireItem *>(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<const WireCellItem *>(item)) | |||
| { | |||
| if (std::find(ids.cbegin(), ids.cend(), cell->expressionId()) == ids.cend()) | |||
| { | |||
| ids.push_back(cell->expressionId()); | |||
| } | |||
| } | |||
| } | |||
| return ids; | |||
| } | |||
| std::vector<int> LogicEditorWidget::selectedEmptySlotColumns() const | |||
| { | |||
| std::vector<int> columns; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| if (const EmptySlotItem *slot = dynamic_cast<const EmptySlotItem *>(item)) | |||
| { | |||
| columns.push_back(slot->column()); | |||
| } | |||
| } | |||
| return columns; | |||
| } | |||
| std::vector<std::pair<std::string, int>> LogicEditorWidget::selectedWireCells() const | |||
| { | |||
| std::vector<std::pair<std::string, int>> cells; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| if (const WireCellItem *cell = dynamic_cast<const WireCellItem *>(item)) | |||
| { | |||
| cells.emplace_back(cell->expressionId(), cell->columnOffset()); | |||
| } | |||
| } | |||
| return cells; | |||
| } | |||
| std::vector<std::string> LogicEditorWidget::selectedBranchIds() const | |||
| { | |||
| std::vector<std::string> ids; | |||
| @@ -1250,6 +1411,15 @@ std::string LogicEditorWidget::selectedRungId() const | |||
| } | |||
| rung_id = wire->rungId(); | |||
| } | |||
| else if (const WireCellItem *cell = | |||
| dynamic_cast<const WireCellItem *>(item)) | |||
| { | |||
| if (!rung_id.empty() && rung_id != cell->rungId()) | |||
| { | |||
| return {}; | |||
| } | |||
| rung_id = cell->rungId(); | |||
| } | |||
| else if (const VerticalConnectorItem *connector = | |||
| dynamic_cast<const VerticalConnectorItem *>(item)) | |||
| { | |||
| @@ -1266,6 +1436,15 @@ std::string LogicEditorWidget::selectedRungId() const | |||
| rung_id = rung->rungId(); | |||
| } | |||
| } | |||
| else if (const EmptySlotItem *slot = | |||
| dynamic_cast<const EmptySlotItem *>(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<int> selected_empty_columns = selectedEmptySlotColumns(); | |||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | |||
| selectedWireCells(); | |||
| const std::vector<std::string> selected_expressions = selectedExpressionIds(); | |||
| const std::vector<std::string> selected_wires = selectedWireIds(); | |||
| const std::vector<std::string> 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, | |||
| "串联插入或替换横线时只能选择一个条件对象", {}}; | |||
| @@ -7,6 +7,7 @@ | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| #include <utility> | |||
| #include <vector> | |||
| 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<std::string> selectedExpressionIds() const; | |||
| std::vector<std::string> selectedWireIds() const; | |||
| std::vector<std::string> selectedBranchIds() const; | |||
| std::vector<int> selectedEmptySlotColumns() const; | |||
| std::vector<std::pair<std::string, int>> selectedWireCells() const; | |||
| LogicEditorService &editor_service_; | |||
| QGraphicsScene *scene_ = nullptr; | |||
| @@ -1188,7 +1188,7 @@ | |||
| <string>常开</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>向当前网络添加常开触点</string> | |||
| <string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string> | |||
| </property> | |||
| </action> | |||
| <action name="addNormallyClosedAction"> | |||
| @@ -1196,7 +1196,7 @@ | |||
| <string>常闭</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>向当前网络添加常闭触点</string> | |||
| <string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string> | |||
| </property> | |||
| </action> | |||
| <action name="addRisingEdgeAction"> | |||
| @@ -1204,7 +1204,7 @@ | |||
| <string>上升沿</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>添加一个扫描周期的上升沿触点</string> | |||
| <string>在选中的空网格或横线处添加一个扫描周期的上升沿触点</string> | |||
| </property> | |||
| </action> | |||
| <action name="addFallingEdgeAction"> | |||
| @@ -1212,7 +1212,7 @@ | |||
| <string>下降沿</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>添加一个扫描周期的下降沿触点</string> | |||
| <string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string> | |||
| </property> | |||
| </action> | |||
| <action name="addTimerContactAction"> | |||
| @@ -1220,12 +1220,12 @@ | |||
| <string>T 触点</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>添加离线仿真定时器触点</string> | |||
| <string>在选中的空网格或横线处添加离线仿真定时器触点</string> | |||
| </property> | |||
| </action> | |||
| <action name="addCounterContactAction"> | |||
| <property name="text"><string>C 触点</string></property> | |||
| <property name="toolTip"><string>添加离线仿真计数器完成触点</string></property> | |||
| <property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property> | |||
| </action> | |||
| <action name="addNormalCoilAction"> | |||
| <property name="text"> | |||
| @@ -1284,7 +1284,7 @@ | |||
| <string>D 比较</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>向当前网络添加 D 值与常量比较</string> | |||
| <string>在选中的空网格或横线处添加 D 值与常量比较条件</string> | |||
| </property> | |||
| </action> | |||
| <action name="editRungCommentAction"> | |||
| @@ -2,6 +2,7 @@ | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include <algorithm> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| @@ -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<ContactNodeConfig>( | |||
| 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<ControlLogic> 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<const LogicNode *> nodes; | |||
| collectConditionNodes(*rung->condition, &nodes); | |||
| require(rung != nullptr && rung->condition.has_value() | |||
| && conditionColumns(*rung->condition) == 10 | |||
| && nodes.size() == static_cast<std::size_t>(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<const LogicNode *> 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<const LogicNode *> 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(); | |||
| @@ -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<QGraphicsItem *> 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<QGraphicsItem *> 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<ContactNodeConfig>( | |||
| 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<QGraphicsItem *> 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<const LogicNode *> nodes; | |||
| collectConditionNodes(*rung->condition, &nodes); | |||
| require(rung != nullptr && rung->condition.has_value() | |||
| && nodes.size() == static_cast<std::size_t>( | |||
| 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<QAction>( | |||
| window, "addNormalCoilAction"); | |||
| QAction *add_set_coil_action = requiredChild<QAction>( | |||
| window, "addSetCoilAction"); | |||
| QAction *add_reset_coil_action = requiredChild<QAction>( | |||
| window, "addResetCoilAction"); | |||
| QAction *parallel_insert_action = requiredChild<QAction>( | |||
| window, "parallelInsertAction"); | |||
| QAction *insert_horizontal_wire_action = requiredChild<QAction>( | |||
| @@ -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<CoilNodeConfig>( | |||
| 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<CoilNodeConfig>( | |||
| 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<CoilNodeConfig>( | |||
| 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(); | |||