| @@ -251,6 +251,12 @@ void normalizeExpression(ConditionExpression *expression) | |||
| std::vector<ConditionExpression> flattened; | |||
| for (ConditionExpression &child : expression->children) | |||
| { | |||
| if ((child.kind == ConditionExpressionKind::Series | |||
| || child.kind == ConditionExpressionKind::Parallel) | |||
| && child.children.empty()) | |||
| { | |||
| continue; | |||
| } | |||
| if (child.kind == expression->kind) | |||
| { | |||
| for (ConditionExpression &grandchild : child.children) | |||
| @@ -1430,6 +1430,15 @@ LogicEditorResult LogicEditorService::removeNodes( | |||
| } | |||
| } | |||
| } | |||
| for (const LadderRung &rung : editable_logic->rungs) | |||
| { | |||
| 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_ids.front()}; | |||
| } | |||
| @@ -192,6 +192,60 @@ void testStructuredWireEditing() | |||
| "wire branch deletion must participate in ladder undo history"); | |||
| } | |||
| void testBatchDeleteAllNodesInParallelBranch() | |||
| { | |||
| 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 first = service.appendCondition( | |||
| logic_id, rung_id, contact(0)); | |||
| const LogicEditorResult second = service.appendCondition( | |||
| logic_id, rung_id, contact(1)); | |||
| const LogicEditorResult third = service.appendCondition( | |||
| logic_id, rung_id, contact(2)); | |||
| const LogicEditorResult fourth = service.appendCondition( | |||
| logic_id, rung_id, contact(3)); | |||
| require(first.succeeded && second.succeeded && third.succeeded | |||
| && fourth.succeeded, | |||
| "parallel batch deletion setup contacts must be created"); | |||
| const LogicEditorResult branch = service.addParallelBranch( | |||
| logic_id, rung_id, | |||
| {second.id, third.id, fourth.id}, | |||
| contact(10)); | |||
| require(branch.succeeded, | |||
| "parallel batch deletion setup branch must be created"); | |||
| require(service.removeNodes( | |||
| logic_id, {second.id, third.id, fourth.id}) | |||
| .succeeded, | |||
| "deleting every node in a parallel branch as one batch must succeed"); | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && rung->condition.has_value() | |||
| && rung->validate(), | |||
| "batch deletion must leave a valid normalized ladder expression"); | |||
| require(rung->condition->kind == ConditionExpressionKind::Series | |||
| && rung->condition->children.size() == 2U | |||
| && rung->condition->children.at(0).kind | |||
| == ConditionExpressionKind::Node | |||
| && rung->condition->children.at(1).kind | |||
| == ConditionExpressionKind::Node, | |||
| "an empty parallel branch must collapse into the remaining branch"); | |||
| require(service.findNode(logic_id, second.id) == nullptr | |||
| && service.findNode(logic_id, third.id) == nullptr | |||
| && service.findNode(logic_id, fourth.id) == nullptr, | |||
| "all selected parallel branch nodes must be removed"); | |||
| require(service.undo().succeeded, | |||
| "parallel batch deletion must be undoable"); | |||
| require(service.findNode(logic_id, second.id) != nullptr | |||
| && service.findNode(logic_id, third.id) != nullptr | |||
| && service.findNode(logic_id, fourth.id) != nullptr, | |||
| "undo must restore every deleted parallel branch node"); | |||
| } | |||
| void testConditionColumnLimit() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -415,6 +469,7 @@ int main() | |||
| testStructuredEditingAndNormalization(); | |||
| testRangeParallelInsertion(); | |||
| testStructuredWireEditing(); | |||
| testBatchDeleteAllNodesInParallelBranch(); | |||
| testConditionColumnLimit(); | |||
| testUnconditionalOutputEditing(); | |||
| testLogicLifecycleAndOrdering(); | |||
| @@ -43,9 +43,11 @@ | |||
| #include <QToolButton> | |||
| #include <QTreeWidget> | |||
| #include <algorithm> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| #include <vector> | |||
| namespace { | |||
| @@ -285,6 +287,85 @@ QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position) | |||
| return image.pixelColor(viewport_position); | |||
| } | |||
| ContactNodeConfig logicContact(int address) | |||
| { | |||
| return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; | |||
| } | |||
| void testLogicEditorBatchDeletesWholeParallelRow() | |||
| { | |||
| 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); | |||
| std::vector<std::string> top_ids; | |||
| for (int address = 0; address < 4; ++address) | |||
| { | |||
| const LogicEditorResult result = service.appendCondition( | |||
| logic_id, rung_id, logicContact(address)); | |||
| require(result.succeeded, | |||
| "logic UI batch deletion setup contacts must be created"); | |||
| top_ids.push_back(result.id); | |||
| } | |||
| const LogicEditorResult lower_first = service.addParallelBranch( | |||
| logic_id, rung_id, top_ids, logicContact(10)); | |||
| require(lower_first.succeeded, | |||
| "logic UI batch deletion setup branch must be created"); | |||
| std::string lower_tail = lower_first.id; | |||
| for (int address = 11; address < 14; ++address) | |||
| { | |||
| const LogicEditorResult result = service.insertConditionAfter( | |||
| logic_id, rung_id, lower_tail, logicContact(address)); | |||
| require(result.succeeded, | |||
| "logic UI batch deletion lower branch contacts must be created"); | |||
| lower_tail = result.id; | |||
| } | |||
| LogicEditorWidget view(service); | |||
| view.setLogicId(logic_id); | |||
| qreal lower_row_y = 0.0; | |||
| for (QGraphicsItem *item : view.scene()->items()) | |||
| { | |||
| if (qFuzzyCompare(item->zValue(), 3.0)) | |||
| { | |||
| lower_row_y = std::max(lower_row_y, item->scenePos().y()); | |||
| } | |||
| } | |||
| int selected_count = 0; | |||
| for (QGraphicsItem *item : view.scene()->items()) | |||
| { | |||
| if (qFuzzyCompare(item->zValue(), 3.0) | |||
| && qFuzzyCompare(item->scenePos().y(), lower_row_y)) | |||
| { | |||
| item->setSelected(true); | |||
| ++selected_count; | |||
| } | |||
| } | |||
| require(selected_count == 4, | |||
| "logic UI batch deletion must select every contact in the lower branch"); | |||
| require(view.deleteSelected().succeeded, | |||
| "logic UI must batch delete a complete parallel row"); | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && rung->condition.has_value() && rung->validate(), | |||
| "logic UI batch deletion must leave a valid ladder expression"); | |||
| require(rung->condition->kind == ConditionExpressionKind::Series | |||
| && rung->condition->children.size() == 4U, | |||
| "logic UI batch deletion must preserve only the original series row"); | |||
| const QList<QGraphicsItem *> rendered_items = view.scene()->items(); | |||
| const bool has_vertical_connector = std::any_of( | |||
| rendered_items.cbegin(), | |||
| rendered_items.cend(), | |||
| [](const QGraphicsItem *item) | |||
| { | |||
| return qFuzzyCompare(item->zValue(), 2.5); | |||
| }); | |||
| require(!has_vertical_connector, | |||
| "logic UI must not render a vertical connector after removing the branch"); | |||
| } | |||
| void testRuntimeButtonMouseInteraction() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -1645,6 +1726,7 @@ int main(int argc, char *argv[]) | |||
| try | |||
| { | |||
| testLogicEditorBatchDeletesWholeParallelRow(); | |||
| testRuntimeButtonMouseInteraction(); | |||
| testRuntimeProgressBarRendering(); | |||
| testRuntimePageJumpDoesNotRequireRegisterWritePermission(); | |||