#include "domain/project_storage.h" #include "domain/project_limits.h" #include "services/logic_editor_service.h" #include "services/logic_command_service.h" #include "services/editor_history.h" #include "services/project_service.h" #include "support/test_support.h" #include #include #include namespace { using TestProjectStorage = TestSupport::InMemoryProjectStorage; using TestSupport::require; ContactNodeConfig contact(int address) { return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; } std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id) { const LogicEditorResult result = service.addRung(logic_id); require(result.succeeded, "test fixture must create an empty network"); return result.id; } int conditionColumns(const ConditionExpression &expression) { if (expression.kind == ConditionExpressionKind::Node) { return 1; } if (expression.kind == ConditionExpressionKind::Wire) { return expression.wire->columnSpan; } if (expression.kind == ConditionExpressionKind::Gap) { return expression.gap->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; } if (expression.kind == ConditionExpressionKind::Gap) { return 0; } int columns = 0; for (const ConditionExpression &child : expression.children) { columns += wireColumns(child); } return columns; } int gapColumns(const ConditionExpression &expression) { if (expression.kind == ConditionExpressionKind::Gap) { return expression.gap->columnSpan; } if (expression.kind == ConditionExpressionKind::Node || expression.kind == ConditionExpressionKind::Wire) { return 0; } int columns = 0; for (const ConditionExpression &child : expression.children) { columns += gapColumns(child); } return columns; } int conditionNodes(const ConditionExpression &expression) { if (expression.kind == ConditionExpressionKind::Node) { return 1; } if (expression.kind == ConditionExpressionKind::Wire) { return 0; } if (expression.kind == ConditionExpressionKind::Gap) { return 0; } int count = 0; for (const ConditionExpression &child : expression.children) { count += conditionNodes(child); } return count; } const ConditionExpression *firstExpressionOfKind( const ConditionExpression &expression, ConditionExpressionKind kind) { if (expression.kind == kind) { return &expression; } for (const ConditionExpression &child : expression.children) { if (const ConditionExpression *found = firstExpressionOfKind(child, kind)) { return found; } } return nullptr; } void testLogicCommandParsing() { const LogicCommandParseResult load = LogicCommandService::parse("ldi m4000"); require(load.succeeded && load.command.opcode == LogicCommandOpcode::LoadInverse && std::get(load.command.config).address.index() == 4000 && std::get(load.command.config).mode == ContactMode::NormallyClosed, "LDI must parse case-insensitively into a configured contact"); const LogicCommandParseResult add = LogicCommandService::parse( "ADD D0 D1 D4000"); require(add.succeeded && add.command.opcode == LogicCommandOpcode::Add && std::get(add.command.config) .destination.index() == 4000, "ADD must parse three D operands through the shared address rules"); const LogicCommandParseResult compare = LogicCommandService::parse( "LD<= D12 -3"); require(compare.succeeded && compare.command.opcode == LogicCommandOpcode::CompareLessThanOrEqual && std::get(compare.command.config).address.index() == 12 && std::get(compare.command.config).value == -3, "LD comparison commands must parse a D address and signed constant"); const LogicCommandParseResult move_constant = LogicCommandService::parse( "MOV -7 D20"); require(move_constant.succeeded && std::get(move_constant.command.config).source.kind == WordOperandKind::Constant && std::get(move_constant.command.config).source.constant == -7, "MOV must preserve constant sources when an existing output is reopened"); const LogicCommandParseResult add_constant = LogicCommandService::parse( "ADD D0 1 D2"); require(add_constant.succeeded && std::get(add_constant.command.config).right.kind == WordOperandKind::Constant, "ADD must accept constant operands used by the instruction dialog"); require(!LogicCommandService::parse("LD D0").succeeded, "contact commands must reject D addresses"); require(!LogicCommandService::parse("ADD D0 D1 D4001").succeeded, "data commands must reject out-of-range D addresses"); require(!LogicCommandService::parse("TON T0").succeeded, "unsupported commands must be rejected explicitly"); require(!LogicCommandService::parse("OUT M0 M1").succeeded, "commands with the wrong operand count must be rejected"); TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService editor(project_service); LogicCommandService commands(editor); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(editor, logic_id); LogicCommandRequest and_without_load; and_without_load.logicId = logic_id; and_without_load.text = "AND M0"; and_without_load.target = { LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; require(!commands.execute(and_without_load).succeeded, "AND without a preceding LD must be rejected"); } void testContinuousLogicCommands() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService editor(project_service); LogicCommandService commands(editor); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(editor, logic_id); LogicCommandRequest request; request.logicId = logic_id; request.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; request.text = "LD M1"; const LogicCommandResult load = commands.execute(request); require(load.succeeded && load.rungId == rung_id, "LD on an empty cell must create the first condition in that network"); request.continuing = true; request.currentRungId = load.rungId; request.text = "AND M2"; const LogicCommandResult and_result = commands.execute(request); require(and_result.succeeded, "AND must append to the current command-input network"); request.text = "OR M3"; const LogicCommandResult or_result = commands.execute(request); require(or_result.succeeded, "OR must parallel the complete accumulated condition: " + or_result.message); const LadderRung *rung = editor.findRung(logic_id, rung_id); const ConditionExpression *parallel = firstExpressionOfKind( *rung->condition, ConditionExpressionKind::Parallel); require(parallel != nullptr && parallel->children.size() == 2U && parallel->children.front().kind == ConditionExpressionKind::Series && conditionNodes(parallel->children.front()) == 2 && conditionNodes(parallel->children.back()) == 1, "LD M1, AND M2, OR M3 must form (M1 AND M2) OR M3"); request.text = "OUT M4"; const LogicCommandResult output = commands.execute(request); rung = editor.findRung(logic_id, rung_id); require(output.succeeded && rung != nullptr && rung->validateForRunning(), "command-created contacts and output must be configured and runnable"); request.text = "LDI M5"; const LogicCommandResult next_load = commands.execute(request); require(next_load.succeeded && next_load.rungId != rung_id && editor.findLogic(logic_id)->rungs.size() == 2U, "a consecutive LD after an output must create the next network"); } void testExistingNodeCommandReplacement() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService editor(project_service); LogicCommandService commands(editor); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(editor, logic_id); const LogicEditorResult contact_result = editor.appendCondition( logic_id, rung_id, contact(10), true); require(contact_result.succeeded, "existing-node command fixture must create a contact"); LogicCommandRequest replace_contact; replace_contact.logicId = logic_id; replace_contact.text = "LDI M11"; replace_contact.target = { LogicCommandTargetKind::ExistingNode, rung_id, contact_result.id, 0}; const LogicCommandResult replaced_contact = commands.execute(replace_contact); const LogicNode *contact_node = editor.findNode(logic_id, contact_result.id); require(replaced_contact.succeeded && contact_node != nullptr && std::holds_alternative(contact_node->config) && std::get(contact_node->config).address.index() == 11 && std::get(contact_node->config).mode == ContactMode::NormallyClosed, "an existing contact must accept a load command replacement"); const LogicEditorResult output_result = editor.setOutput( logic_id, rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, true); require(output_result.succeeded, "existing-node command fixture must create an output"); LogicCommandRequest replace_output; replace_output.logicId = logic_id; replace_output.text = "SET M21"; replace_output.target = { LogicCommandTargetKind::ExistingNode, rung_id, output_result.id, 0}; const LogicCommandResult replaced_output = commands.execute(replace_output); const LogicNode *output_node = editor.findNode(logic_id, output_result.id); require(replaced_output.succeeded && output_node != nullptr && std::holds_alternative(output_node->config) && std::get(output_node->config).address.index() == 21 && std::get(output_node->config).mode == CoilMode::Set, "an existing output must accept an output command replacement"); LogicCommandRequest wrong_type = replace_contact; wrong_type.text = "OUT M30"; require(!commands.execute(wrong_type).succeeded, "an existing condition must reject an output command replacement"); } void testPositionedLogicCommandsAndAtomicFailures() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService editor(project_service); LogicCommandService commands(editor); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(editor, logic_id); const LogicEditorResult first = editor.appendCondition( logic_id, rung_id, contact(10), true); const LogicEditorResult second = editor.appendCondition( logic_id, rung_id, contact(11), true); LogicCommandRequest selected_or; selected_or.logicId = logic_id; selected_or.text = "ORI M12"; selected_or.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 2}; selected_or.parallelNodeIds = {first.id, second.id}; require(commands.execute(selected_or).succeeded, "ORI on an existing network must use the selected continuous range"); const std::string wire_rung = makeEmptyRung(editor, logic_id); const LogicEditorResult wire = editor.appendWire(logic_id, wire_rung, 3); LogicCommandRequest wire_command; wire_command.logicId = logic_id; wire_command.text = "LDP M20"; wire_command.target = { LogicCommandTargetKind::WireColumn, wire_rung, wire.id, 1}; const LogicCommandResult inserted = commands.execute(wire_command); require(inserted.succeeded && editor.findNode(logic_id, inserted.id)->isConfigured(), "a command on a wire cell must replace exactly that cell"); const std::string gap_rung = makeEmptyRung(editor, logic_id); const LogicEditorResult gap_wire = editor.appendWire(logic_id, gap_rung, 2); require(editor.disconnectWireCells(logic_id, gap_rung, {{gap_wire.id, 0}}).succeeded, "gap command fixture must disconnect one wire cell"); const ConditionExpression *gap = firstExpressionOfKind( *editor.findRung(logic_id, gap_rung)->condition, ConditionExpressionKind::Gap); LogicCommandRequest gap_command; gap_command.logicId = logic_id; gap_command.text = "LD M21"; gap_command.target = { LogicCommandTargetKind::GapColumn, gap_rung, gap->id, 0}; require(commands.execute(gap_command).succeeded && gapColumns(*editor.findRung(logic_id, gap_rung)->condition) == 0, "a command on a gap cell must repair that exact gap"); editor.clearHistory(); const std::size_t rung_count = editor.findLogic(logic_id)->rungs.size(); LogicCommandRequest invalid = gap_command; invalid.text = "ADD D0 D1 D4001"; require(!commands.execute(invalid).succeeded && editor.findLogic(logic_id)->rungs.size() == rung_count && !editor.canUndo(), "an invalid command must not change the project or undo history"); LogicCommandRequest wrong_position = gap_command; wrong_position.text = "OUT M30"; require(!commands.execute(wrong_position).succeeded && !editor.canUndo(), "an output command in the condition area must be rejected atomically"); } void testEmptyLogicCreatesNetworksOnFirstEdit() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; require(service.findLogic(logic_id)->rungs.empty(), "a default control logic must start without an empty network"); const LogicEditorResult first_condition = service.appendCondition( logic_id, {}, contact(0)); require(first_condition.succeeded && service.findLogic(logic_id)->rungs.size() == 1U, "the first condition must create network 1 atomically"); const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front(); require(condition_rung.condition.has_value() && condition_rung.condition->kind == ConditionExpressionKind::Node, "the first condition must not create an implicit horizontal wire"); require(service.removeRung(logic_id, condition_rung.id).succeeded && service.findLogic(logic_id)->rungs.empty(), "the last network must be removable back to an empty logic"); const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1); require(first_wire.succeeded && service.findLogic(logic_id)->rungs.size() == 1U && service.findLogic(logic_id)->rungs.front().condition->kind == ConditionExpressionKind::Wire, "the first horizontal wire must create a one-cell network"); require(service.removeRung( logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded, "the wire-only network must be removable"); const LogicEditorResult first_output = service.setOutput( logic_id, {}, CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}); require(first_output.succeeded && service.findLogic(logic_id)->rungs.size() == 1U && service.findLogic(logic_id)->rungs.front().condition.has_value() && service.findLogic(logic_id)->rungs.front().condition->kind == ConditionExpressionKind::Wire && service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan == ProjectLimits::kMaximumConditionColumns && service.findLogic(logic_id)->rungs.front().output.has_value(), "the first output must create a full-width explicit wire network"); } void testBatchRungDeleteIsAtomic() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string first = service.addRung(logic_id).id; const std::string second = service.addRung(logic_id).id; const std::string third = service.addRung(logic_id).id; require(service.findLogic(logic_id)->rungs.size() == 3U, "the fixture must create three networks"); require(service.removeRungs(logic_id, {first, second}).succeeded && service.findLogic(logic_id)->rungs.size() == 1U && service.findRung(logic_id, third) != nullptr, "a selected group of networks must be deleted together"); require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 3U, "undo must restore a batch network deletion as one edit"); service.clearHistory(); require(!service.removeRungs(logic_id, {first, first}).succeeded && service.findLogic(logic_id)->rungs.size() == 3U && !service.canUndo(), "duplicate network selection must fail without mutation or history"); } void testAppendingWireMovesToNextNetworkAfterTenColumns() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); for (int index = 0; index < ProjectLimits::kMaximumConditionColumns; ++index) { require(service.appendWire(logic_id, rung_id).succeeded, "ten horizontal wire cells must fit in one network"); } const LogicEditorResult next = service.appendWire(logic_id, rung_id); require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U, "the next appended wire must create the following network"); const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id; require(service.findRung(logic_id, rung_id)->condition.has_value() && conditionColumns(*service.findRung(logic_id, rung_id)->condition) == ProjectLimits::kMaximumConditionColumns && service.findRung(logic_id, next_rung_id)->condition.has_value(), "automatic network rollover must preserve both network contents"); require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U, "automatic network rollover must be undone as one edit"); } void testAppendingWireReusesDeletedGapCells() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult full_wire = service.appendWire( logic_id, rung_id, ProjectLimits::kMaximumConditionColumns); require(full_wire.succeeded, "gap reuse setup must create a full-width wire"); require(service.disconnectWires(logic_id, rung_id, {full_wire.id}).succeeded, "deleting the full-width wire must create reusable gaps"); LogicEditorResult current = service.appendWire(logic_id, rung_id); require(current.succeeded, "the first appended wire must reuse the first deleted gap cell"); for (int index = 1; index < ProjectLimits::kMaximumConditionColumns; ++index) { current = service.insertWireAfter(logic_id, rung_id, current.id); require(current.succeeded, "inserting after a selected wire must reuse the next deleted gap cell"); require(service.findLogic(logic_id)->rungs.size() == 1U, "reusing a gap must not create a new network"); } const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && conditionColumns(*rung->condition) == ProjectLimits::kMaximumConditionColumns && gapColumns(*rung->condition) == 0, "all deleted gap cells must be restored as horizontal wires"); } 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 = makeEmptyRung(service, 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::Series && gapColumns(*rung->condition) == 1 && conditionColumns(*rung->condition) == 3, "deleting a nested node must preserve its column as an explicit gap"); 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, "deleting a parallel leaf must preserve the surrounding topology"); require(gapColumns(*rung->condition) == 2 && conditionColumns(*rung->condition) == 3, "each deleted condition must remain as a one-column gap"); 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 = makeEmptyRung(service, 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 testParallelBranchGridInsertion() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, 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 branch = service.addParallelBranch( logic_id, rung_id, {first.id, second.id, third.id}, contact(10)); require(branch.succeeded, "parallel grid insertion setup must create a short lower branch"); service.clearHistory(); const LadderRung *padded_rung = service.findRung(logic_id, rung_id); const ConditionExpression &padded_lower = padded_rung->condition->children.at(1); require(padded_lower.kind == ConditionExpressionKind::Series && padded_lower.children.size() == 2U && padded_lower.children.back().kind == ConditionExpressionKind::Wire && padded_lower.children.back().wire->columnSpan == 2, "a short parallel branch must persist its two padding cells as a wire"); const LogicEditorResult inserted = service.replaceWireColumnWithCondition( logic_id, rung_id, padded_lower.children.back().id, 1, contact(12)); require(inserted.succeeded, "a persisted parallel branch wire cell must accept a condition"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Parallel, "branch grid insertion must preserve the surrounding parallel expression"); const ConditionExpression &lower = rung->condition->children.at(1); require(lower.kind == ConditionExpressionKind::Series && lower.children.size() == 3U && lower.children.at(0).node->id == branch.id && lower.children.at(1).kind == ConditionExpressionKind::Wire && lower.children.at(1).wire->columnSpan == 1 && lower.children.at(2).node->id == inserted.id, "a distant branch cell must persist only the required gap and new condition"); require(service.undo().succeeded, "parallel branch grid insertion must be one undoable edit"); rung = service.findRung(logic_id, rung_id); require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series && rung->condition->children.at(1).children.front().node->id == branch.id && rung->condition->children.at(1).children.back().kind == ConditionExpressionKind::Wire && rung->condition->children.at(1).children.back().wire->columnSpan == 2, "undo must restore the original branch and its explicit padding wire"); service.clearHistory(); const bool modified_before = project_service.isModified(); const LogicEditorResult invalid = service.insertConditionInBranchAtColumn( logic_id, rung_id, branch.id, 3, contact(13)); require(!invalid.succeeded && !service.canUndo() && project_service.isModified() == modified_before && service.findNode(logic_id, inserted.id) == nullptr, "a cell outside the visible branch padding must fail atomically"); } 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 = makeEmptyRung(service, 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-2", "replacing one cell of a multi-cell wire must retain the original id on the remaining cell"); 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.has_value() && conditionNodes(*rung->condition) == 3 && conditionColumns(*rung->condition) == 4 && wireColumns(*rung->condition) == 1 && service.findNode(logic_id, replacement.id) == nullptr, "removing a bypass branch must keep the materialized padding wire without reconnecting positions"); require(service.undo().succeeded && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, "wire branch deletion must participate in ladder undo history"); } void testWireCellParallelSelectionUsesExactColumns() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult source = service.appendWire( logic_id, rung_id, 3); require(source.succeeded, "wire-cell parallel setup must create a three-column wire"); const LogicEditorResult branch = service.addParallelWireBranchAtCells( logic_id, rung_id, {{source.id, 0}, {source.id, 1}}); require(branch.succeeded, "a selected two-cell wire range must create a parallel bypass"); 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() == 2U, "a partial wire selection must preserve the surrounding series layout"); const ConditionExpression ¶llel = rung->condition->children.front(); require(parallel.kind == ConditionExpressionKind::Parallel && parallel.children.size() == 2U && parallel.children.front().kind == ConditionExpressionKind::Wire && parallel.children.front().wire->columnSpan == 2 && parallel.children.back().kind == ConditionExpressionKind::Wire && parallel.children.back().wire->columnSpan == 2 && rung->condition->children.back().kind == ConditionExpressionKind::Wire && rung->condition->children.back().wire->columnSpan == 1 && conditionColumns(*rung->condition) == 3, "the new bypass width must match the selected two cells, not the full source wire"); service.clearHistory(); const bool modified_before = project_service.isModified(); const LogicEditorResult non_contiguous = service.addParallelWireBranchAtCells( logic_id, rung_id, {{source.id, 0}, {source.id, 2}}); require(!non_contiguous.succeeded && project_service.isModified() == modified_before && !service.canUndo(), "non-contiguous wire-cell selections must fail without mutation"); } void testWireCellParallelSelectionAcrossAdjacentWires() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); std::vector wire_ids; for (int index = 0; index < 3; ++index) { const LogicEditorResult wire = service.appendWire(logic_id, rung_id); require(wire.succeeded, "adjacent wire setup must create each one-column segment"); wire_ids.push_back(wire.id); } const LogicEditorResult branch = service.addParallelWireBranchAtCells( logic_id, rung_id, {{wire_ids.at(0), 0}, {wire_ids.at(1), 0}, {wire_ids.at(2), 0}}); require(branch.succeeded, "visually continuous adjacent wire cells must create one bypass"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Parallel && rung->condition->children.size() == 2U && rung->condition->children.front().kind == ConditionExpressionKind::Wire && rung->condition->children.front().wire->columnSpan == 3 && rung->condition->children.back().kind == ConditionExpressionKind::Wire && rung->condition->children.back().wire->columnSpan == 3, "three adjacent one-column wires must become a three-column parallel range"); } 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 = makeEmptyRung(service, 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::Parallel && conditionNodes(*rung->condition) == 2 && gapColumns(*rung->condition) == 3 && conditionColumns(*rung->condition) == 4, "deleted parallel conditions must remain as gaps without collapsing the 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 = makeEmptyRung(service, 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"); } require(project_service.saveAs("logic-editor-condition-limit.json").succeeded, "the ten-column network must be saveable before testing overflow"); require(!project_service.isModified(), "saving the ten-column network must clear the modified state"); 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"); require(!project_service.isModified(), "a failed eleventh-column edit must preserve the saved state"); 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"); require(service.setOutput( logic_id, rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, true) .succeeded && project_service.isModified(), "a successful edit must make the project modified again"); require(!service.appendCondition( logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns)) .succeeded && project_service.isModified(), "a failed edit must preserve an existing modified state"); } 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 = makeEmptyRung(service, 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->condition->kind == ConditionExpressionKind::Wire && rung->condition->wire->columnSpan == ProjectLimits::kMaximumConditionColumns && rung->output.has_value() && rung->validateForRunning(), "an editor-created unconditional network must use a full-width wire"); const std::string contact_rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult contact_result = service.appendCondition( logic_id, contact_rung_id, contact(0)); require(contact_result.succeeded && service.updateNodeConfig( logic_id, contact_result.id, contact(0)).succeeded && service.setOutput( logic_id, contact_rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal}, true).succeeded, "a contact followed by an output must be editable"); const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id); require(contact_rung != nullptr && contact_rung->condition.has_value() && conditionNodes(*contact_rung->condition) == 1 && wireColumns(*contact_rung->condition) == 9 && conditionColumns(*contact_rung->condition) == 10 && contact_rung->validateForRunning(), "a contact followed by an output must persist the remaining nine wire cells"); } void testExplicitGapEditing() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); require(service.setOutput( logic_id, rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal}, true).succeeded, "gap editing setup must create an explicit full-width wire"); const LadderRung *rung = service.findRung(logic_id, rung_id); const std::string full_wire_id = rung->condition->id; require(service.disconnectWireCells( logic_id, rung_id, {{full_wire_id, 4}}).succeeded, "deleting one wire cell must create a gap"); rung = service.findRung(logic_id, rung_id); require(rung->validate() && !rung->validateForRunning() && conditionColumns(*rung->condition) == 10 && wireColumns(*rung->condition) == 9 && gapColumns(*rung->condition) == 1, "a disconnected wire cell must preserve width and block runtime"); const ConditionExpression *gap = firstExpressionOfKind( *rung->condition, ConditionExpressionKind::Gap); require(gap != nullptr, "the disconnected cell must expose a gap expression"); const std::string first_gap_id = gap->id; require(service.replaceGapColumnWithWire( logic_id, rung_id, first_gap_id, 0).succeeded, "a gap cell must be repairable with a real wire"); rung = service.findRung(logic_id, rung_id); require(gapColumns(*rung->condition) == 0 && wireColumns(*rung->condition) == 10 && rung->validateForRunning(), "repairing the gap with a wire must restore a runnable path"); const ConditionExpression *wire = firstExpressionOfKind( *rung->condition, ConditionExpressionKind::Wire); require(wire != nullptr, "the repaired path must expose a wire expression"); const std::string repaired_wire_id = wire->id; require(service.disconnectWireCells( logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded, "a repaired wire cell must be disconnectable again"); rung = service.findRung(logic_id, rung_id); gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap); require(gap != nullptr, "the second disconnection must expose a gap expression"); const std::string second_gap_id = gap->id; const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition( logic_id, rung_id, second_gap_id, 0, contact(31)); require(inserted_result.succeeded && service.updateNodeConfig( logic_id, inserted_result.id, contact(31)).succeeded, "a gap cell must be replaceable with a contact"); rung = service.findRung(logic_id, rung_id); const ConditionExpression *inserted = firstExpressionOfKind( *rung->condition, ConditionExpressionKind::Node); require(inserted != nullptr && gapColumns(*rung->condition) == 0 && conditionNodes(*rung->condition) == 1 && conditionColumns(*rung->condition) == 10 && rung->validateForRunning(), "a contact inserted into a gap must preserve the ten-column path"); const std::string inserted_node_id = inserted->node->id; require(service.removeNode(logic_id, inserted_node_id).succeeded, "the inserted contact must be removable"); rung = service.findRung(logic_id, rung_id); require(conditionNodes(*rung->condition) == 0 && gapColumns(*rung->condition) == 1 && conditionColumns(*rung->condition) == 10 && !rung->validateForRunning(), "deleting a contact must restore a gap instead of reconnecting the path"); const std::string multi_rung_id = makeEmptyRung(service, logic_id); require(service.setOutput( logic_id, multi_rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal}, true).succeeded, "multi-gap setup must create a full-width wire"); const std::string multi_wire_id = service.findRung( logic_id, multi_rung_id)->condition->id; require(service.disconnectWireCells( logic_id, multi_rung_id, {{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded, "multiple cells in one wire must disconnect atomically"); const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id); require(conditionColumns(*multi_rung->condition) == 10 && wireColumns(*multi_rung->condition) == 8 && gapColumns(*multi_rung->condition) == 2 && !multi_rung->validateForRunning(), "multiple disconnected cells must retain both explicit gaps"); } 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 = makeEmptyRung(service, 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"); const std::string output_wire_id = service.findRung( logic_id, second_rung.id)->condition->id; require(service.replaceWireColumnWithCondition( logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded, "the first wire cell must accept a contact on an unconditional 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::Series && conditionNodes(*output_rung->condition) == 1 && wireColumns(*output_rung->condition) == 9 && conditionColumns(*output_rung->condition) == 10 && 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 = makeEmptyRung(service, 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 = makeEmptyRung(service, 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 testEdgeNodesAndRungComments() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, 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"); 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.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded, "the editor must update a network comment by stable rung id"); require(!service.updateRungComment( logic_id, rung_id, "第一行\n第二行").succeeded, "the editor must reject multiline network comments"); require(!service.updateRungComment( logic_id, rung_id, std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a')) .succeeded, "the editor must reject oversized network comments"); const LadderRung *rung = service.findRung(logic_id, rung_id); const LogicNode *edge = service.findNode(logic_id, rising.id); require(rung != nullptr && rung->comment == "延时启动网络" && edge != nullptr && std::get(edge->config).mode == EdgeMode::Falling, "edge 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 = makeEmptyRung(service, 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 == static_cast(EditorHistory::kMaximumEntries), "logic history must retain exactly the configured 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; } void testBatchPasteNodesAndRung() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult first = service.appendCondition( logic_id, rung_id, contact(10)); const LogicEditorResult second = service.appendCondition( logic_id, rung_id, contact(11)); require(first.succeeded && second.succeeded, "nodes for paste testing must be created"); const LogicNode first_copy = *service.findNode(logic_id, first.id); const LogicNode second_copy = *service.findNode(logic_id, second.id); service.clearHistory(); const LogicEditorResult pasted = service.pasteConditionNodes( logic_id, rung_id, {first_copy, second_copy}); require(pasted.succeeded && service.findNode(logic_id, pasted.id) != nullptr && service.findRung(logic_id, rung_id)->condition.has_value() && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U, "batch condition paste must append fresh nodes"); require(pasted.id != first.id && pasted.id != second.id && service.undo().succeeded && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U, "batch condition paste must use fresh ids and one undo step"); require(service.areConditionNodesContiguous( logic_id, rung_id, {first.id, second.id}), "adjacent nodes in one series must be copyable as a range"); const std::string wire_rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3); service.clearHistory(); LogicConditionPasteTarget wire_target; wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; wire_target.expressionId = wire.id; wire_target.column = 1; const LogicEditorResult pasted_on_wire = service.pasteConditionNodes( logic_id, wire_rung_id, {first_copy, second_copy}, wire_target); const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id); require(pasted_on_wire.succeeded && wire_rung != nullptr && wire_rung->condition.has_value() && conditionNodes(*wire_rung->condition) == 2 && conditionColumns(*wire_rung->condition) == 3, "condition paste must replace the selected wire cell and consume following wire cells"); require(service.undo().succeeded && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3, "wire-targeted paste must be undone as one edit"); const std::string full_rung_id = makeEmptyRung(service, logic_id); for (int address = 0; address < 9; ++address) { require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded, "nine conditions must fit before an atomic paste failure test"); } service.clearHistory(); require(!service.pasteConditionNodes( logic_id, full_rung_id, {first_copy, second_copy}).succeeded && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9 && !service.canUndo(), "a multi-node paste that exceeds ten columns must roll back completely"); const LogicEditorResult third = service.appendCondition( logic_id, rung_id, contact(12)); require(third.succeeded && !service.areConditionNodesContiguous( logic_id, rung_id, {first.id, third.id}), "non-adjacent nodes must not be copied as one condition range"); const LogicEditorResult output = service.setOutput( logic_id, rung_id, CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set}, true); require(output.succeeded, "the source rung must accept an output before copying"); const LadderRung source = *service.findRung(logic_id, rung_id); service.clearHistory(); const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size(); const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source); require(pasted_rung.succeeded && service.findLogic(logic_id)->rungs.size() == rung_count_before_paste + 1U && pasted_rung.id != source.id, "whole rung paste must append a new network"); const LadderRung *copy = service.findRung(logic_id, pasted_rung.id); require(copy != nullptr && copy->condition.has_value() && copy->condition->id != source.condition->id && copy->output.has_value() && source.output.has_value() && copy->output->id != source.output->id && std::get(copy->output->config).address.index() == 20 && std::get(copy->output->config).mode == CoilMode::Set, "whole rung paste must regenerate ids and preserve output configuration"); } void testConditionPasteTargetsAndValidation() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; LogicNode source; source.id = "copied-condition"; source.config = contact(90); source.configured = true; const std::string empty_column_rung = makeEmptyRung(service, logic_id); LogicConditionPasteTarget empty_column; empty_column.kind = LogicConditionPasteTargetKind::EmptyColumn; empty_column.column = 2; require(service.pasteConditionNodes( logic_id, empty_column_rung, {source}, empty_column).succeeded, "condition paste must support an empty grid column"); require(conditionColumns(*service.findRung( logic_id, empty_column_rung)->condition) == 3, "empty-column paste must preserve the requested column offset"); const std::string branch_rung = makeEmptyRung(service, logic_id); const LogicEditorResult branch_source = service.appendCondition( logic_id, branch_rung, contact(1)); const LogicEditorResult branch_source_two = service.appendCondition( logic_id, branch_rung, contact(2)); const LogicEditorResult branch_source_three = service.appendCondition( logic_id, branch_rung, contact(3)); require(branch_source.succeeded && branch_source_two.succeeded && branch_source_three.succeeded, "branch paste setup must add a source range"); const LogicEditorResult branch = service.addParallelBranch( logic_id, branch_rung, {branch_source.id, branch_source_two.id, branch_source_three.id}, contact(4)); require(branch.succeeded, "branch paste setup must create a parallel branch"); const ConditionExpression &paste_lower = service.findRung( logic_id, branch_rung)->condition->children.at(1); require(paste_lower.kind == ConditionExpressionKind::Series && paste_lower.children.back().kind == ConditionExpressionKind::Wire, "branch paste setup must persist the short branch padding wire"); LogicConditionPasteTarget branch_target; branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; branch_target.expressionId = paste_lower.children.back().id; branch_target.column = 1; require(service.pasteConditionNodes( logic_id, branch_rung, {source}, branch_target).succeeded, "condition paste must support a persisted wire cell in a branch"); const std::string after_node_rung = makeEmptyRung(service, logic_id); const LogicEditorResult after_source = service.appendCondition( logic_id, after_node_rung, contact(3)); require(after_source.succeeded, "after-node paste setup must add a source node"); LogicConditionPasteTarget after_target; after_target.kind = LogicConditionPasteTargetKind::AfterNode; after_target.expressionId = after_source.id; require(service.pasteConditionNodes( logic_id, after_node_rung, {source}, after_target).succeeded, "condition paste must support insertion after a selected node"); require(conditionNodes(*service.findRung( logic_id, after_node_rung)->condition) == 2, "after-node paste must add exactly one condition"); const std::string replace_wire_rung = makeEmptyRung(service, logic_id); const LogicEditorResult wire = service.appendWire( logic_id, replace_wire_rung, 2); require(wire.succeeded, "replace-wire paste setup must add a wire"); LogicConditionPasteTarget replace_wire; replace_wire.kind = LogicConditionPasteTargetKind::ReplaceWire; replace_wire.expressionId = wire.id; require(service.pasteConditionNodes( logic_id, replace_wire_rung, {source}, replace_wire).succeeded, "condition paste must replace an entire wire expression"); require(conditionNodes(*service.findRung( logic_id, replace_wire_rung)->condition) == 1, "whole-wire paste must create one condition node"); const std::string replace_cell_rung = makeEmptyRung(service, logic_id); const LogicEditorResult cell_wire = service.appendWire( logic_id, replace_cell_rung, 3); require(cell_wire.succeeded, "replace-cell paste setup must add a wire"); LogicConditionPasteTarget replace_cell; replace_cell.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; replace_cell.expressionId = cell_wire.id; replace_cell.column = 1; require(service.pasteConditionNodes( logic_id, replace_cell_rung, {source}, replace_cell).succeeded, "condition paste must replace one selected wire cell"); require(conditionColumns(*service.findRung( logic_id, replace_cell_rung)->condition) == 3, "wire-cell paste must preserve the original network width"); require(!service.pasteConditionNodes(logic_id, after_node_rung, {}).succeeded, "an empty condition clipboard must be rejected"); LogicNode output = source; output.config = CoilNodeConfig{RegisterAddress{RegisterArea::M, 91}, CoilMode::Normal}; require(!service.pasteConditionNodes( logic_id, after_node_rung, {output}).succeeded, "an output instruction must not be pasted into the condition area"); require(!service.pasteConditionNodes( "missing-logic", after_node_rung, {source}).succeeded, "condition paste must reject an unknown logic"); require(!service.pasteConditionNodes( logic_id, "missing-rung", {source}).succeeded, "condition paste must reject an unknown rung"); const LadderRung *before_invalid = service.findRung( logic_id, after_node_rung); require(before_invalid != nullptr && before_invalid->condition.has_value(), "invalid paste setup must retain its target network"); const int node_count_before_invalid = conditionNodes(*before_invalid->condition); LogicConditionPasteTarget invalid_target; invalid_target.kind = LogicConditionPasteTargetKind::AfterNode; invalid_target.expressionId = "missing-expression"; service.clearHistory(); require(!service.pasteConditionNodes( logic_id, after_node_rung, {source}, invalid_target).succeeded && conditionNodes(*service.findRung( logic_id, after_node_rung)->condition) == node_count_before_invalid && !service.canUndo(), "an invalid paste target must roll back without recording history"); } void testConfiguredLogicLimits() { TestProjectStorage storage; ProjectLimitSettings limits; limits.maximumControlLogics = 1U; limits.maximumRungsPerLogic = 0U; ProjectService project_service(storage, limits); LogicEditorService service(project_service); const LogicEditorResult initial = service.ensureDefaultLogic(); require(initial.succeeded, "the configured-limit fixture must create its one default logic"); require(!service.addLogic("Second logic").succeeded, "the logic editor must use the configured logic-group limit"); require(!service.addRung(initial.id).succeeded, "the logic editor must use the configured per-logic rung limit"); } } // namespace int main() { try { testLogicCommandParsing(); testContinuousLogicCommands(); testExistingNodeCommandReplacement(); testPositionedLogicCommandsAndAtomicFailures(); testEmptyLogicCreatesNetworksOnFirstEdit(); testBatchRungDeleteIsAtomic(); testAppendingWireMovesToNextNetworkAfterTenColumns(); testAppendingWireReusesDeletedGapCells(); testStructuredEditingAndNormalization(); testRangeParallelInsertion(); testParallelBranchGridInsertion(); testStructuredWireEditing(); testWireCellParallelSelectionUsesExactColumns(); testWireCellParallelSelectionAcrossAdjacentWires(); testBatchDeleteAllNodesInParallelBranch(); testConditionColumnLimit(); testUnconditionalOutputEditing(); testExplicitGapEditing(); testColumnTargetedConditionInsertion(); testWireColumnReplacement(); testSequentialConditionInsertionConsumesFollowingWire(); testLogicLifecycleAndOrdering(); testEdgeNodesAndRungComments(); testHistoryAndAtomicBatchDelete(); testBatchPasteNodesAndRung(); testConditionPasteTargetsAndValidation(); testConfiguredLogicLimits(); } 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; }