|
- #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 <algorithm>
- #include <iostream>
- #include <stdexcept>
- #include <string>
- #include <vector>
-
- 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<std::size_t>(
- 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<std::size_t>(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<std::size_t>(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<ContactNodeConfig>(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<CoilNodeConfig>(
- 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},
- });
- }
|