#include "domain/project_storage.h" #include "services/logic_editor_service.h" #include "services/project_service.h" #include #include #include namespace { class TestProjectStorage final : public ProjectStorage { public: ProjectSaveResult save(const Project &, const std::string &) override { return {true, ProjectStorageError::None, {}}; } ProjectLoadResult load(const std::string &) override { return {false, {}, ProjectStorageError::FileReadFailed, {}}; } }; void require(bool condition, const std::string &message) { if (!condition) { throw std::runtime_error(message); } } 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; 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)); require(first.succeeded && second.succeeded, "series append must succeed"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.size() == 2U, "two appended nodes must form a series expression"); const std::string second_expression_id = second.id; const LogicEditorResult parallel = service.addParallelBranch( logic_id, rung_id, {second_expression_id}, contact(2)); require(parallel.succeeded, "parallel insertion must succeed"); rung = service.findRung(logic_id, rung_id); const ConditionExpression *parallel_expression = service.findExpression( logic_id, rung_id, rung->condition->children.at(1).id); require(parallel_expression != nullptr && parallel_expression->kind == ConditionExpressionKind::Parallel, "selected node must become a parallel expression"); const LogicEditorResult nested_series = service.insertConditionAfter( logic_id, rung_id, parallel.id, contact(3)); require(nested_series.succeeded, "a parallel branch must accept a series node"); rung = service.findRung(logic_id, rung_id); require(rung->condition->kind == ConditionExpressionKind::Series, "root must remain a series expression"); const ConditionExpression &nested_parallel_expression = rung->condition->children.at(1); require(nested_parallel_expression.kind == ConditionExpressionKind::Parallel && nested_parallel_expression.children.at(1).kind == ConditionExpressionKind::Series, "editor must express A AND (B OR (C AND D))"); require(service.removeNode(logic_id, nested_series.id).succeeded, "nested series node deletion must succeed"); rung = service.findRung(logic_id, rung_id); require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel && rung->condition->children.at(1).children.at(1).kind == ConditionExpressionKind::Node, "single-child series container must collapse after deletion"); require(service.removeNode(logic_id, parallel.id).succeeded, "parallel leaf deletion must succeed"); rung = service.findRung(logic_id, rung_id); require(rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.size() == 2U, "single-child parallel container must collapse after deletion"); require(service.setOutput( logic_id, rung_id, CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}) .succeeded, "output coil must be set"); require(service.findLogic(logic_id)->validate(), "structured editing result must remain a valid draft"); } void testRangeParallelInsertion() { 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); service.appendCondition(logic_id, rung_id, contact(0)); service.appendCondition(logic_id, rung_id, contact(1)); service.appendCondition(logic_id, rung_id, contact(2)); const LogicEditorResult branch = service.addParallelBranch( logic_id, rung_id, {"contact-2", "contact-3"}, contact(3)); require(branch.succeeded, "a continuous series range must accept a parallel branch"); const ConditionExpression &root = *service.findRung(logic_id, rung_id)->condition; require(root.kind == ConditionExpressionKind::Series && root.children.size() == 2U && root.children.at(1).kind == ConditionExpressionKind::Parallel && root.children.at(1).children.front().kind == ConditionExpressionKind::Series, "range insertion must express A AND ((B AND C) OR D)"); require(root.validate(), "range insertion must preserve normalized topology"); const LogicEditorResult invalid = service.addParallelBranch( logic_id, rung_id, {"contact-1", "contact-3"}, contact(4)); require(!invalid.succeeded && invalid.error == LogicEditorError::InvalidOperation, "a non-contiguous selection must be rejected"); } void testStructuredWireEditing() { 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); service.appendCondition(logic_id, rung_id, contact(0)); service.appendCondition(logic_id, rung_id, contact(1)); service.appendCondition(logic_id, rung_id, contact(2)); const LogicEditorResult branch = service.addParallelWireBranch( logic_id, rung_id, {"contact-2", "contact-3"}); require(branch.succeeded && branch.id == "wire-1", "a continuous range must accept a structured horizontal bypass"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel, "a vertical connection must produce a parallel expression"); const ConditionExpression &wire = rung->condition->children.at(1).children.at(1); require(wire.kind == ConditionExpressionKind::Wire && wire.wire->columnSpan == 2, "the bypass wire span must match the selected two-column range"); const LogicEditorResult replacement = service.replaceWireWithCondition( logic_id, rung_id, branch.id, contact(3)); require(replacement.succeeded && replacement.id == "contact-4", "a selected wire must be replaceable by a configured node type"); const LogicEditorResult extension = service.insertWireAfter( logic_id, rung_id, replacement.id); require(extension.succeeded && extension.id == "wire-1", "a wire id must become reusable after its expression is replaced"); rung = service.findRung(logic_id, rung_id); const ConditionExpression *extended_branch = service.findExpression( logic_id, rung_id, rung->condition->children.at(1).children.at(1).id); require(extended_branch != nullptr && extended_branch->kind == ConditionExpressionKind::Series && extended_branch->children.at(1).kind == ConditionExpressionKind::Wire, "inserting a horizontal wire after a branch node must preserve structure"); const std::string extended_branch_id = extended_branch->id; require(!service.addParallelWireBranch( logic_id, rung_id, {"contact-1", "contact-3"}).succeeded, "non-contiguous wire connection targets must be rejected"); require(service.removeExpressions( logic_id, rung_id, {extended_branch_id}).succeeded, "deleting a selected vertical connection branch must succeed atomically"); rung = service.findRung(logic_id, rung_id); require(rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.size() == 3U, "removing a bypass branch must normalize back to the original series"); require(service.undo().succeeded && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, "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; 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); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded, "the first ten condition columns must be editable"); } const LogicEditorResult overflow = service.appendCondition( logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns)); require(!overflow.succeeded && overflow.error == LogicEditorError::InvalidOperation, "the eleventh condition column must be rejected by the editor service"); 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() == static_cast( ProjectLimits::kMaximumConditionColumns), "a rejected eleventh column must leave the ten-column network unchanged"); } void testUnconditionalOutputEditing() { 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); require(service.setOutput( logic_id, rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, true) .succeeded, "the editor must allow a coil before any condition is added"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && !rung->condition.has_value() && rung->output.has_value() && rung->validateForRunning(), "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; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string first_id = service.ensureDefaultLogic().id; const LogicEditorResult second = service.addLogic("Safety logic"); const LogicEditorResult third = service.addLogic("Alarm logic"); require(second.succeeded && third.succeeded, "multiple control logic modules must be creatable"); require(service.renameLogic(second.id, "Interlock logic").succeeded, "control logic modules must be renamable by stable id"); require(service.renameLogic(third.id, "Interlock logic").error == LogicEditorError::DuplicateName, "control logic names must remain unique"); require(service.moveLogic(third.id, -1).succeeded && project_service.project().controlLogics.at(1).id == third.id, "logic scan order must follow editable vector order"); require(service.setLogicEnabled(second.id, false).succeeded && !service.findLogic(second.id)->enabled, "a control logic module must support explicit disable and enable"); require(service.setLogicEnabled(second.id, true).succeeded && service.findLogic(second.id)->enabled, "a disabled control logic module must be re-enableable"); require(service.removeLogic(third.id).succeeded, "a non-final control logic module must be deletable"); require(service.removeLogic(second.id).succeeded, "logic deletion must preserve the remaining module"); require(service.removeLogic(first_id).error == LogicEditorError::LastLogicRequired, "the project must retain at least one control logic module"); } void testEdgeTimerNodesAndRungComments() { 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 rising = service.appendCondition( logic_id, rung_id, EdgeContactNodeConfig{ RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising}); require(rising.succeeded && rising.id == "edge-1", "the editor must create rising edge nodes with a stable prefix"); const LogicEditorResult timer = service.addParallelBranch( logic_id, rung_id, {rising.id}, TimerContactNodeConfig{TimerAddress{2}, ContactMode::NormallyOpen}); require(timer.succeeded && timer.id == "timer-contact-1", "the editor must insert T contacts as conditions"); const LogicEditorResult ton = service.setOutput( logic_id, rung_id, TonNodeConfig{TimerAddress{2}, 500}); require(ton.succeeded && ton.id == "ton-1", "the editor must create TON outputs with a stable prefix"); require(service.updateNodeConfig( logic_id, rising.id, EdgeContactNodeConfig{ RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling}) .succeeded, "the editor must apply edge mode and M address properties"); require(service.updateNodeConfig( logic_id, timer.id, TimerContactNodeConfig{TimerAddress{4}, ContactMode::NormallyClosed}) .succeeded, "the editor must apply T contact mode and address properties"); require(service.updateNodeConfig( logic_id, ton.id, TonNodeConfig{TimerAddress{4}, 750}) .succeeded, "the editor must apply TON preset properties"); require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded, "the editor must update a network comment by stable rung id"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->comment == "延时启动网络" && std::get( rung->condition->children.front().node->config).mode == EdgeMode::Falling && std::get( rung->condition->children.at(1).node->config).address == TimerAddress{4} && std::get(rung->output->config).presetMs == 750, "edge, T contact, TON and rung comment updates must remain in the model"); } void testHistoryAndAtomicBatchDelete() { 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); const LogicEditorResult first = service.appendCondition( logic_id, first_rung_id, contact(0)); const LogicEditorResult second = service.appendCondition( logic_id, first_rung_id, contact(1)); const LogicEditorResult second_rung = service.addRung(logic_id); const LogicEditorResult third = service.appendCondition( logic_id, second_rung.id, contact(2)); require(first.succeeded && second.succeeded && second_rung.succeeded && third.succeeded, "nodes for history testing must be created"); service.clearHistory(); require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded, "batch node deletion must validate every id before changing the logic"); require(service.findNode(logic_id, first.id) != nullptr && service.findNode(logic_id, third.id) != nullptr && !service.canUndo(), "failed batch node deletion must be atomic and leave history unchanged"); require(service.removeNodes(logic_id, {first.id, third.id}).succeeded, "valid nodes across multiple rungs must be deleted together"); require(service.findNode(logic_id, first.id) == nullptr && service.findNode(logic_id, third.id) == nullptr, "all selected nodes must be removed by one batch operation"); require(service.undo().succeeded && service.findNode(logic_id, first.id) != nullptr && service.findNode(logic_id, third.id) != nullptr, "logic undo must restore a cross-rung batch deletion"); require(service.redo().succeeded && service.findNode(logic_id, first.id) == nullptr && service.findNode(logic_id, third.id) == nullptr, "logic redo must reapply a cross-rung batch deletion"); service.clearHistory(); const ControlLogic *logic = service.findLogic(logic_id); require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded && !service.canUndo(), "setting an unchanged logic state must not consume history"); service.clearHistory(); for (int index = 1; index <= 101; ++index) { require(service.updateRungComment( logic_id, first_rung_id, "comment-" + std::to_string(index)) .succeeded, "repeated valid rung edits must succeed"); } int undo_count = 0; while (service.undo().succeeded) { ++undo_count; } require(undo_count == 100, "logic history must retain exactly the configured 100 most recent steps"); require(service.redo().succeeded, "logic redo must be available after an undo"); require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded, "a new logic edit must succeed after undo"); require(!service.canRedo(), "a new logic edit must clear the redo history"); (void)second; } } // namespace int main() { try { testStructuredEditingAndNormalization(); testRangeParallelInsertion(); testStructuredWireEditing(); testBatchDeleteAllNodesInParallelBranch(); testConditionColumnLimit(); testUnconditionalOutputEditing(); testColumnTargetedConditionInsertion(); testWireColumnReplacement(); testSequentialConditionInsertionConsumesFollowingWire(); testLogicLifecycleAndOrdering(); testEdgeTimerNodesAndRungComments(); testHistoryAndAtomicBatchDelete(); } catch (const std::exception &error) { std::cerr << "logic editor service tests failed: " << error.what() << '\n'; return 1; } std::cout << "logic editor service tests passed\n"; return 0; }