|
- #include "domain/project_storage.h"
- #include "domain/project_limits.h"
- #include "services/logic_editor_service.h"
- #include "services/editor_history.h"
- #include "services/project_service.h"
- #include "support/test_support.h"
-
- #include <algorithm>
- #include <iostream>
- #include <stdexcept>
-
- namespace {
-
- using TestProjectStorage = TestSupport::InMemoryProjectStorage;
- using TestSupport::require;
-
- ContactNodeConfig contact(int address)
- {
- return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
- }
-
- std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id)
- {
- const LogicEditorResult result = service.addRung(logic_id);
- require(result.succeeded, "test fixture must create an empty network");
- return result.id;
- }
-
- int conditionColumns(const ConditionExpression &expression)
- {
- if (expression.kind == ConditionExpressionKind::Node)
- {
- return 1;
- }
- if (expression.kind == ConditionExpressionKind::Wire)
- {
- return expression.wire->columnSpan;
- }
- if (expression.kind == ConditionExpressionKind::Gap)
- {
- return expression.gap->columnSpan;
- }
- int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
- for (const ConditionExpression &child : expression.children)
- {
- const int child_columns = conditionColumns(child);
- columns = expression.kind == ConditionExpressionKind::Series
- ? columns + child_columns : std::max(columns, child_columns);
- }
- return columns;
- }
-
- int wireColumns(const ConditionExpression &expression)
- {
- if (expression.kind == ConditionExpressionKind::Node)
- {
- return 0;
- }
- if (expression.kind == ConditionExpressionKind::Wire)
- {
- return expression.wire->columnSpan;
- }
- if (expression.kind == ConditionExpressionKind::Gap)
- {
- return 0;
- }
- int columns = 0;
- for (const ConditionExpression &child : expression.children)
- {
- columns += wireColumns(child);
- }
- return columns;
- }
-
- int gapColumns(const ConditionExpression &expression)
- {
- if (expression.kind == ConditionExpressionKind::Gap)
- {
- return expression.gap->columnSpan;
- }
- if (expression.kind == ConditionExpressionKind::Node
- || expression.kind == ConditionExpressionKind::Wire)
- {
- return 0;
- }
- int columns = 0;
- for (const ConditionExpression &child : expression.children)
- {
- columns += gapColumns(child);
- }
- return columns;
- }
-
- int conditionNodes(const ConditionExpression &expression)
- {
- if (expression.kind == ConditionExpressionKind::Node)
- {
- return 1;
- }
- if (expression.kind == ConditionExpressionKind::Wire)
- {
- return 0;
- }
- if (expression.kind == ConditionExpressionKind::Gap)
- {
- return 0;
- }
- int count = 0;
- for (const ConditionExpression &child : expression.children)
- {
- count += conditionNodes(child);
- }
- return count;
- }
-
- const ConditionExpression *firstExpressionOfKind(
- const ConditionExpression &expression, ConditionExpressionKind kind)
- {
- if (expression.kind == kind)
- {
- return &expression;
- }
- for (const ConditionExpression &child : expression.children)
- {
- if (const ConditionExpression *found = firstExpressionOfKind(child, kind))
- {
- return found;
- }
- }
- return nullptr;
- }
-
- void testEmptyLogicCreatesNetworksOnFirstEdit()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- require(service.findLogic(logic_id)->rungs.empty(),
- "a default control logic must start without an empty network");
-
- const LogicEditorResult first_condition = service.appendCondition(
- logic_id, {}, contact(0));
- require(first_condition.succeeded
- && service.findLogic(logic_id)->rungs.size() == 1U,
- "the first condition must create network 1 atomically");
- const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front();
- require(condition_rung.condition.has_value()
- && condition_rung.condition->kind == ConditionExpressionKind::Node,
- "the first condition must not create an implicit horizontal wire");
-
- require(service.removeRung(logic_id, condition_rung.id).succeeded
- && service.findLogic(logic_id)->rungs.empty(),
- "the last network must be removable back to an empty logic");
-
- const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1);
- require(first_wire.succeeded
- && service.findLogic(logic_id)->rungs.size() == 1U
- && service.findLogic(logic_id)->rungs.front().condition->kind
- == ConditionExpressionKind::Wire,
- "the first horizontal wire must create a one-cell network");
-
- require(service.removeRung(
- logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded,
- "the wire-only network must be removable");
- const LogicEditorResult first_output = service.setOutput(
- logic_id,
- {},
- CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal});
- require(first_output.succeeded
- && service.findLogic(logic_id)->rungs.size() == 1U
- && service.findLogic(logic_id)->rungs.front().condition.has_value()
- && service.findLogic(logic_id)->rungs.front().condition->kind
- == ConditionExpressionKind::Wire
- && service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan
- == ProjectLimits::kMaximumConditionColumns
- && service.findLogic(logic_id)->rungs.front().output.has_value(),
- "the first output must create a full-width explicit wire network");
- }
-
- void testAppendingWireMovesToNextNetworkAfterTenColumns()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- for (int index = 0; index < ProjectLimits::kMaximumConditionColumns; ++index)
- {
- require(service.appendWire(logic_id, rung_id).succeeded,
- "ten horizontal wire cells must fit in one network");
- }
- const LogicEditorResult next = service.appendWire(logic_id, rung_id);
- require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U,
- "the next appended wire must create the following network");
- const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id;
- require(service.findRung(logic_id, rung_id)->condition.has_value()
- && conditionColumns(*service.findRung(logic_id, rung_id)->condition)
- == ProjectLimits::kMaximumConditionColumns
- && service.findRung(logic_id, next_rung_id)->condition.has_value(),
- "automatic network rollover must preserve both network contents");
- require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U,
- "automatic network rollover must be undone as one edit");
- }
-
- void testStructuredEditingAndNormalization()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
-
- const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0));
- const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1));
- require(first.succeeded && second.succeeded, "series append must succeed");
-
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size() == 2U,
- "two appended nodes must form a series expression");
-
- const std::string second_expression_id = second.id;
- const LogicEditorResult parallel = service.addParallelBranch(
- logic_id, rung_id, {second_expression_id}, contact(2));
- require(parallel.succeeded, "parallel insertion must succeed");
- rung = service.findRung(logic_id, rung_id);
- const ConditionExpression *parallel_expression = service.findExpression(
- logic_id, rung_id, rung->condition->children.at(1).id);
- require(parallel_expression != nullptr
- && parallel_expression->kind == ConditionExpressionKind::Parallel,
- "selected node must become a parallel expression");
-
- const LogicEditorResult nested_series = service.insertConditionAfter(
- logic_id,
- rung_id,
- parallel.id,
- contact(3));
- require(nested_series.succeeded, "a parallel branch must accept a series node");
- rung = service.findRung(logic_id, rung_id);
- require(rung->condition->kind == ConditionExpressionKind::Series,
- "root must remain a series expression");
- const ConditionExpression &nested_parallel_expression = rung->condition->children.at(1);
- require(nested_parallel_expression.kind == ConditionExpressionKind::Parallel
- && nested_parallel_expression.children.at(1).kind
- == ConditionExpressionKind::Series,
- "editor must express A AND (B OR (C AND D))");
-
- require(service.removeNode(logic_id, nested_series.id).succeeded,
- "nested series node deletion must succeed");
- rung = service.findRung(logic_id, rung_id);
- require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel
- && rung->condition->children.at(1).children.at(1).kind
- == ConditionExpressionKind::Series
- && gapColumns(*rung->condition) == 1
- && conditionColumns(*rung->condition) == 3,
- "deleting a nested node must preserve its column as an explicit gap");
-
- require(service.removeNode(logic_id, parallel.id).succeeded,
- "parallel leaf deletion must succeed");
- rung = service.findRung(logic_id, rung_id);
- require(rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size() == 2U,
- "deleting a parallel leaf must preserve the surrounding topology");
- require(gapColumns(*rung->condition) == 2
- && conditionColumns(*rung->condition) == 3,
- "each deleted condition must remain as a one-column gap");
-
- require(service.setOutput(
- logic_id,
- rung_id,
- CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
- .succeeded,
- "output coil must be set");
- require(service.findLogic(logic_id)->validate(),
- "structured editing result must remain a valid draft");
- }
-
- void testRangeParallelInsertion()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- service.appendCondition(logic_id, rung_id, contact(0));
- service.appendCondition(logic_id, rung_id, contact(1));
-
- service.appendCondition(logic_id, rung_id, contact(2));
- const LogicEditorResult branch = service.addParallelBranch(
- logic_id, rung_id, {"contact-2", "contact-3"}, contact(3));
- require(branch.succeeded, "a continuous series range must accept a parallel branch");
- const ConditionExpression &root = *service.findRung(logic_id, rung_id)->condition;
- require(root.kind == ConditionExpressionKind::Series
- && root.children.size() == 2U
- && root.children.at(1).kind == ConditionExpressionKind::Parallel
- && root.children.at(1).children.front().kind
- == ConditionExpressionKind::Series,
- "range insertion must express A AND ((B AND C) OR D)");
- require(root.validate(), "range insertion must preserve normalized topology");
-
- const LogicEditorResult invalid = service.addParallelBranch(
- logic_id, rung_id, {"contact-1", "contact-3"}, contact(4));
- require(!invalid.succeeded
- && invalid.error == LogicEditorError::InvalidOperation,
- "a non-contiguous selection must be rejected");
- }
-
- void testParallelBranchGridInsertion()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult first = service.appendCondition(
- logic_id, rung_id, contact(0));
- const LogicEditorResult second = service.appendCondition(
- logic_id, rung_id, contact(1));
- const LogicEditorResult third = service.appendCondition(
- logic_id, rung_id, contact(2));
- const LogicEditorResult branch = service.addParallelBranch(
- logic_id, rung_id, {first.id, second.id, third.id}, contact(10));
- require(branch.succeeded,
- "parallel grid insertion setup must create a short lower branch");
-
- service.clearHistory();
- const LadderRung *padded_rung = service.findRung(logic_id, rung_id);
- const ConditionExpression &padded_lower = padded_rung->condition->children.at(1);
- require(padded_lower.kind == ConditionExpressionKind::Series
- && padded_lower.children.size() == 2U
- && padded_lower.children.back().kind == ConditionExpressionKind::Wire
- && padded_lower.children.back().wire->columnSpan == 2,
- "a short parallel branch must persist its two padding cells as a wire");
- const LogicEditorResult inserted = service.replaceWireColumnWithCondition(
- logic_id,
- rung_id,
- padded_lower.children.back().id,
- 1,
- contact(12));
- require(inserted.succeeded,
- "a persisted parallel branch wire cell must accept a condition");
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Parallel,
- "branch grid insertion must preserve the surrounding parallel expression");
- const ConditionExpression &lower = rung->condition->children.at(1);
- require(lower.kind == ConditionExpressionKind::Series
- && lower.children.size() == 3U
- && lower.children.at(0).node->id == branch.id
- && lower.children.at(1).kind == ConditionExpressionKind::Wire
- && lower.children.at(1).wire->columnSpan == 1
- && lower.children.at(2).node->id == inserted.id,
- "a distant branch cell must persist only the required gap and new condition");
-
- require(service.undo().succeeded,
- "parallel branch grid insertion must be one undoable edit");
- rung = service.findRung(logic_id, rung_id);
- require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series
- && rung->condition->children.at(1).children.front().node->id == branch.id
- && rung->condition->children.at(1).children.back().kind
- == ConditionExpressionKind::Wire
- && rung->condition->children.at(1).children.back().wire->columnSpan == 2,
- "undo must restore the original branch and its explicit padding wire");
-
- service.clearHistory();
- const bool modified_before = project_service.isModified();
- const LogicEditorResult invalid = service.insertConditionInBranchAtColumn(
- logic_id, rung_id, branch.id, 3, contact(13));
- require(!invalid.succeeded && !service.canUndo()
- && project_service.isModified() == modified_before
- && service.findNode(logic_id, inserted.id) == nullptr,
- "a cell outside the visible branch padding must fail atomically");
- }
-
- void testStructuredWireEditing()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- service.appendCondition(logic_id, rung_id, contact(0));
- service.appendCondition(logic_id, rung_id, contact(1));
- service.appendCondition(logic_id, rung_id, contact(2));
-
- const LogicEditorResult branch = service.addParallelWireBranch(
- logic_id, rung_id, {"contact-2", "contact-3"});
- require(branch.succeeded && branch.id == "wire-1",
- "a continuous range must accept a structured horizontal bypass");
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.at(1).kind
- == ConditionExpressionKind::Parallel,
- "a vertical connection must produce a parallel expression");
- const ConditionExpression &wire =
- rung->condition->children.at(1).children.at(1);
- require(wire.kind == ConditionExpressionKind::Wire
- && wire.wire->columnSpan == 2,
- "the bypass wire span must match the selected two-column range");
-
- const LogicEditorResult replacement = service.replaceWireWithCondition(
- logic_id, rung_id, branch.id, contact(3));
- require(replacement.succeeded && replacement.id == "contact-4",
- "a selected wire must be replaceable by a configured node type");
- const LogicEditorResult extension = service.insertWireAfter(
- logic_id, rung_id, replacement.id);
- require(extension.succeeded && extension.id == "wire-2",
- "replacing one cell of a multi-cell wire must retain the original id on the remaining cell");
-
- rung = service.findRung(logic_id, rung_id);
- const ConditionExpression *extended_branch = service.findExpression(
- logic_id, rung_id, rung->condition->children.at(1).children.at(1).id);
- require(extended_branch != nullptr
- && extended_branch->kind == ConditionExpressionKind::Series
- && extended_branch->children.at(1).kind
- == ConditionExpressionKind::Wire,
- "inserting a horizontal wire after a branch node must preserve structure");
- const std::string extended_branch_id = extended_branch->id;
- require(!service.addParallelWireBranch(
- logic_id, rung_id, {"contact-1", "contact-3"}).succeeded,
- "non-contiguous wire connection targets must be rejected");
-
- require(service.removeExpressions(
- logic_id, rung_id, {extended_branch_id}).succeeded,
- "deleting a selected vertical connection branch must succeed atomically");
- rung = service.findRung(logic_id, rung_id);
- require(rung->condition.has_value()
- && conditionNodes(*rung->condition) == 3
- && conditionColumns(*rung->condition) == 4
- && wireColumns(*rung->condition) == 1
- && service.findNode(logic_id, replacement.id) == nullptr,
- "removing a bypass branch must keep the materialized padding wire without reconnecting positions");
- require(service.undo().succeeded
- && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr,
- "wire branch deletion must participate in ladder undo history");
- }
-
- void testWireCellParallelSelectionUsesExactColumns()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult source = service.appendWire(
- logic_id, rung_id, 3);
- require(source.succeeded, "wire-cell parallel setup must create a three-column wire");
-
- const LogicEditorResult branch = service.addParallelWireBranchAtCells(
- logic_id,
- rung_id,
- {{source.id, 0}, {source.id, 1}});
- require(branch.succeeded,
- "a selected two-cell wire range must create a parallel bypass");
-
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size() == 2U,
- "a partial wire selection must preserve the surrounding series layout");
- const ConditionExpression ¶llel = rung->condition->children.front();
- require(parallel.kind == ConditionExpressionKind::Parallel
- && parallel.children.size() == 2U
- && parallel.children.front().kind == ConditionExpressionKind::Wire
- && parallel.children.front().wire->columnSpan == 2
- && parallel.children.back().kind == ConditionExpressionKind::Wire
- && parallel.children.back().wire->columnSpan == 2
- && rung->condition->children.back().kind
- == ConditionExpressionKind::Wire
- && rung->condition->children.back().wire->columnSpan == 1
- && conditionColumns(*rung->condition) == 3,
- "the new bypass width must match the selected two cells, not the full source wire");
-
- service.clearHistory();
- const bool modified_before = project_service.isModified();
- const LogicEditorResult non_contiguous =
- service.addParallelWireBranchAtCells(
- logic_id,
- rung_id,
- {{source.id, 0}, {source.id, 2}});
- require(!non_contiguous.succeeded
- && project_service.isModified() == modified_before
- && !service.canUndo(),
- "non-contiguous wire-cell selections must fail without mutation");
- }
-
- void testWireCellParallelSelectionAcrossAdjacentWires()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- std::vector<std::string> wire_ids;
- for (int index = 0; index < 3; ++index)
- {
- const LogicEditorResult wire = service.appendWire(logic_id, rung_id);
- require(wire.succeeded,
- "adjacent wire setup must create each one-column segment");
- wire_ids.push_back(wire.id);
- }
-
- const LogicEditorResult branch = service.addParallelWireBranchAtCells(
- logic_id,
- rung_id,
- {{wire_ids.at(0), 0}, {wire_ids.at(1), 0}, {wire_ids.at(2), 0}});
- require(branch.succeeded,
- "visually continuous adjacent wire cells must create one bypass");
-
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Parallel
- && rung->condition->children.size() == 2U
- && rung->condition->children.front().kind
- == ConditionExpressionKind::Wire
- && rung->condition->children.front().wire->columnSpan == 3
- && rung->condition->children.back().kind
- == ConditionExpressionKind::Wire
- && rung->condition->children.back().wire->columnSpan == 3,
- "three adjacent one-column wires must become a three-column parallel range");
- }
-
- void testBatchDeleteAllNodesInParallelBranch()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
-
- const LogicEditorResult first = service.appendCondition(
- logic_id, rung_id, contact(0));
- const LogicEditorResult second = service.appendCondition(
- logic_id, rung_id, contact(1));
- const LogicEditorResult third = service.appendCondition(
- logic_id, rung_id, contact(2));
- const LogicEditorResult fourth = service.appendCondition(
- logic_id, rung_id, contact(3));
- require(first.succeeded && second.succeeded && third.succeeded
- && fourth.succeeded,
- "parallel batch deletion setup contacts must be created");
-
- const LogicEditorResult branch = service.addParallelBranch(
- logic_id, rung_id,
- {second.id, third.id, fourth.id},
- contact(10));
- require(branch.succeeded,
- "parallel batch deletion setup branch must be created");
-
- require(service.removeNodes(
- logic_id, {second.id, third.id, fourth.id})
- .succeeded,
- "deleting every node in a parallel branch as one batch must succeed");
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->validate(),
- "batch deletion must leave a valid normalized ladder expression");
- require(rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size() == 2U
- && rung->condition->children.at(0).kind
- == ConditionExpressionKind::Node
- && rung->condition->children.at(1).kind
- == ConditionExpressionKind::Parallel
- && conditionNodes(*rung->condition) == 2
- && gapColumns(*rung->condition) == 3
- && conditionColumns(*rung->condition) == 4,
- "deleted parallel conditions must remain as gaps without collapsing the branch");
- require(service.findNode(logic_id, second.id) == nullptr
- && service.findNode(logic_id, third.id) == nullptr
- && service.findNode(logic_id, fourth.id) == nullptr,
- "all selected parallel branch nodes must be removed");
- require(service.undo().succeeded,
- "parallel batch deletion must be undoable");
- require(service.findNode(logic_id, second.id) != nullptr
- && service.findNode(logic_id, third.id) != nullptr
- && service.findNode(logic_id, fourth.id) != nullptr,
- "undo must restore every deleted parallel branch node");
- }
-
- void testConditionColumnLimit()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
-
- for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
- {
- require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded,
- "the first ten condition columns must be editable");
- }
- require(project_service.saveAs("logic-editor-condition-limit.json").succeeded,
- "the ten-column network must be saveable before testing overflow");
- require(!project_service.isModified(),
- "saving the ten-column network must clear the modified state");
- const LogicEditorResult overflow = service.appendCondition(
- logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns));
- require(!overflow.succeeded
- && overflow.error == LogicEditorError::InvalidOperation,
- "the eleventh condition column must be rejected by the editor service");
- require(!project_service.isModified(),
- "a failed eleventh-column edit must preserve the saved state");
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size()
- == static_cast<std::size_t>(
- ProjectLimits::kMaximumConditionColumns),
- "a rejected eleventh column must leave the ten-column network unchanged");
-
- require(service.setOutput(
- logic_id,
- rung_id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
- true)
- .succeeded
- && project_service.isModified(),
- "a successful edit must make the project modified again");
- require(!service.appendCondition(
- logic_id, rung_id,
- contact(ProjectLimits::kMaximumConditionColumns))
- .succeeded
- && project_service.isModified(),
- "a failed edit must preserve an existing modified state");
- }
-
- void testUnconditionalOutputEditing()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
-
- require(service.setOutput(
- logic_id,
- rung_id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
- true)
- .succeeded,
- "the editor must allow a coil before any condition is added");
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Wire
- && rung->condition->wire->columnSpan
- == ProjectLimits::kMaximumConditionColumns
- && rung->output.has_value() && rung->validateForRunning(),
- "an editor-created unconditional network must use a full-width wire");
-
- const std::string contact_rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult contact_result = service.appendCondition(
- logic_id, contact_rung_id, contact(0));
- require(contact_result.succeeded
- && service.updateNodeConfig(
- logic_id, contact_result.id, contact(0)).succeeded
- && service.setOutput(
- logic_id,
- contact_rung_id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal},
- true).succeeded,
- "a contact followed by an output must be editable");
- const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id);
- require(contact_rung != nullptr && contact_rung->condition.has_value()
- && conditionNodes(*contact_rung->condition) == 1
- && wireColumns(*contact_rung->condition) == 9
- && conditionColumns(*contact_rung->condition) == 10
- && contact_rung->validateForRunning(),
- "a contact followed by an output must persist the remaining nine wire cells");
- }
-
- void testExplicitGapEditing()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- require(service.setOutput(
- logic_id,
- rung_id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal},
- true).succeeded,
- "gap editing setup must create an explicit full-width wire");
-
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- const std::string full_wire_id = rung->condition->id;
- require(service.disconnectWireCells(
- logic_id, rung_id, {{full_wire_id, 4}}).succeeded,
- "deleting one wire cell must create a gap");
- rung = service.findRung(logic_id, rung_id);
- require(rung->validate() && !rung->validateForRunning()
- && conditionColumns(*rung->condition) == 10
- && wireColumns(*rung->condition) == 9
- && gapColumns(*rung->condition) == 1,
- "a disconnected wire cell must preserve width and block runtime");
-
- const ConditionExpression *gap = firstExpressionOfKind(
- *rung->condition, ConditionExpressionKind::Gap);
- require(gap != nullptr, "the disconnected cell must expose a gap expression");
- const std::string first_gap_id = gap->id;
- require(service.replaceGapColumnWithWire(
- logic_id, rung_id, first_gap_id, 0).succeeded,
- "a gap cell must be repairable with a real wire");
- rung = service.findRung(logic_id, rung_id);
- require(gapColumns(*rung->condition) == 0
- && wireColumns(*rung->condition) == 10
- && rung->validateForRunning(),
- "repairing the gap with a wire must restore a runnable path");
-
- const ConditionExpression *wire = firstExpressionOfKind(
- *rung->condition, ConditionExpressionKind::Wire);
- require(wire != nullptr, "the repaired path must expose a wire expression");
- const std::string repaired_wire_id = wire->id;
- require(service.disconnectWireCells(
- logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded,
- "a repaired wire cell must be disconnectable again");
- rung = service.findRung(logic_id, rung_id);
- gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap);
- require(gap != nullptr, "the second disconnection must expose a gap expression");
- const std::string second_gap_id = gap->id;
- const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition(
- logic_id, rung_id, second_gap_id, 0, contact(31));
- require(inserted_result.succeeded
- && service.updateNodeConfig(
- logic_id, inserted_result.id, contact(31)).succeeded,
- "a gap cell must be replaceable with a contact");
- rung = service.findRung(logic_id, rung_id);
- const ConditionExpression *inserted = firstExpressionOfKind(
- *rung->condition, ConditionExpressionKind::Node);
- require(inserted != nullptr && gapColumns(*rung->condition) == 0
- && conditionNodes(*rung->condition) == 1
- && conditionColumns(*rung->condition) == 10
- && rung->validateForRunning(),
- "a contact inserted into a gap must preserve the ten-column path");
- const std::string inserted_node_id = inserted->node->id;
- require(service.removeNode(logic_id, inserted_node_id).succeeded,
- "the inserted contact must be removable");
- rung = service.findRung(logic_id, rung_id);
- require(conditionNodes(*rung->condition) == 0
- && gapColumns(*rung->condition) == 1
- && conditionColumns(*rung->condition) == 10
- && !rung->validateForRunning(),
- "deleting a contact must restore a gap instead of reconnecting the path");
-
- const std::string multi_rung_id = makeEmptyRung(service, logic_id);
- require(service.setOutput(
- logic_id,
- multi_rung_id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal},
- true).succeeded,
- "multi-gap setup must create a full-width wire");
- const std::string multi_wire_id = service.findRung(
- logic_id, multi_rung_id)->condition->id;
- require(service.disconnectWireCells(
- logic_id,
- multi_rung_id,
- {{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded,
- "multiple cells in one wire must disconnect atomically");
- const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id);
- require(conditionColumns(*multi_rung->condition) == 10
- && wireColumns(*multi_rung->condition) == 8
- && gapColumns(*multi_rung->condition) == 2
- && !multi_rung->validateForRunning(),
- "multiple disconnected cells must retain both explicit gaps");
- }
-
- void testColumnTargetedConditionInsertion()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string first_rung_id = makeEmptyRung(service, logic_id);
-
- require(service.insertConditionAtColumn(
- logic_id, first_rung_id, 4, contact(4)).succeeded,
- "an empty network must accept a condition at the selected fifth column");
- const LadderRung *rung = service.findRung(logic_id, first_rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size() == 2U
- && rung->condition->children.front().kind
- == ConditionExpressionKind::Wire
- && rung->condition->children.front().wire->columnSpan == 4
- && rung->condition->children.back().kind
- == ConditionExpressionKind::Node
- && rung->validate()
- && conditionColumns(*rung->condition) == 5,
- "column insertion must preserve the requested horizontal position");
-
- require(service.insertConditionAtColumn(
- logic_id, first_rung_id, 2, contact(2)).error
- == LogicEditorError::InvalidOperation,
- "inserting into an already occupied column must be rejected atomically");
- require(service.insertConditionAtColumn(
- logic_id, first_rung_id, 10, contact(10)).error
- == LogicEditorError::InvalidOperation,
- "the eleventh condition column must be rejected");
- require(service.insertConditionAtColumn(
- logic_id, first_rung_id, -1, contact(10)).error
- == LogicEditorError::InvalidOperation,
- "a negative grid column must be rejected");
- require(service.insertConditionAtColumn(
- logic_id,
- first_rung_id,
- 5,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
- .error == LogicEditorError::InvalidNode,
- "a condition grid slot must reject output instructions");
-
- const LogicEditorResult second_rung = service.addRung(logic_id);
- require(second_rung.succeeded,
- "column insertion test must create a second empty network");
- require(service.setOutput(
- logic_id,
- second_rung.id,
- CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
- true)
- .succeeded,
- "an output-only network must be configurable before adding a condition");
- const std::string output_wire_id = service.findRung(
- logic_id, second_rung.id)->condition->id;
- require(service.replaceWireColumnWithCondition(
- logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded,
- "the first wire cell must accept a contact on an unconditional network");
- const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
- require(output_rung != nullptr && output_rung->condition.has_value()
- && output_rung->condition->kind == ConditionExpressionKind::Series
- && conditionNodes(*output_rung->condition) == 1
- && wireColumns(*output_rung->condition) == 9
- && conditionColumns(*output_rung->condition) == 10
- && output_rung->output.has_value()
- && output_rung->validate(),
- "condition insertion must keep the independent output slot intact");
-
- const LogicEditorResult last_column_rung = service.addRung(logic_id);
- require(last_column_rung.succeeded
- && service.insertConditionAtColumn(
- logic_id, last_column_rung.id, 9, contact(9)).succeeded,
- "the tenth condition column must remain a valid insertion target");
- const LadderRung *full_width = service.findRung(
- logic_id, last_column_rung.id);
- require(full_width != nullptr && full_width->condition.has_value()
- && full_width->condition->kind == ConditionExpressionKind::Series
- && full_width->condition->children.front().wire->columnSpan == 9
- && conditionColumns(*full_width->condition) == 10,
- "last-column insertion must fill exactly the ten-column condition area");
- require(!service.appendCondition(
- logic_id, last_column_rung.id, contact(10)).succeeded
- && conditionColumns(*service.findRung(
- logic_id, last_column_rung.id)->condition) == 10,
- "a full-width grid must reject another condition without partial changes");
- }
-
- void testWireColumnReplacement()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
- require(wire.succeeded,
- "wire-column replacement test must create a four-column wire");
-
- require(service.replaceWireColumnWithCondition(
- logic_id, rung_id, wire.id, 1,
- ContactNodeConfig{
- RegisterAddress{RegisterArea::M, 1},
- ContactMode::NormallyClosed})
- .succeeded,
- "a selected wire cell must be replaceable without removing adjacent cells");
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- require(rung != nullptr && rung->condition.has_value()
- && rung->condition->kind == ConditionExpressionKind::Series
- && rung->condition->children.size() == 3U
- && rung->condition->children.front().wire->columnSpan == 1
- && rung->condition->children.at(1).kind
- == ConditionExpressionKind::Node
- && std::get<ContactNodeConfig>(
- rung->condition->children.at(1).node->config).mode
- == ContactMode::NormallyClosed
- && rung->condition->children.back().wire->columnSpan == 2
- && conditionColumns(*rung->condition) == 4,
- "wire-cell replacement must split the wire around the new contact");
-
- const std::vector<ControlLogic> before_invalid =
- project_service.project().controlLogics;
- require(service.replaceWireColumnWithCondition(
- logic_id,
- rung_id,
- rung->condition->children.front().id,
- 1,
- contact(2))
- .error == LogicEditorError::InvalidOperation,
- "an out-of-range wire-cell offset must be rejected");
- require(project_service.project().controlLogics.size() == before_invalid.size()
- && conditionColumns(*service.findRung(
- logic_id, rung_id)->condition) == 4,
- "a rejected wire-cell replacement must leave the network width unchanged");
-
- const LogicEditorResult first_cell_rung = service.addRung(logic_id);
- const LogicEditorResult first_cell_wire = service.appendWire(
- logic_id, first_cell_rung.id, 4);
- require(first_cell_rung.succeeded && first_cell_wire.succeeded
- && service.replaceWireColumnWithCondition(
- logic_id,
- first_cell_rung.id,
- first_cell_wire.id,
- 0,
- contact(10))
- .succeeded,
- "the first cell of a multi-column wire must be replaceable");
- const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
- require(first_cell != nullptr && first_cell->condition.has_value()
- && first_cell->condition->kind == ConditionExpressionKind::Series
- && first_cell->condition->children.size() == 2U
- && first_cell->condition->children.front().kind
- == ConditionExpressionKind::Node
- && first_cell->condition->children.back().kind
- == ConditionExpressionKind::Wire
- && first_cell->condition->children.back().wire->columnSpan == 3,
- "first-cell replacement must preserve the trailing wire cells");
- require(service.undo().succeeded,
- "wire-cell replacement must be one undoable edit");
- const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
- require(undone != nullptr && undone->condition.has_value()
- && undone->condition->kind == ConditionExpressionKind::Wire
- && undone->condition->wire->columnSpan == 4,
- "undo must restore the original unsplit wire");
- require(service.redo().succeeded,
- "wire-cell replacement must be redoable");
- const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
- require(redone != nullptr && redone->condition.has_value()
- && redone->condition->kind == ConditionExpressionKind::Series
- && conditionColumns(*redone->condition) == 4,
- "redo must restore the split wire without changing its width");
- const std::string redone_trailing_wire_id =
- redone->condition->children.back().id;
-
- const LogicEditorResult last_cell_rung = service.addRung(logic_id);
- const LogicEditorResult last_cell_wire = service.appendWire(
- logic_id, last_cell_rung.id, 4);
- require(last_cell_rung.succeeded && last_cell_wire.succeeded
- && service.replaceWireColumnWithCondition(
- logic_id,
- last_cell_rung.id,
- last_cell_wire.id,
- 3,
- contact(11))
- .succeeded,
- "the last cell of a multi-column wire must be replaceable");
- const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
- require(last_cell != nullptr && last_cell->condition.has_value()
- && last_cell->condition->kind == ConditionExpressionKind::Series
- && last_cell->condition->children.size() == 2U
- && last_cell->condition->children.front().kind
- == ConditionExpressionKind::Wire
- && last_cell->condition->children.front().wire->columnSpan == 3
- && last_cell->condition->children.back().kind
- == ConditionExpressionKind::Node,
- "last-cell replacement must preserve the leading wire cells");
-
- const LogicEditorResult single_cell_rung = service.addRung(logic_id);
- const LogicEditorResult single_cell_wire = service.appendWire(
- logic_id, single_cell_rung.id, 1);
- require(single_cell_rung.succeeded && single_cell_wire.succeeded
- && service.replaceWireColumnWithCondition(
- logic_id,
- single_cell_rung.id,
- single_cell_wire.id,
- 0,
- contact(12))
- .succeeded,
- "a one-column wire must use the same grid-cell replacement API");
- const LadderRung *single_cell = service.findRung(
- logic_id, single_cell_rung.id);
- require(single_cell != nullptr && single_cell->condition.has_value()
- && single_cell->condition->kind == ConditionExpressionKind::Node,
- "one-column wire replacement must normalize directly to a condition node");
-
- require(service.replaceWireColumnWithCondition(
- logic_id,
- first_cell_rung.id,
- redone_trailing_wire_id,
- 0,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
- .error == LogicEditorError::InvalidNode,
- "wire grid cells must reject output instructions");
- require(service.replaceWireColumnWithCondition(
- logic_id,
- first_cell_rung.id,
- "missing-wire",
- 0,
- contact(13))
- .error == LogicEditorError::ExpressionNotFound,
- "wire grid replacement must reject an unknown wire without mutation");
- }
-
- void testSequentialConditionInsertionConsumesFollowingWire()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
- require(wire.succeeded,
- "sequential wire replacement must start with a full-width wire");
-
- LogicEditorResult inserted = service.replaceWireColumnWithCondition(
- logic_id, rung_id, wire.id, 0, contact(0));
- require(inserted.succeeded,
- "the first condition must replace the first full-wire cell");
- std::string selected_node_id = inserted.id;
- for (int address = 1; address < 10; ++address)
- {
- inserted = service.insertConditionAfter(
- logic_id, rung_id, selected_node_id, contact(address));
- require(inserted.succeeded,
- "continuous condition insertion must consume the following wire cell");
- selected_node_id = inserted.id;
-
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- std::vector<const LogicNode *> nodes;
- collectConditionNodes(*rung->condition, &nodes);
- require(rung != nullptr && rung->condition.has_value()
- && conditionColumns(*rung->condition) == 10
- && nodes.size() == static_cast<std::size_t>(address + 1)
- && wireColumns(*rung->condition) == 9 - address,
- "each continuous insertion must preserve width while consuming one wire cell");
- }
-
- const LadderRung *full = service.findRung(logic_id, rung_id);
- require(full != nullptr && full->condition.has_value()
- && conditionColumns(*full->condition) == 10
- && wireColumns(*full->condition) == 0,
- "ten continuous insertions must replace the entire wire without expanding it");
- require(service.insertConditionAfter(
- logic_id, rung_id, selected_node_id, contact(10))
- .error == LogicEditorError::InvalidOperation,
- "the eleventh condition must still be rejected after all wire cells are consumed");
- require(conditionColumns(*service.findRung(
- logic_id, rung_id)->condition) == 10,
- "a rejected eleventh insertion must leave the full network unchanged");
-
- require(service.undo().succeeded,
- "a failed eleventh insertion must not displace the last successful undo step");
- const LadderRung *undone = service.findRung(logic_id, rung_id);
- std::vector<const LogicNode *> undone_nodes;
- collectConditionNodes(*undone->condition, &undone_nodes);
- require(undone_nodes.size() == 9U
- && wireColumns(*undone->condition) == 1
- && conditionColumns(*undone->condition) == 10,
- "undo must restore nine contacts followed by one wire cell");
- require(service.redo().succeeded,
- "the final wire-consuming insertion must be redoable");
- const LadderRung *redone = service.findRung(logic_id, rung_id);
- std::vector<const LogicNode *> redone_nodes;
- collectConditionNodes(*redone->condition, &redone_nodes);
- require(redone_nodes.size() == 10U
- && wireColumns(*redone->condition) == 0
- && conditionColumns(*redone->condition) == 10,
- "redo must restore all ten contacts without wire cells");
- }
-
- void testLogicLifecycleAndOrdering()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string first_id = service.ensureDefaultLogic().id;
- const LogicEditorResult second = service.addLogic("Safety logic");
- const LogicEditorResult third = service.addLogic("Alarm logic");
- require(second.succeeded && third.succeeded,
- "multiple control logic modules must be creatable");
- require(service.renameLogic(second.id, "Interlock logic").succeeded,
- "control logic modules must be renamable by stable id");
- require(service.renameLogic(third.id, "Interlock logic").error
- == LogicEditorError::DuplicateName,
- "control logic names must remain unique");
- require(service.moveLogic(third.id, -1).succeeded
- && project_service.project().controlLogics.at(1).id == third.id,
- "logic scan order must follow editable vector order");
- require(service.setLogicEnabled(second.id, false).succeeded
- && !service.findLogic(second.id)->enabled,
- "a control logic module must support explicit disable and enable");
- require(service.setLogicEnabled(second.id, true).succeeded
- && service.findLogic(second.id)->enabled,
- "a disabled control logic module must be re-enableable");
- require(service.removeLogic(third.id).succeeded,
- "a non-final control logic module must be deletable");
- require(service.removeLogic(second.id).succeeded,
- "logic deletion must preserve the remaining module");
- require(service.removeLogic(first_id).error
- == LogicEditorError::LastLogicRequired,
- "the project must retain at least one control logic module");
- }
-
- void testEdgeNodesAndRungComments()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
-
- const LogicEditorResult rising = service.appendCondition(
- logic_id,
- rung_id,
- EdgeContactNodeConfig{
- RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising});
- require(rising.succeeded && rising.id == "edge-1",
- "the editor must create rising edge nodes with a stable prefix");
- require(service.updateNodeConfig(
- logic_id,
- rising.id,
- EdgeContactNodeConfig{
- RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling})
- .succeeded,
- "the editor must apply edge mode and M address properties");
- require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded,
- "the editor must update a network comment by stable rung id");
- require(!service.updateRungComment(
- logic_id, rung_id, "第一行\n第二行").succeeded,
- "the editor must reject multiline network comments");
- require(!service.updateRungComment(
- logic_id,
- rung_id,
- std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a'))
- .succeeded,
- "the editor must reject oversized network comments");
-
- const LadderRung *rung = service.findRung(logic_id, rung_id);
- const LogicNode *edge = service.findNode(logic_id, rising.id);
- require(rung != nullptr && rung->comment == "延时启动网络"
- && edge != nullptr
- && std::get<EdgeContactNodeConfig>(edge->config).mode
- == EdgeMode::Falling,
- "edge and rung comment updates must remain in the model");
- }
-
- void testHistoryAndAtomicBatchDelete()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string first_rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult first = service.appendCondition(
- logic_id, first_rung_id, contact(0));
- const LogicEditorResult second = service.appendCondition(
- logic_id, first_rung_id, contact(1));
- const LogicEditorResult second_rung = service.addRung(logic_id);
- const LogicEditorResult third = service.appendCondition(
- logic_id, second_rung.id, contact(2));
- require(first.succeeded && second.succeeded && second_rung.succeeded
- && third.succeeded,
- "nodes for history testing must be created");
-
- service.clearHistory();
- require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
- "batch node deletion must validate every id before changing the logic");
- require(service.findNode(logic_id, first.id) != nullptr
- && service.findNode(logic_id, third.id) != nullptr
- && !service.canUndo(),
- "failed batch node deletion must be atomic and leave history unchanged");
-
- require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
- "valid nodes across multiple rungs must be deleted together");
- require(service.findNode(logic_id, first.id) == nullptr
- && service.findNode(logic_id, third.id) == nullptr,
- "all selected nodes must be removed by one batch operation");
- require(service.undo().succeeded
- && service.findNode(logic_id, first.id) != nullptr
- && service.findNode(logic_id, third.id) != nullptr,
- "logic undo must restore a cross-rung batch deletion");
- require(service.redo().succeeded
- && service.findNode(logic_id, first.id) == nullptr
- && service.findNode(logic_id, third.id) == nullptr,
- "logic redo must reapply a cross-rung batch deletion");
-
- service.clearHistory();
- const ControlLogic *logic = service.findLogic(logic_id);
- require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
- && !service.canUndo(),
- "setting an unchanged logic state must not consume history");
-
- service.clearHistory();
- for (int index = 1; index <= 101; ++index)
- {
- require(service.updateRungComment(
- logic_id, first_rung_id, "comment-" + std::to_string(index))
- .succeeded,
- "repeated valid rung edits must succeed");
- }
- int undo_count = 0;
- while (service.undo().succeeded)
- {
- ++undo_count;
- }
- require(undo_count == static_cast<int>(EditorHistory<int>::kMaximumEntries),
- "logic history must retain exactly the configured most recent steps");
-
- require(service.redo().succeeded,
- "logic redo must be available after an undo");
- require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
- "a new logic edit must succeed after undo");
- require(!service.canRedo(),
- "a new logic edit must clear the redo history");
- (void)second;
- }
-
- void testBatchPasteNodesAndRung()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
- const std::string rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult first = service.appendCondition(
- logic_id, rung_id, contact(10));
- const LogicEditorResult second = service.appendCondition(
- logic_id, rung_id, contact(11));
- require(first.succeeded && second.succeeded,
- "nodes for paste testing must be created");
-
- const LogicNode first_copy = *service.findNode(logic_id, first.id);
- const LogicNode second_copy = *service.findNode(logic_id, second.id);
- service.clearHistory();
- const LogicEditorResult pasted = service.pasteConditionNodes(
- logic_id, rung_id, {first_copy, second_copy});
- require(pasted.succeeded
- && service.findNode(logic_id, pasted.id) != nullptr
- && service.findRung(logic_id, rung_id)->condition.has_value()
- && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U,
- "batch condition paste must append fresh nodes");
- require(pasted.id != first.id && pasted.id != second.id
- && service.undo().succeeded
- && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U,
- "batch condition paste must use fresh ids and one undo step");
- require(service.areConditionNodesContiguous(
- logic_id, rung_id, {first.id, second.id}),
- "adjacent nodes in one series must be copyable as a range");
-
- const std::string wire_rung_id = makeEmptyRung(service, logic_id);
- const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3);
- service.clearHistory();
- LogicConditionPasteTarget wire_target;
- wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
- wire_target.expressionId = wire.id;
- wire_target.column = 1;
- const LogicEditorResult pasted_on_wire = service.pasteConditionNodes(
- logic_id, wire_rung_id, {first_copy, second_copy}, wire_target);
- const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id);
- require(pasted_on_wire.succeeded && wire_rung != nullptr
- && wire_rung->condition.has_value()
- && conditionNodes(*wire_rung->condition) == 2
- && conditionColumns(*wire_rung->condition) == 3,
- "condition paste must replace the selected wire cell and consume following wire cells");
- require(service.undo().succeeded
- && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3,
- "wire-targeted paste must be undone as one edit");
-
- const std::string full_rung_id = makeEmptyRung(service, logic_id);
- for (int address = 0; address < 9; ++address)
- {
- require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded,
- "nine conditions must fit before an atomic paste failure test");
- }
- service.clearHistory();
- require(!service.pasteConditionNodes(
- logic_id, full_rung_id, {first_copy, second_copy}).succeeded
- && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9
- && !service.canUndo(),
- "a multi-node paste that exceeds ten columns must roll back completely");
-
- const LogicEditorResult third = service.appendCondition(
- logic_id, rung_id, contact(12));
- require(third.succeeded
- && !service.areConditionNodesContiguous(
- logic_id, rung_id, {first.id, third.id}),
- "non-adjacent nodes must not be copied as one condition range");
- const LogicEditorResult output = service.setOutput(
- logic_id,
- rung_id,
- CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set},
- true);
- require(output.succeeded, "the source rung must accept an output before copying");
- const LadderRung source = *service.findRung(logic_id, rung_id);
- service.clearHistory();
- const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size();
- const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source);
- require(pasted_rung.succeeded
- && service.findLogic(logic_id)->rungs.size()
- == rung_count_before_paste + 1U
- && pasted_rung.id != source.id,
- "whole rung paste must append a new network");
- const LadderRung *copy = service.findRung(logic_id, pasted_rung.id);
- require(copy != nullptr && copy->condition.has_value()
- && copy->condition->id != source.condition->id
- && copy->output.has_value() && source.output.has_value()
- && copy->output->id != source.output->id
- && std::get<CoilNodeConfig>(copy->output->config).address.index() == 20
- && std::get<CoilNodeConfig>(copy->output->config).mode == CoilMode::Set,
- "whole rung paste must regenerate ids and preserve output configuration");
- }
-
- void testConditionPasteTargetsAndValidation()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- LogicEditorService service(project_service);
- const std::string logic_id = service.ensureDefaultLogic().id;
-
- LogicNode source;
- source.id = "copied-condition";
- source.config = contact(90);
- source.configured = true;
-
- const std::string empty_column_rung = makeEmptyRung(service, logic_id);
- LogicConditionPasteTarget empty_column;
- empty_column.kind = LogicConditionPasteTargetKind::EmptyColumn;
- empty_column.column = 2;
- require(service.pasteConditionNodes(
- logic_id, empty_column_rung, {source}, empty_column).succeeded,
- "condition paste must support an empty grid column");
- require(conditionColumns(*service.findRung(
- logic_id, empty_column_rung)->condition) == 3,
- "empty-column paste must preserve the requested column offset");
-
- const std::string branch_rung = makeEmptyRung(service, logic_id);
- const LogicEditorResult branch_source = service.appendCondition(
- logic_id, branch_rung, contact(1));
- const LogicEditorResult branch_source_two = service.appendCondition(
- logic_id, branch_rung, contact(2));
- const LogicEditorResult branch_source_three = service.appendCondition(
- logic_id, branch_rung, contact(3));
- require(branch_source.succeeded && branch_source_two.succeeded
- && branch_source_three.succeeded,
- "branch paste setup must add a source range");
- const LogicEditorResult branch = service.addParallelBranch(
- logic_id, branch_rung,
- {branch_source.id, branch_source_two.id, branch_source_three.id},
- contact(4));
- require(branch.succeeded, "branch paste setup must create a parallel branch");
- const ConditionExpression &paste_lower = service.findRung(
- logic_id, branch_rung)->condition->children.at(1);
- require(paste_lower.kind == ConditionExpressionKind::Series
- && paste_lower.children.back().kind == ConditionExpressionKind::Wire,
- "branch paste setup must persist the short branch padding wire");
- LogicConditionPasteTarget branch_target;
- branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
- branch_target.expressionId = paste_lower.children.back().id;
- branch_target.column = 1;
- require(service.pasteConditionNodes(
- logic_id, branch_rung, {source}, branch_target).succeeded,
- "condition paste must support a persisted wire cell in a branch");
-
- const std::string after_node_rung = makeEmptyRung(service, logic_id);
- const LogicEditorResult after_source = service.appendCondition(
- logic_id, after_node_rung, contact(3));
- require(after_source.succeeded, "after-node paste setup must add a source node");
- LogicConditionPasteTarget after_target;
- after_target.kind = LogicConditionPasteTargetKind::AfterNode;
- after_target.expressionId = after_source.id;
- require(service.pasteConditionNodes(
- logic_id, after_node_rung, {source}, after_target).succeeded,
- "condition paste must support insertion after a selected node");
- require(conditionNodes(*service.findRung(
- logic_id, after_node_rung)->condition) == 2,
- "after-node paste must add exactly one condition");
-
- const std::string replace_wire_rung = makeEmptyRung(service, logic_id);
- const LogicEditorResult wire = service.appendWire(
- logic_id, replace_wire_rung, 2);
- require(wire.succeeded, "replace-wire paste setup must add a wire");
- LogicConditionPasteTarget replace_wire;
- replace_wire.kind = LogicConditionPasteTargetKind::ReplaceWire;
- replace_wire.expressionId = wire.id;
- require(service.pasteConditionNodes(
- logic_id, replace_wire_rung, {source}, replace_wire).succeeded,
- "condition paste must replace an entire wire expression");
- require(conditionNodes(*service.findRung(
- logic_id, replace_wire_rung)->condition) == 1,
- "whole-wire paste must create one condition node");
-
- const std::string replace_cell_rung = makeEmptyRung(service, logic_id);
- const LogicEditorResult cell_wire = service.appendWire(
- logic_id, replace_cell_rung, 3);
- require(cell_wire.succeeded, "replace-cell paste setup must add a wire");
- LogicConditionPasteTarget replace_cell;
- replace_cell.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
- replace_cell.expressionId = cell_wire.id;
- replace_cell.column = 1;
- require(service.pasteConditionNodes(
- logic_id, replace_cell_rung, {source}, replace_cell).succeeded,
- "condition paste must replace one selected wire cell");
- require(conditionColumns(*service.findRung(
- logic_id, replace_cell_rung)->condition) == 3,
- "wire-cell paste must preserve the original network width");
-
- require(!service.pasteConditionNodes(logic_id, after_node_rung, {}).succeeded,
- "an empty condition clipboard must be rejected");
- LogicNode output = source;
- output.config = CoilNodeConfig{RegisterAddress{RegisterArea::M, 91}, CoilMode::Normal};
- require(!service.pasteConditionNodes(
- logic_id, after_node_rung, {output}).succeeded,
- "an output instruction must not be pasted into the condition area");
- require(!service.pasteConditionNodes(
- "missing-logic", after_node_rung, {source}).succeeded,
- "condition paste must reject an unknown logic");
- require(!service.pasteConditionNodes(
- logic_id, "missing-rung", {source}).succeeded,
- "condition paste must reject an unknown rung");
-
- const LadderRung *before_invalid = service.findRung(
- logic_id, after_node_rung);
- require(before_invalid != nullptr && before_invalid->condition.has_value(),
- "invalid paste setup must retain its target network");
- const int node_count_before_invalid = conditionNodes(*before_invalid->condition);
- LogicConditionPasteTarget invalid_target;
- invalid_target.kind = LogicConditionPasteTargetKind::AfterNode;
- invalid_target.expressionId = "missing-expression";
- service.clearHistory();
- require(!service.pasteConditionNodes(
- logic_id, after_node_rung, {source}, invalid_target).succeeded
- && conditionNodes(*service.findRung(
- logic_id, after_node_rung)->condition) == node_count_before_invalid
- && !service.canUndo(),
- "an invalid paste target must roll back without recording history");
- }
-
- } // namespace
-
- int main()
- {
- try
- {
- testEmptyLogicCreatesNetworksOnFirstEdit();
- testAppendingWireMovesToNextNetworkAfterTenColumns();
- testStructuredEditingAndNormalization();
- testRangeParallelInsertion();
- testParallelBranchGridInsertion();
- testStructuredWireEditing();
- testWireCellParallelSelectionUsesExactColumns();
- testWireCellParallelSelectionAcrossAdjacentWires();
- testBatchDeleteAllNodesInParallelBranch();
- testConditionColumnLimit();
- testUnconditionalOutputEditing();
- testExplicitGapEditing();
- testColumnTargetedConditionInsertion();
- testWireColumnReplacement();
- testSequentialConditionInsertionConsumesFollowingWire();
- testLogicLifecycleAndOrdering();
- testEdgeNodesAndRungComments();
- testHistoryAndAtomicBatchDelete();
- testBatchPasteNodesAndRung();
- testConditionPasteTargetsAndValidation();
- }
- catch (const std::exception &error)
- {
- std::cerr << "logic editor service tests failed: " << error.what() << '\n';
- return 1;
- }
- std::cout << "logic editor service tests passed\n";
- return 0;
- }
|