#include "domain/project_limits.h" #include "services/logic_command_service.h" #include "services/logic_editor_service.h" #include "services/project_service.h" #include "support/test_support.h" #include #include #include #include #include namespace { using TestSupport::require; ContactNodeConfig contact( int address, ContactMode mode = ContactMode::NormallyOpen) { return {RegisterAddress{RegisterArea::M, address}, mode}; } LogicEditorResult setConditionAtColumn( LogicEditorService &editor, const std::string &logic_id, const std::string &rung_id, int column, const LogicNodeConfig &config, bool configured) { return editor.applyConditionAndAdvance( logic_id, {rung_id, column, false}, config, configured).edit; } struct Fixture { TestSupport::InMemoryProjectStorage storage; ProjectService projects{storage, defaultProjectLimitSettings()}; LogicEditorService editor{projects}; std::string logicId; Fixture() { const LogicEditorResult result = editor.ensureDefaultLogic(); require(result.succeeded, "fixture must create the default logic"); logicId = result.id; } std::string addRung() { const LogicEditorResult result = editor.addRung(logicId); require(result.succeeded, "fixture must create a ladder row"); return result.id; } }; const VerticalConnection *connectionAt( const ControlLogic &logic, const std::string &upper, const std::string &lower, int boundary) { const auto found = std::find_if( logic.verticalConnections.cbegin(), logic.verticalConnections.cend(), [&upper, &lower, boundary](const VerticalConnection &connection) { return connection.upperRungId == upper && connection.lowerRungId == lower && connection.columnBoundary == boundary; }); return found == logic.verticalConnections.cend() ? nullptr : &*found; } void requireEmptyGrid(const LadderRung &rung) { require( rung.cells.size() == static_cast( ProjectLimits::kMaximumConditionColumns), "every visual row must have exactly ten persisted cells"); for (const LadderCell &cell : rung.cells) { require( !cell.id.empty() && cell.kind == LadderCellKind::Gap && !cell.node.has_value(), "a new row must contain stable empty cells"); } } void testContinuousGridAndIndependentHorizontalWires() { Fixture fixture; const std::string rung_id = fixture.addRung(); requireEmptyGrid(*fixture.editor.findRung(fixture.logicId, rung_id)); require( fixture.editor.setHorizontalWireRange( fixture.logicId, rung_id, 2, 5, true).succeeded, "drawing a horizontal range must succeed"); const LadderRung *rung = fixture.editor.findRung( fixture.logicId, rung_id); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { const LadderCellKind expected = column >= 2 && column <= 5 ? LadderCellKind::Wire : LadderCellKind::Gap; require( rung->cells[static_cast(column)].kind == expected, "horizontal wires must be persisted independently per cell"); } fixture.editor.clearHistory(); fixture.projects.restoreModifiedState(false); const LogicEditorResult duplicate = fixture.editor.setHorizontalWireRange( fixture.logicId, rung_id, 2, 5, true); require( duplicate.succeeded && !fixture.projects.isModified() && !fixture.editor.canUndo(), "repeating an existing horizontal wire range must not dirty the project or history"); const LogicEditorResult node = setConditionAtColumn(fixture.editor, fixture.logicId, rung_id, 3, contact(3), true); require(node.succeeded, "a wire cell must accept a condition node"); require( fixture.editor.setHorizontalWireRange( fixture.logicId, rung_id, 2, 5, false).succeeded, "erasing a horizontal range must succeed"); rung = fixture.editor.findRung(fixture.logicId, rung_id); require( rung->cells[3].kind == LadderCellKind::Node && rung->cells[3].node->id == node.id, "line erasing must not delete a condition node"); require( rung->cells[2].kind == LadderCellKind::Gap && rung->cells[4].kind == LadderCellKind::Gap && rung->cells[5].kind == LadderCellKind::Gap, "line erasing must clear each selected wire cell"); } void testIndependentVerticalConnectionsAndNetworkSplit() { Fixture fixture; const std::string first = fixture.addRung(); const std::string second = fixture.addRung(); const std::string third = fixture.addRung(); require( fixture.editor.setVerticalConnectionRange( fixture.logicId, first, third, 4, true).succeeded, "a vertical gesture must create all adjacent segments"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require( logic->verticalConnections.size() == 2U && connectionAt(*logic, first, second, 4) != nullptr && connectionAt(*logic, second, third, 4) != nullptr, "a long vertical line must be represented by independent segments"); fixture.editor.clearHistory(); fixture.projects.restoreModifiedState(false); const LogicVerticalEditResult duplicate = fixture.editor.applyVerticalConnectionAndAdvance( fixture.logicId, first, 4); require( duplicate.edit.succeeded && !duplicate.changed && duplicate.nextRungId == second && duplicate.columnBoundary == 4 && fixture.editor.findLogic(fixture.logicId) ->verticalConnections.size() == 2U && !fixture.projects.isModified() && !fixture.editor.canUndo(), "an existing vertical edge must advance without dirty state or empty history"); const std::string first_segment = connectionAt(*logic, first, second, 4)->id; require( fixture.editor.removeVerticalConnections( fixture.logicId, {first_segment}).succeeded, "an individual vertical segment must be removable"); logic = fixture.editor.findLogic(fixture.logicId); require( connectionAt(*logic, first, second, 4) == nullptr && connectionAt(*logic, second, third, 4) != nullptr, "deleting one segment must split the connected network"); require( !fixture.editor.setVerticalConnection( fixture.logicId, first, third, 4, true).succeeded, "the model must reject vertical edges between non-adjacent rows"); } void testNetworkCommentsFollowNetworkHeadsAndMergeAtomically() { Fixture fixture; const std::string first = fixture.addRung(); const std::string second = fixture.addRung(); const std::string third = fixture.addRung(); require( fixture.editor.updateNetworkComment( fixture.logicId, second, "下方网络注释").succeeded, "an independent lower network must accept its own comment"); fixture.editor.clearHistory(); const LogicEditorResult merged = fixture.editor.setVerticalConnection( fixture.logicId, first, second, 4, true); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require( merged.succeeded && logic->rungs[0].comment == "下方网络注释" && logic->rungs[1].comment.empty() && fixture.editor.findNetworkHeadRung(fixture.logicId, second)->id == first, "merging a commented lower network into an empty upper network must move the comment to the new head"); require( fixture.editor.undo().succeeded && fixture.editor.findRung(fixture.logicId, first)->comment.empty() && fixture.editor.findRung(fixture.logicId, second)->comment == "下方网络注释" && fixture.editor.redo().succeeded, "network merge comment movement must belong to the same undo transaction"); fixture.editor.clearHistory(); const LogicEditorResult edited_from_branch = fixture.editor.updateNetworkComment( fixture.logicId, second, "从支路编辑后的注释"); require( edited_from_branch.succeeded && edited_from_branch.id == first && fixture.editor.findRung(fixture.logicId, first)->comment == "从支路编辑后的注释" && fixture.editor.findRung(fixture.logicId, second)->comment.empty(), "editing from a branch row must update only the network head comment"); require( fixture.editor.updateNetworkComment( fixture.logicId, third, "另一个网络注释").succeeded, "the third independent network must accept a different comment"); fixture.editor.clearHistory(); fixture.projects.restoreModifiedState(false); const LogicEditorResult conflict = fixture.editor.setVerticalConnection( fixture.logicId, second, third, 4, true); logic = fixture.editor.findLogic(fixture.logicId); require( !conflict.succeeded && conflict.message.find("存在不同注释") != std::string::npos && connectionAt(*logic, second, third, 4) == nullptr && logic->rungs[0].comment == "从支路编辑后的注释" && logic->rungs[2].comment == "另一个网络注释" && !fixture.projects.isModified() && !fixture.editor.canUndo(), "merging differently commented networks must fail without partial topology or history"); require( fixture.editor.updateNetworkComment( fixture.logicId, third, "从支路编辑后的注释").succeeded, "the lower network comment must be editable before a retry"); fixture.editor.clearHistory(); require( fixture.editor.setVerticalConnection( fixture.logicId, second, third, 4, true).succeeded && fixture.editor.findRung(fixture.logicId, third)->comment.empty(), "merging equal network comments must keep one comment at the head"); const std::string connection_id = connectionAt( *fixture.editor.findLogic(fixture.logicId), second, third, 4)->id; require( fixture.editor.removeVerticalConnections( fixture.logicId, {connection_id}).succeeded && fixture.editor.findRung(fixture.logicId, first)->comment == "从支路编辑后的注释" && fixture.editor.findRung(fixture.logicId, third)->comment.empty(), "splitting a network must not duplicate its head comment"); } void testInsertRowSplitsVerticalEdges() { Fixture fixture; const std::string upper = fixture.addRung(); const std::string lower = fixture.addRung(); require( fixture.editor.setVerticalConnection( fixture.logicId, upper, lower, 2, true).succeeded, "fixture must connect the original adjacent rows"); require( fixture.editor.setVerticalConnection( fixture.logicId, upper, lower, 7, true).succeeded, "fixture must support more than one boundary between two rows"); const LogicEditorResult inserted = fixture.editor.insertRung( fixture.logicId, upper, true); require(inserted.succeeded, "inserting a row must succeed"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require(logic->rungs.size() == 3U, "the row must be inserted in place"); requireEmptyGrid(*fixture.editor.findRung(fixture.logicId, inserted.id)); for (int boundary : {2, 7}) { require( connectionAt(*logic, upper, inserted.id, boundary) != nullptr && connectionAt(*logic, inserted.id, lower, boundary) != nullptr && connectionAt(*logic, upper, lower, boundary) == nullptr, "insertion must split every crossed vertical edge"); } } void testDeleteRowMergesOnlyContinuousEdges() { Fixture fixture; const std::string upper = fixture.addRung(); const std::string middle = fixture.addRung(); const std::string lower = fixture.addRung(); require( fixture.editor.setVerticalConnection( fixture.logicId, upper, middle, 1, true).succeeded, "fixture must add the upper half of a continuous edge"); require( fixture.editor.setVerticalConnection( fixture.logicId, middle, lower, 1, true).succeeded, "fixture must add the lower half of a continuous edge"); require( fixture.editor.setVerticalConnection( fixture.logicId, upper, middle, 6, true).succeeded, "fixture must add an upper-only edge"); require( fixture.editor.setVerticalConnection( fixture.logicId, middle, lower, 8, true).succeeded, "fixture must add a lower-only edge"); require( fixture.editor.removeRung(fixture.logicId, middle).succeeded, "deleting the middle row must succeed"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require(logic->rungs.size() == 2U, "the middle row must be removed"); require( connectionAt(*logic, upper, lower, 1) != nullptr, "matching upper and lower segments must merge after row deletion"); require( connectionAt(*logic, upper, lower, 6) == nullptr && connectionAt(*logic, upper, lower, 8) == nullptr, "a one-sided edge must disappear instead of creating a false bridge"); require( logic->rungs[0].name == "行 1" && logic->rungs[1].name == "行 2", "row labels must be renumbered after deletion"); } void testParallelBranchCreatesConnectedVisualRow() { Fixture fixture; const std::string source = fixture.addRung(); const LogicEditorResult first = setConditionAtColumn(fixture.editor, fixture.logicId, source, 2, contact(1), true); const LogicEditorResult second = setConditionAtColumn(fixture.editor, fixture.logicId, source, 3, contact(2), true); require(first.succeeded && second.succeeded, "fixture must place adjacent conditions"); const LogicEditorResult branch = fixture.editor.addParallelBranch( fixture.logicId, source, {first.id, second.id}, contact(3), true); require(branch.succeeded, "parallel insertion must create a visual row"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require(logic->rungs.size() == 2U, "parallel insertion must add one row to the continuous grid"); const LadderRung &lower = logic->rungs[1]; require( lower.cells[2].kind == LadderCellKind::Node && lower.cells[2].node->id == branch.id && lower.cells[3].kind == LadderCellKind::Wire, "the branch row must align the new condition with its selected span"); require( connectionAt(*logic, source, lower.id, 2) != nullptr && connectionAt(*logic, source, lower.id, 4) != nullptr, "a parallel branch must be bounded by independent left and right edges"); } void testParallelBranchReusesExistingEdges() { Fixture fixture; const std::string source = fixture.addRung(); const std::string following = fixture.addRung(); const LogicEditorResult first = setConditionAtColumn(fixture.editor, fixture.logicId, source, 2, contact(4), true); const LogicEditorResult second = setConditionAtColumn(fixture.editor, fixture.logicId, source, 3, contact(5), true); require(first.succeeded && second.succeeded, "fixture must place the branch source conditions"); require( fixture.editor.setVerticalConnection( fixture.logicId, source, following, 2, true).succeeded && fixture.editor.setVerticalConnection( fixture.logicId, source, following, 4, true).succeeded, "fixture must create edges at the future branch boundaries"); const LogicEditorResult branch = fixture.editor.addParallelBranch( fixture.logicId, source, {first.id, second.id}, contact(6), true); require(branch.succeeded, "parallel insertion must reuse matching split edges"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require(logic->rungs.size() == 3U, "parallel insertion must add exactly one row"); const std::string branch_rung = logic->rungs[1].id; for (int boundary : {2, 4}) { require( connectionAt(*logic, source, branch_rung, boundary) != nullptr && connectionAt(*logic, branch_rung, following, boundary) != nullptr, "existing topology and new branch must share one edge per boundary"); } } void testNodeDeletionAndHistoryAreAtomic() { Fixture fixture; const std::string rung_id = fixture.addRung(); const LogicEditorResult node = setConditionAtColumn(fixture.editor, fixture.logicId, rung_id, 0, contact(9), true); require(node.succeeded, "fixture must place a condition"); fixture.editor.clearHistory(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, rung_id, 1, 4, true).succeeded, "one horizontal drag must be one edit"); require(fixture.editor.canUndo(), "the drag must enter undo history"); require(fixture.editor.undo().succeeded, "the drag must undo atomically"); const LadderRung *rung = fixture.editor.findRung( fixture.logicId, rung_id); for (int column = 1; column <= 4; ++column) { require( rung->cells[static_cast(column)].kind == LadderCellKind::Gap, "undo must restore every cell changed by the drag"); } require(fixture.editor.redo().succeeded, "the drag must redo atomically"); require( fixture.editor.removeNodes(fixture.logicId, {node.id}).succeeded, "deleting a condition node must succeed"); rung = fixture.editor.findRung(fixture.logicId, rung_id); require( rung->cells[0].kind == LadderCellKind::Gap && !rung->cells[0].node.has_value(), "a deleted node must leave an editable gap cell"); const std::size_t rows_before = fixture.editor.findLogic( fixture.logicId)->rungs.size(); require( !fixture.editor.removeRungs( fixture.logicId, {rung_id, "missing-rung"}).succeeded, "a batch containing an invalid row must fail"); require( fixture.editor.findLogic(fixture.logicId)->rungs.size() == rows_before, "a failed batch edit must not partially mutate the graph"); } void testSelectionDeletionIsAtomic() { Fixture fixture; const std::string upper = fixture.addRung(); const std::string lower = fixture.addRung(); require( setConditionAtColumn(fixture.editor, fixture.logicId, upper, 0, contact(30), true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, upper, 1, 1, true).succeeded && fixture.editor.setOutput( fixture.logicId, upper, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 31}, CoilMode::Normal}, true).succeeded && fixture.editor.setVerticalConnection( fixture.logicId, upper, lower, 2, true).succeeded, "fixture must create every selectable object type"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require(logic != nullptr && logic->verticalConnections.size() == 1U, "fixture must expose the vertical connection id"); const std::string connection_id = logic->verticalConnections.front().id; fixture.editor.clearHistory(); LogicSelectionDeleteRequest selection; selection.cells = {{upper, 0}, {upper, 1}}; selection.outputRungIds = {upper}; selection.verticalConnectionIds = {connection_id}; require( fixture.editor.deleteSelection(fixture.logicId, selection).succeeded, "one selection delete must remove mixed ladder objects"); const LadderRung *rung = fixture.editor.findRung(fixture.logicId, upper); logic = fixture.editor.findLogic(fixture.logicId); require( rung != nullptr && rung->cells[0].kind == LadderCellKind::Gap && rung->cells[1].kind == LadderCellKind::Gap && !rung->output.has_value() && logic->verticalConnections.empty(), "selection delete must clear every requested object together"); require( fixture.editor.undo().succeeded, "mixed selection deletion must create one undo entry"); rung = fixture.editor.findRung(fixture.logicId, upper); logic = fixture.editor.findLogic(fixture.logicId); require( rung->cells[0].kind == LadderCellKind::Node && rung->cells[1].kind == LadderCellKind::Wire && rung->output.has_value() && logic->verticalConnections.size() == 1U, "one undo must restore the complete mixed selection"); require( fixture.editor.redo().succeeded, "mixed selection deletion must redo as one entry"); rung = fixture.editor.findRung(fixture.logicId, upper); require( rung->cells[0].kind == LadderCellKind::Gap && rung->cells[1].kind == LadderCellKind::Gap && !rung->output.has_value() && fixture.editor.findLogic(fixture.logicId) ->verticalConnections.empty(), "one redo must delete the complete mixed selection again"); } void testInvalidSelectionDeletionDoesNotMutateOrRecordHistory() { Fixture fixture; const std::string rung_id = fixture.addRung(); require( setConditionAtColumn(fixture.editor, fixture.logicId, rung_id, 0, contact(40), true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, rung_id, 1, 1, true).succeeded, "fixture must create valid objects before an invalid mixed delete"); fixture.editor.clearHistory(); LogicSelectionDeleteRequest selection; selection.cells = {{rung_id, 0}, {rung_id, 1}}; selection.verticalConnectionIds = {"missing-vertical"}; require( !fixture.editor.deleteSelection(fixture.logicId, selection).succeeded, "a mixed selection containing an invalid object must fail"); const LadderRung *rung = fixture.editor.findRung( fixture.logicId, rung_id); require( rung->cells[0].kind == LadderCellKind::Node && rung->cells[1].kind == LadderCellKind::Wire, "an invalid mixed deletion must not partially clear valid cells"); require( !fixture.editor.canUndo(), "an invalid mixed deletion must not create an undo entry"); } void testCommandInputMapsToGridCoordinates() { Fixture fixture; LogicCommandService commands(fixture.editor); const std::string rung_id = fixture.addRung(); require( LogicCommandService::parse("ldi m4000").succeeded, "command parsing must remain case-insensitive"); require( !LogicCommandService::parse("LD D0").succeeded, "contact commands must reject D addresses"); LogicCommandRequest request; request.logicId = fixture.logicId; request.text = "LD M12"; request.target = { LogicCommandTargetKind::EmptyColumn, rung_id, {}, 5}; const LogicCommandResult loaded = commands.execute(request); require(loaded.succeeded && loaded.hasNextCursor && loaded.nextCursor.rungId == rung_id && loaded.nextCursor.column == 6 && !loaded.nextCursor.output, "LD must be written to the selected grid cell and advance right"); const LadderCell *cell = fixture.editor.findCell( fixture.logicId, rung_id, 5); require( cell != nullptr && cell->kind == LadderCellKind::Node && cell->node->id == loaded.id, "command input must preserve the target row and column"); request.text = "LDI M13"; request.target.kind = LogicCommandTargetKind::ExistingNode; request.target.expressionId = loaded.id; const LogicCommandResult replaced = commands.execute(request); cell = fixture.editor.findCell(fixture.logicId, rung_id, 5); require( replaced.succeeded && replaced.id == loaded.id && cell->node->id == loaded.id && std::get(cell->node->config).mode == ContactMode::NormallyClosed, "editing an existing command must preserve its node identity"); request.text = "OUT M20"; request.target.kind = LogicCommandTargetKind::Output; const LogicCommandResult output = commands.execute(request); require(output.succeeded && output.hasNextCursor && output.nextCursor.column == 0 && output.nextCursor.rungId != rung_id, "OUT must target the row output slot and advance to the next row"); require( fixture.editor.findRung(fixture.logicId, rung_id)->output->id == output.id, "the command must create the configured output node"); request.text = "AND M21"; require( !commands.execute(request).succeeded, "condition commands must not be accepted in the output slot"); } void testProjectRungLimitAppliesToParallelBranch() { Fixture fixture; const std::string source = fixture.addRung(); const LogicEditorResult node = setConditionAtColumn(fixture.editor, fixture.logicId, source, 0, contact(20), true); require(node.succeeded, "fixture must create a branch source node"); Project &project = fixture.projects.editProject(); std::size_t remaining = ProjectLimits::kMaximumRungsPerProject - 1U; std::size_t logic_index = 2U; std::size_t rung_index = 1U; while (remaining > 0U) { ControlLogic extra; extra.id = "limit-logic-" + std::to_string(logic_index); extra.name = "Limit logic " + std::to_string(logic_index); const std::size_t count = std::min( remaining, ProjectLimits::kMaximumRungsPerLogic); for (std::size_t index = 0U; index < count; ++index, ++rung_index) { LadderRung rung; rung.id = "limit-rung-" + std::to_string(rung_index); rung.name = "Limit row " + std::to_string(rung_index); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { rung.cells.push_back({ rung.id + "-cell-" + std::to_string(column), LadderCellKind::Gap, std::nullopt}); } extra.rungs.push_back(std::move(rung)); } project.controlLogics.push_back(std::move(extra)); remaining -= count; ++logic_index; } require(project.validate(defaultProjectLimitSettings()), "the project-wide rung limit fixture must itself be valid"); require( !fixture.editor.addParallelBranch( fixture.logicId, source, {node.id}, contact(21), true).succeeded, "parallel insertion must respect the project-wide rung limit"); require( fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U, "failed limit checks must not partially add a row"); } void testConfiguredRowLimit() { ProjectLimitSettings limits = defaultProjectLimitSettings(); limits.maximumRungsPerLogic = 1U; TestSupport::InMemoryProjectStorage storage; ProjectService projects(storage, limits); LogicEditorService editor(projects); const std::string logic_id = editor.ensureDefaultLogic().id; require(editor.addRung(logic_id).succeeded, "the configured row limit must permit its boundary value"); require(!editor.addRung(logic_id).succeeded, "the configured row limit must reject one extra row"); } void testFirstEditOnEmptyLogicIsAtomic() { Fixture fixture; fixture.editor.clearHistory(); const LogicEditResult condition = fixture.editor.applyConditionAndAdvance( fixture.logicId, {}, contact(11), true); require(condition.edit.succeeded, "placing the first condition must create the first row"); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require(logic->rungs.size() == 1U && logic->rungs.front().cells.front().node.has_value(), "the first condition must be stored in the first grid cell"); require(fixture.editor.canUndo(), "creating the first row and condition must be one history entry"); require(fixture.editor.undo().succeeded && fixture.editor.findLogic(fixture.logicId)->rungs.empty(), "undo must remove the atomically created first condition and row"); require(fixture.editor.redo().succeeded && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U, "redo must restore the atomically created first condition and row"); fixture.editor.clearHistory(); const std::string wire_rung = fixture.addRung(); const LogicEditResult wire = fixture.editor.applyWireAndAdvance( fixture.logicId, {wire_rung, 9, false}); require(wire.edit.succeeded, "a wire edit must succeed on a newly added row"); logic = fixture.editor.findLogic(fixture.logicId); require(logic->rungs.size() == 2U && logic->rungs.back().cells.back().kind == LadderCellKind::Wire, "an empty-target wire must be placed in the new row"); } void testCursorAdvanceAndOutputTransaction() { Fixture fixture; fixture.editor.clearHistory(); LogicEditResult applied = fixture.editor.applyConditionAndAdvance( fixture.logicId, {}, contact(50), true); require( applied.edit.succeeded && !applied.nextCursor.output && applied.nextCursor.column == 1 && !applied.nextCursor.rungId.empty(), "the first condition must create a row and advance to column two"); const std::string first_rung = applied.nextCursor.rungId; require( fixture.editor.findRung(fixture.logicId, first_rung) ->cells.front().kind == LadderCellKind::Node, "the first condition must be written to column one"); require( fixture.editor.undo().succeeded && fixture.editor.findLogic(fixture.logicId)->rungs.empty(), "one undo must remove the first condition and its atomically created row"); require(fixture.editor.redo().succeeded, "the first cursor edit must redo atomically"); fixture.editor.clearHistory(); LogicEditCursor cursor{first_rung, 1, false}; for (int column = 1; column < ProjectLimits::kMaximumConditionColumns; ++column) { applied = fixture.editor.applyWireAndAdvance( fixture.logicId, cursor); require(applied.edit.succeeded, "each wire cursor edit must succeed"); cursor = applied.nextCursor; } require( cursor.output && cursor.column == ProjectLimits::kMaximumConditionColumns, "the tenth condition cell must advance to the output slot"); fixture.editor.clearHistory(); const LogicEditResult output = fixture.editor.applyOutputAndAdvance( fixture.logicId, cursor, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 51}, CoilMode::Normal}, true); require( output.edit.succeeded && !output.nextCursor.output && output.nextCursor.column == 0 && output.nextCursor.rungId != first_rung && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 2U, "a final-network output must append a new row and move to its first cell"); require( fixture.editor.undo().succeeded && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U && !fixture.editor.findRung(fixture.logicId, first_rung) ->output.has_value(), "one undo must remove both the output and its automatically appended row"); } void testOutputAutomaticallyCompletesTrailingWires() { Fixture direct; direct.editor.clearHistory(); const LogicEditResult direct_output = direct.editor.applyOutputAndAdvance( direct.logicId, {}, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 54}, CoilMode::Normal}, true); const ControlLogic *direct_logic = direct.editor.findLogic(direct.logicId); require( direct_output.edit.succeeded && direct_logic->rungs.size() == 2U && direct_logic->rungs.front().output.has_value() && std::all_of( direct_logic->rungs.front().cells.cbegin(), direct_logic->rungs.front().cells.cend(), [](const LadderCell &cell) { return cell.kind == LadderCellKind::Wire && !cell.node.has_value(); }), "a direct output on empty logic must atomically create ten wires"); requireEmptyGrid(direct_logic->rungs.back()); require( direct.editor.undo().succeeded && direct.editor.findLogic(direct.logicId)->rungs.empty(), "one undo must remove the direct output, its wires, and the next row"); Fixture trailing; const std::string rung_id = trailing.addRung(); require( setConditionAtColumn(trailing.editor, trailing.logicId, rung_id, 0, contact(55), true).succeeded && setConditionAtColumn(trailing.editor, trailing.logicId, rung_id, 2, contact(56), true).succeeded, "the trailing-wire fixture must leave one intentional middle gap"); trailing.editor.clearHistory(); const LogicEditResult trailing_output = trailing.editor.applyOutputAndAdvance( trailing.logicId, {rung_id, ProjectLimits::kMaximumConditionColumns, true}, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 57}, CoilMode::Normal}, true); const LadderRung *completed = trailing.editor.findRung( trailing.logicId, rung_id); require( trailing_output.edit.succeeded && completed->cells[1].kind == LadderCellKind::Gap && std::all_of( completed->cells.cbegin() + 3, completed->cells.cend(), [](const LadderCell &cell) { return cell.kind == LadderCellKind::Wire; }), "output insertion must fill trailing gaps without bridging a middle gap"); require( trailing.editor.undo().succeeded && trailing.editor.findLogic(trailing.logicId)->rungs.size() == 1U && trailing.editor.findRung(trailing.logicId, rung_id) ->cells[3].kind == LadderCellKind::Gap && !trailing.editor.findRung(trailing.logicId, rung_id) ->output.has_value(), "trailing wires, output, and appended row must undo together"); } void testOutputAdvancesPastTheWholeNetworkGroup() { Fixture fixture; const std::string upper = fixture.addRung(); const std::string branch = fixture.addRung(); const std::string next_network = fixture.addRung(); require( fixture.editor.setVerticalConnection( fixture.logicId, upper, branch, 0, true).succeeded, "fixture must connect two rows into one network group"); fixture.editor.clearHistory(); const LogicEditResult output = fixture.editor.applyOutputAndAdvance( fixture.logicId, {upper, ProjectLimits::kMaximumConditionColumns, true}, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 52}, CoilMode::Normal}, true); require( output.edit.succeeded && output.nextCursor.rungId == next_network && output.nextCursor.column == 0 && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 3U, "an output must jump after every row in its connected network group"); } void testOutputLimitFailureLeavesNoPartialEdit() { ProjectLimitSettings limits = defaultProjectLimitSettings(); limits.maximumRungsPerLogic = 1U; TestSupport::InMemoryProjectStorage storage; ProjectService projects(storage, limits); LogicEditorService editor(projects); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string rung_id = editor.addRung(logic_id).id; editor.clearHistory(); const LogicEditResult output = editor.applyOutputAndAdvance( logic_id, {rung_id, ProjectLimits::kMaximumConditionColumns, true}, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 53}, CoilMode::Normal}, true); require( !output.edit.succeeded && !editor.findRung(logic_id, rung_id)->output.has_value() && editor.findLogic(logic_id)->rungs.size() == 1U && !editor.canUndo(), "a row-limit failure must not leave the output or an undo record behind"); } void testSingleWireClipboardPasteAndUndo() { Fixture fixture; const std::string source = fixture.addRung(); const std::string target = fixture.addRung(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, source, 2, 2, true).succeeded, "fixture must create one copyable wire cell"); LogicSelectionCopyRequest selection; selection.cells.push_back({source, 2}); const LogicClipboardCopyResult copied = fixture.editor.copySelection( fixture.logicId, selection); require( copied.copy.succeeded && copied.fragment.mode == LogicClipboardMode::GridObjects && copied.fragment.cells.size() == 1U && copied.fragment.cells.front().kind == LadderCellKind::Wire && copied.fragment.rowSpan == 1 && copied.fragment.columnSpan == 1, "a selected wire must become a one-cell grid fragment"); fixture.editor.clearHistory(); const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard( fixture.logicId, copied.fragment, {target, 5, false, false}); require( pasted.edit.succeeded && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 2U && fixture.editor.findCell(fixture.logicId, target, 5)->kind == LadderCellKind::Wire, "pasting one wire must change one target cell without adding a row"); require( fixture.editor.undo().succeeded && fixture.editor.findCell(fixture.logicId, target, 5)->kind == LadderCellKind::Gap, "one undo must restore the complete single-wire paste"); } void testMixedAndSparseGridClipboardFragments() { Fixture fixture; const std::string source = fixture.addRung(); const std::string target = fixture.addRung(); const LogicEditorResult first = setConditionAtColumn(fixture.editor, fixture.logicId, source, 0, contact(70), true); const LogicEditorResult second = setConditionAtColumn(fixture.editor, fixture.logicId, source, 2, contact(71), true); require( first.succeeded && second.succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, source, 1, 1, true).succeeded, "fixture must create a node-wire-node source fragment"); LogicSelectionCopyRequest selection; selection.cells = {{source, 2}, {source, 0}, {source, 1}}; const LogicClipboardCopyResult copied = fixture.editor.copySelection( fixture.logicId, selection); require( copied.copy.succeeded && copied.fragment.cells.size() == 3U && copied.fragment.cells[0].relativeColumn == 0 && copied.fragment.cells[1].relativeColumn == 1 && copied.fragment.cells[2].relativeColumn == 2, "mixed copied cells must be sorted by visual coordinates"); fixture.editor.clearHistory(); const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard( fixture.logicId, copied.fragment, {target, 4, false, false}); const LadderCell *first_paste = fixture.editor.findCell( fixture.logicId, target, 4); const LadderCell *wire_paste = fixture.editor.findCell( fixture.logicId, target, 5); const LadderCell *second_paste = fixture.editor.findCell( fixture.logicId, target, 6); require( pasted.edit.succeeded && first_paste->kind == LadderCellKind::Node && wire_paste->kind == LadderCellKind::Wire && second_paste->kind == LadderCellKind::Node && first_paste->node->id != first.id && second_paste->node->id != second.id, "mixed paste must preserve cell order and allocate new node IDs"); require( fixture.editor.undo().succeeded && fixture.editor.findCell(fixture.logicId, target, 4)->kind == LadderCellKind::Gap && fixture.editor.findCell(fixture.logicId, target, 5)->kind == LadderCellKind::Gap && fixture.editor.findCell(fixture.logicId, target, 6)->kind == LadderCellKind::Gap, "one undo must remove every object in a mixed paste"); Fixture sparse; const std::string sparse_source = sparse.addRung(); const std::string sparse_target = sparse.addRung(); require( sparse.editor.setHorizontalWireRange( sparse.logicId, sparse_source, 0, 0, true).succeeded && setConditionAtColumn(sparse.editor, sparse.logicId, sparse_source, 2, contact(72), true).succeeded && sparse.editor.setHorizontalWireRange( sparse.logicId, sparse_target, 5, 5, true).succeeded, "fixture must create a sparse source and occupied transparent hole"); LogicSelectionCopyRequest sparse_selection; sparse_selection.cells = {{sparse_source, 0}, {sparse_source, 2}}; const LogicClipboardCopyResult sparse_copy = sparse.editor.copySelection( sparse.logicId, sparse_selection); require( sparse.editor.pasteClipboard( sparse.logicId, sparse_copy.fragment, {sparse_target, 4, false, false}).edit.succeeded && sparse.editor.findCell(sparse.logicId, sparse_target, 4)->kind == LadderCellKind::Wire && sparse.editor.findCell(sparse.logicId, sparse_target, 5)->kind == LadderCellKind::Wire && sparse.editor.findCell(sparse.logicId, sparse_target, 6)->kind == LadderCellKind::Node, "an unselected hole must stay transparent and preserve target content"); } void testGridClipboardFailuresAreAtomic() { Fixture fixture; const std::string source = fixture.addRung(); const std::string target = fixture.addRung(); const std::string last = fixture.addRung(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, source, 0, 1, true).succeeded && setConditionAtColumn(fixture.editor, fixture.logicId, target, 4, contact(73), true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, target, 0, 0, true).succeeded, "fixture must create clipboard failure targets"); LogicSelectionCopyRequest one_wire_selection; one_wire_selection.cells = {{source, 0}}; const LogicClipboardFragment one_wire = fixture.editor.copySelection( fixture.logicId, one_wire_selection).fragment; fixture.editor.clearHistory(); require( !fixture.editor.pasteClipboard( fixture.logicId, one_wire, {target, 4, false, false}).edit.succeeded && fixture.editor.findCell(fixture.logicId, target, 4)->kind == LadderCellKind::Node && !fixture.editor.canUndo(), "wire paste onto a node must fail without model or history changes"); LogicSelectionCopyRequest two_wire_selection; two_wire_selection.cells = {{source, 0}, {source, 1}}; const LogicClipboardFragment two_wires = fixture.editor.copySelection( fixture.logicId, two_wire_selection).fragment; require( !fixture.editor.pasteClipboard( fixture.logicId, two_wires, {target, 9, false, false}).edit.succeeded && fixture.editor.findCell(fixture.logicId, target, 9)->kind == LadderCellKind::Gap && !fixture.editor.canUndo(), "a fragment crossing the tenth condition column must fail atomically"); LogicSelectionCopyRequest cross_row_selection; cross_row_selection.cells = {{target, 0}, {last, 0}}; require( fixture.editor.setHorizontalWireRange( fixture.logicId, last, 0, 0, true).succeeded, "fixture must complete a two-row source fragment"); const LogicClipboardFragment cross_rows = fixture.editor.copySelection( fixture.logicId, cross_row_selection).fragment; fixture.editor.clearHistory(); require( !fixture.editor.pasteClipboard( fixture.logicId, cross_rows, {last, 2, false, false}).edit.succeeded && fixture.editor.findCell(fixture.logicId, last, 2)->kind == LadderCellKind::Gap && !fixture.editor.canUndo(), "cross-row paste without enough target rows must fail atomically"); } void testOutputAndVerticalClipboardRules() { Fixture fixture; const std::string first = fixture.addRung(); const std::string second = fixture.addRung(); const std::string third = fixture.addRung(); const std::string fourth = fixture.addRung(); require( fixture.editor.setOutput( fixture.logicId, first, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 80}, CoilMode::Normal}, true).succeeded && fixture.editor.setOutput( fixture.logicId, second, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 81}, CoilMode::Set}, true).succeeded, "fixture must create source and target outputs"); LogicSelectionCopyRequest output_selection; output_selection.outputRungIds = {first}; const LogicClipboardFragment output = fixture.editor.copySelection( fixture.logicId, output_selection).fragment; require( !fixture.editor.pasteClipboard( fixture.logicId, output, {second, 0, false, false}).edit.succeeded, "a copied output must reject a condition-grid target"); const std::string old_output_id = fixture.editor.findRung( fixture.logicId, second)->output->id; fixture.editor.clearHistory(); const LogicClipboardPasteResult output_paste = fixture.editor.pasteClipboard( fixture.logicId, output, {second, ProjectLimits::kMaximumConditionColumns, true, false}); require( output_paste.edit.succeeded && fixture.editor.findRung(fixture.logicId, second)->output->id != old_output_id && std::get( fixture.editor.findRung(fixture.logicId, second)->output->config) .address.index() == 80, "an explicitly selected single output may replace a target output"); require( fixture.editor.undo().succeeded && fixture.editor.findRung(fixture.logicId, second)->output->id == old_output_id, "one undo must restore the replaced output"); require( fixture.editor.setHorizontalWireRange( fixture.logicId, first, 8, 8, true).succeeded, "fixture must add a condition-grid object beside the copied output"); LogicSelectionCopyRequest mixed_output_selection; mixed_output_selection.cells = {{first, 8}}; mixed_output_selection.outputRungIds = {first}; const LogicClipboardFragment mixed_output = fixture.editor.copySelection( fixture.logicId, mixed_output_selection).fragment; fixture.editor.clearHistory(); require( !fixture.editor.pasteClipboard( fixture.logicId, mixed_output, {second, 8, false, false}).edit.succeeded && fixture.editor.findCell(fixture.logicId, second, 8)->kind == LadderCellKind::Gap && fixture.editor.findRung(fixture.logicId, second)->output->id == old_output_id && !fixture.editor.canUndo(), "a mixed fragment must not silently replace an occupied output slot"); require( fixture.editor.setVerticalConnection( fixture.logicId, first, second, ProjectLimits::kMaximumConditionColumns, true).succeeded, "fixture must create an output-side vertical edge"); LogicSelectionCopyRequest output_edge_selection; output_edge_selection.outputRungIds = {first}; output_edge_selection.verticalConnectionIds = { connectionAt( *fixture.editor.findLogic(fixture.logicId), first, second, ProjectLimits::kMaximumConditionColumns)->id}; const LogicClipboardFragment output_edge = fixture.editor.copySelection( fixture.logicId, output_edge_selection).fragment; fixture.editor.clearHistory(); require( fixture.editor.pasteClipboard( fixture.logicId, output_edge, {third, ProjectLimits::kMaximumConditionColumns, true, false}) .edit.succeeded && fixture.editor.findRung(fixture.logicId, third) ->output.has_value() && connectionAt( *fixture.editor.findLogic(fixture.logicId), third, fourth, ProjectLimits::kMaximumConditionColumns) != nullptr, "an output plus its right-side vertical edge must paste from the output anchor"); require( fixture.editor.undo().succeeded, "one undo must remove the mixed output-edge paste"); const LogicEditorResult vertical = fixture.editor.setVerticalConnection( fixture.logicId, first, second, 2, true); require(vertical.succeeded, "fixture must create a copyable vertical edge"); LogicSelectionCopyRequest vertical_selection; vertical_selection.verticalConnectionIds = { connectionAt( *fixture.editor.findLogic(fixture.logicId), first, second, 2)->id}; const LogicClipboardFragment edge = fixture.editor.copySelection( fixture.logicId, vertical_selection).fragment; fixture.editor.clearHistory(); require( fixture.editor.pasteClipboard( fixture.logicId, edge, {second, 6, false, true}).edit.succeeded && connectionAt( *fixture.editor.findLogic(fixture.logicId), second, third, 6) != nullptr, "a vertical edge must paste onto a valid adjacent-row boundary"); const std::size_t connection_count = fixture.editor.findLogic( fixture.logicId)->verticalConnections.size(); require( fixture.editor.pasteClipboard( fixture.logicId, edge, {second, 6, false, true}).edit.succeeded && fixture.editor.findLogic(fixture.logicId) ->verticalConnections.size() == connection_count, "pasting an existing vertical edge must be idempotent"); require( !fixture.editor.pasteClipboard( fixture.logicId, edge, {fourth, 6, false, true}).edit.succeeded, "a vertical edge must not paste below the final row"); } void testWholeRowClipboardInsertionAndLimit() { Fixture fixture; const std::string first = fixture.addRung(); const std::string second = fixture.addRung(); const std::string target = fixture.addRung(); const LogicEditorResult source_node = setConditionAtColumn(fixture.editor, fixture.logicId, first, 0, contact(90), true); require( source_node.succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, second, 0, 1, true).succeeded && fixture.editor.updateNetworkComment( fixture.logicId, first, "整行复制注释").succeeded && fixture.editor.setVerticalConnection( fixture.logicId, first, second, 2, true).succeeded, "fixture must create two connected rows for whole-row copy"); LogicSelectionCopyRequest selection; selection.wholeRungIds = {second, first}; const LogicClipboardCopyResult copied = fixture.editor.copySelection( fixture.logicId, selection); require( copied.copy.succeeded && copied.fragment.mode == LogicClipboardMode::WholeRows && copied.fragment.rows.size() == 2U && copied.fragment.verticalConnections.size() == 1U, "explicit row headers must copy complete consecutive rows and internal edges"); fixture.editor.clearHistory(); const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard( fixture.logicId, copied.fragment, {target, 0, false, false}); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require( pasted.edit.succeeded && pasted.wholeRungIds.size() == 2U && logic->rungs.size() == 5U && logic->rungs[3].id == pasted.wholeRungIds[0] && logic->rungs[4].id == pasted.wholeRungIds[1] && logic->rungs[3].comment == "整行复制注释" && logic->rungs[3].cells[0].node->id != source_node.id && connectionAt( *logic, pasted.wholeRungIds[0], pasted.wholeRungIds[1], 2) != nullptr, "whole rows must insert after the selected row with new identities"); require( fixture.editor.undo().succeeded && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 3U, "one undo must remove every row in one whole-row paste"); ProjectLimitSettings limits = defaultProjectLimitSettings(); limits.maximumRungsPerLogic = 2U; TestSupport::InMemoryProjectStorage storage; ProjectService projects(storage, limits); LogicEditorService editor(projects); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string source = editor.addRung(logic_id).id; const std::string destination = editor.addRung(logic_id).id; LogicSelectionCopyRequest limit_selection; limit_selection.wholeRungIds = {source}; const LogicClipboardFragment row = editor.copySelection( logic_id, limit_selection).fragment; editor.clearHistory(); require( !editor.pasteClipboard( logic_id, row, {destination, 0, false, false}).edit.succeeded && editor.findLogic(logic_id)->rungs.size() == 2U && !editor.canUndo(), "whole-row paste at the row limit must leave no partial edit or history"); } void testSyntaxCheckNormalizesUnusedWiresAsOneEdit() { Fixture fixture; const std::string output_rung = fixture.addRung(); const std::string dangling_branch = fixture.addRung(); const std::string isolated_rung = fixture.addRung(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, output_rung, 0, 9, true).succeeded && fixture.editor.setOutput( fixture.logicId, output_rung, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 100}, CoilMode::Normal}, true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, dangling_branch, 2, 5, true).succeeded && fixture.editor.setVerticalConnection( fixture.logicId, output_rung, dangling_branch, 3, true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, isolated_rung, 7, 8, true).succeeded, "fixture must contain one valid output and several unused line fragments"); fixture.editor.clearHistory(); const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax( fixture.logicId); const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); require( checked.completed && checked.valid && checked.changed && checked.removedWireCells == 6U && checked.removedVerticalConnections == 1U && logic->verticalConnections.empty(), "syntax check must remove every unused horizontal and vertical line; wires=" + std::to_string(checked.removedWireCells) + ", verticals=" + std::to_string(checked.removedVerticalConnections) + ", valid=" + std::to_string(checked.valid) + ", changed=" + std::to_string(checked.changed)); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { require( fixture.editor.findCell( fixture.logicId, output_rung, column)->kind == LadderCellKind::Wire, "syntax normalization must preserve the complete output path"); } requireEmptyGrid(*fixture.editor.findRung( fixture.logicId, dangling_branch)); requireEmptyGrid(*fixture.editor.findRung( fixture.logicId, isolated_rung)); require( fixture.editor.canUndo() && fixture.editor.undo().succeeded && fixture.editor.findLogic(fixture.logicId) ->verticalConnections.size() == 1U && fixture.editor.findCell( fixture.logicId, dangling_branch, 2)->kind == LadderCellKind::Wire && fixture.editor.findCell( fixture.logicId, isolated_rung, 7)->kind == LadderCellKind::Wire && !fixture.editor.canUndo(), "all syntax normalization changes must be restored by one undo"); } void testSyntaxCheckPreservesValidParallelPath() { Fixture fixture; const std::string upper = fixture.addRung(); const std::string lower = fixture.addRung(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, upper, 0, 9, true).succeeded && fixture.editor.setOutput( fixture.logicId, upper, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 101}, CoilMode::Normal}, true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, upper, 5, 5, false).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, lower, 0, 5, true).succeeded && fixture.editor.setVerticalConnection( fixture.logicId, upper, lower, 0, true).succeeded && fixture.editor.setVerticalConnection( fixture.logicId, upper, lower, 6, true).succeeded, "fixture must create a parallel path around one broken upper cell"); fixture.editor.clearHistory(); const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax( fixture.logicId); require( checked.completed && checked.valid && checked.removedWireCells == 5U && checked.removedVerticalConnections == 0U && fixture.editor.findLogic(fixture.logicId) ->verticalConnections.size() == 2U, "syntax normalization must keep every line used by the valid parallel path"); for (int column = 0; column <= 5; ++column) { require( fixture.editor.findCell( fixture.logicId, lower, column)->kind == LadderCellKind::Wire, "the lower bypass path must remain complete"); } require( fixture.editor.findLogic(fixture.logicId)->validateForRunning(), "the normalized parallel network must remain runnable"); } void testSyntaxCheckKeepsBrokenOutputForErrorLocation() { Fixture fixture; const std::string rung_id = fixture.addRung(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, rung_id, 2, 9, true).succeeded && fixture.editor.setOutput( fixture.logicId, rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 102}, CoilMode::Normal}, true).succeeded, "fixture must create an output connected only on its right side"); fixture.editor.clearHistory(); const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax( fixture.logicId); require( checked.completed && !checked.valid && !checked.changed && checked.location.has_value() && checked.location->logicId == fixture.logicId && checked.location->rungId == rung_id && checked.location->network == 1 && checked.location->row == 1 && checked.location->column == ProjectLimits::kMaximumLadderColumns && checked.message.find("第 11 列") != std::string::npos && checked.message.find("第 1 列起断开") != std::string::npos && fixture.editor.findRung(fixture.logicId, rung_id) ->output.has_value() && fixture.editor.findCell( fixture.logicId, rung_id, 2)->kind == LadderCellKind::Wire && !fixture.editor.canUndo(), "a broken output network must stay visible and report its output slot"); } void testDoubleCoilCheckRemainsIndependent() { Fixture fixture; const std::string first = fixture.addRung(); const std::string second = fixture.addRung(); require( fixture.editor.setHorizontalWireRange( fixture.logicId, first, 0, 9, true).succeeded && fixture.editor.setHorizontalWireRange( fixture.logicId, second, 0, 9, true).succeeded && fixture.editor.setOutput( fixture.logicId, first, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 120}, CoilMode::Normal}, true).succeeded && fixture.editor.setOutput( fixture.logicId, second, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 120}, CoilMode::Set}, true).succeeded, "fixture must create two outputs for the same M address"); fixture.editor.clearHistory(); const LogicSyntaxCheckResult syntax = fixture.editor.checkSyntax( fixture.logicId); const LogicSyntaxCheckResult double_coil = fixture.editor.checkDoubleCoils(fixture.logicId); require( syntax.completed && syntax.valid && !syntax.changed && double_coil.completed && !double_coil.valid && double_coil.location.has_value() && double_coil.location->rungId == second && double_coil.location->row == 2 && double_coil.location->column == ProjectLimits::kMaximumLadderColumns && double_coil.message.find("M120") != std::string::npos && double_coil.message.find("第 1 行") != std::string::npos && !fixture.editor.canUndo(), "double coils must be reported only by the independent check"); } } // namespace int main() { return TestSupport::runTestSuite("logic editor service tests", { {"testContinuousGridAndIndependentHorizontalWires", testContinuousGridAndIndependentHorizontalWires}, {"testIndependentVerticalConnectionsAndNetworkSplit", testIndependentVerticalConnectionsAndNetworkSplit}, {"testNetworkCommentsFollowNetworkHeadsAndMergeAtomically", testNetworkCommentsFollowNetworkHeadsAndMergeAtomically}, {"testInsertRowSplitsVerticalEdges", testInsertRowSplitsVerticalEdges}, {"testDeleteRowMergesOnlyContinuousEdges", testDeleteRowMergesOnlyContinuousEdges}, {"testParallelBranchCreatesConnectedVisualRow", testParallelBranchCreatesConnectedVisualRow}, {"testParallelBranchReusesExistingEdges", testParallelBranchReusesExistingEdges}, {"testNodeDeletionAndHistoryAreAtomic", testNodeDeletionAndHistoryAreAtomic}, {"testSelectionDeletionIsAtomic", testSelectionDeletionIsAtomic}, {"testInvalidSelectionDeletionDoesNotMutateOrRecordHistory", testInvalidSelectionDeletionDoesNotMutateOrRecordHistory}, {"testCommandInputMapsToGridCoordinates", testCommandInputMapsToGridCoordinates}, {"testProjectRungLimitAppliesToParallelBranch", testProjectRungLimitAppliesToParallelBranch}, {"testConfiguredRowLimit", testConfiguredRowLimit}, {"testFirstEditOnEmptyLogicIsAtomic", testFirstEditOnEmptyLogicIsAtomic}, {"testCursorAdvanceAndOutputTransaction", testCursorAdvanceAndOutputTransaction}, {"testOutputAutomaticallyCompletesTrailingWires", testOutputAutomaticallyCompletesTrailingWires}, {"testOutputAdvancesPastTheWholeNetworkGroup", testOutputAdvancesPastTheWholeNetworkGroup}, {"testOutputLimitFailureLeavesNoPartialEdit", testOutputLimitFailureLeavesNoPartialEdit}, {"testSingleWireClipboardPasteAndUndo", testSingleWireClipboardPasteAndUndo}, {"testMixedAndSparseGridClipboardFragments", testMixedAndSparseGridClipboardFragments}, {"testGridClipboardFailuresAreAtomic", testGridClipboardFailuresAreAtomic}, {"testOutputAndVerticalClipboardRules", testOutputAndVerticalClipboardRules}, {"testWholeRowClipboardInsertionAndLimit", testWholeRowClipboardInsertionAndLimit}, {"testSyntaxCheckNormalizesUnusedWiresAsOneEdit", testSyntaxCheckNormalizesUnusedWiresAsOneEdit}, {"testSyntaxCheckPreservesValidParallelPath", testSyntaxCheckPreservesValidParallelPath}, {"testSyntaxCheckKeepsBrokenOutputForErrorLocation", testSyntaxCheckKeepsBrokenOutputForErrorLocation}, {"testDoubleCoilCheckRemainsIndependent", testDoubleCoilCheckRemainsIndependent}, }); }