| @@ -251,6 +251,12 @@ void normalizeExpression(ConditionExpression *expression) | |||||
| std::vector<ConditionExpression> flattened; | std::vector<ConditionExpression> flattened; | ||||
| for (ConditionExpression &child : expression->children) | for (ConditionExpression &child : expression->children) | ||||
| { | { | ||||
| if ((child.kind == ConditionExpressionKind::Series | |||||
| || child.kind == ConditionExpressionKind::Parallel) | |||||
| && child.children.empty()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| if (child.kind == expression->kind) | if (child.kind == expression->kind) | ||||
| { | { | ||||
| for (ConditionExpression &grandchild : child.children) | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, node_ids.front()}; | return {true, LogicEditorError::None, {}, node_ids.front()}; | ||||
| } | } | ||||
| @@ -192,6 +192,60 @@ void testStructuredWireEditing() | |||||
| "wire branch deletion must participate in ladder undo history"); | "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() | void testConditionColumnLimit() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -415,6 +469,7 @@ int main() | |||||
| testStructuredEditingAndNormalization(); | testStructuredEditingAndNormalization(); | ||||
| testRangeParallelInsertion(); | testRangeParallelInsertion(); | ||||
| testStructuredWireEditing(); | testStructuredWireEditing(); | ||||
| testBatchDeleteAllNodesInParallelBranch(); | |||||
| testConditionColumnLimit(); | testConditionColumnLimit(); | ||||
| testUnconditionalOutputEditing(); | testUnconditionalOutputEditing(); | ||||
| testLogicLifecycleAndOrdering(); | testLogicLifecycleAndOrdering(); | ||||
| @@ -43,9 +43,11 @@ | |||||
| #include <QToolButton> | #include <QToolButton> | ||||
| #include <QTreeWidget> | #include <QTreeWidget> | ||||
| #include <algorithm> | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | |||||
| namespace { | namespace { | ||||
| @@ -285,6 +287,85 @@ QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position) | |||||
| return image.pixelColor(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() | void testRuntimeButtonMouseInteraction() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -1645,6 +1726,7 @@ int main(int argc, char *argv[]) | |||||
| try | try | ||||
| { | { | ||||
| testLogicEditorBatchDeletesWholeParallelRow(); | |||||
| testRuntimeButtonMouseInteraction(); | testRuntimeButtonMouseInteraction(); | ||||
| testRuntimeProgressBarRendering(); | testRuntimeProgressBarRendering(); | ||||
| testRuntimePageJumpDoesNotRequireRegisterWritePermission(); | testRuntimePageJumpDoesNotRequireRegisterWritePermission(); | ||||