综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1497 lines
72 KiB

  1. #include "domain/project_storage.h"
  2. #include "domain/project_limits.h"
  3. #include "services/logic_editor_service.h"
  4. #include "services/editor_history.h"
  5. #include "services/project_service.h"
  6. #include "support/test_support.h"
  7. #include <algorithm>
  8. #include <iostream>
  9. #include <stdexcept>
  10. namespace {
  11. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  12. using TestSupport::require;
  13. ContactNodeConfig contact(int address)
  14. {
  15. return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
  16. }
  17. std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id)
  18. {
  19. const LogicEditorResult result = service.addRung(logic_id);
  20. require(result.succeeded, "test fixture must create an empty network");
  21. return result.id;
  22. }
  23. int conditionColumns(const ConditionExpression &expression)
  24. {
  25. if (expression.kind == ConditionExpressionKind::Node)
  26. {
  27. return 1;
  28. }
  29. if (expression.kind == ConditionExpressionKind::Wire)
  30. {
  31. return expression.wire->columnSpan;
  32. }
  33. if (expression.kind == ConditionExpressionKind::Gap)
  34. {
  35. return expression.gap->columnSpan;
  36. }
  37. int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
  38. for (const ConditionExpression &child : expression.children)
  39. {
  40. const int child_columns = conditionColumns(child);
  41. columns = expression.kind == ConditionExpressionKind::Series
  42. ? columns + child_columns : std::max(columns, child_columns);
  43. }
  44. return columns;
  45. }
  46. int wireColumns(const ConditionExpression &expression)
  47. {
  48. if (expression.kind == ConditionExpressionKind::Node)
  49. {
  50. return 0;
  51. }
  52. if (expression.kind == ConditionExpressionKind::Wire)
  53. {
  54. return expression.wire->columnSpan;
  55. }
  56. if (expression.kind == ConditionExpressionKind::Gap)
  57. {
  58. return 0;
  59. }
  60. int columns = 0;
  61. for (const ConditionExpression &child : expression.children)
  62. {
  63. columns += wireColumns(child);
  64. }
  65. return columns;
  66. }
  67. int gapColumns(const ConditionExpression &expression)
  68. {
  69. if (expression.kind == ConditionExpressionKind::Gap)
  70. {
  71. return expression.gap->columnSpan;
  72. }
  73. if (expression.kind == ConditionExpressionKind::Node
  74. || expression.kind == ConditionExpressionKind::Wire)
  75. {
  76. return 0;
  77. }
  78. int columns = 0;
  79. for (const ConditionExpression &child : expression.children)
  80. {
  81. columns += gapColumns(child);
  82. }
  83. return columns;
  84. }
  85. int conditionNodes(const ConditionExpression &expression)
  86. {
  87. if (expression.kind == ConditionExpressionKind::Node)
  88. {
  89. return 1;
  90. }
  91. if (expression.kind == ConditionExpressionKind::Wire)
  92. {
  93. return 0;
  94. }
  95. if (expression.kind == ConditionExpressionKind::Gap)
  96. {
  97. return 0;
  98. }
  99. int count = 0;
  100. for (const ConditionExpression &child : expression.children)
  101. {
  102. count += conditionNodes(child);
  103. }
  104. return count;
  105. }
  106. const ConditionExpression *firstExpressionOfKind(
  107. const ConditionExpression &expression, ConditionExpressionKind kind)
  108. {
  109. if (expression.kind == kind)
  110. {
  111. return &expression;
  112. }
  113. for (const ConditionExpression &child : expression.children)
  114. {
  115. if (const ConditionExpression *found = firstExpressionOfKind(child, kind))
  116. {
  117. return found;
  118. }
  119. }
  120. return nullptr;
  121. }
  122. void testEmptyLogicCreatesNetworksOnFirstEdit()
  123. {
  124. TestProjectStorage storage;
  125. ProjectService project_service(storage);
  126. LogicEditorService service(project_service);
  127. const std::string logic_id = service.ensureDefaultLogic().id;
  128. require(service.findLogic(logic_id)->rungs.empty(),
  129. "a default control logic must start without an empty network");
  130. const LogicEditorResult first_condition = service.appendCondition(
  131. logic_id, {}, contact(0));
  132. require(first_condition.succeeded
  133. && service.findLogic(logic_id)->rungs.size() == 1U,
  134. "the first condition must create network 1 atomically");
  135. const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front();
  136. require(condition_rung.condition.has_value()
  137. && condition_rung.condition->kind == ConditionExpressionKind::Node,
  138. "the first condition must not create an implicit horizontal wire");
  139. require(service.removeRung(logic_id, condition_rung.id).succeeded
  140. && service.findLogic(logic_id)->rungs.empty(),
  141. "the last network must be removable back to an empty logic");
  142. const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1);
  143. require(first_wire.succeeded
  144. && service.findLogic(logic_id)->rungs.size() == 1U
  145. && service.findLogic(logic_id)->rungs.front().condition->kind
  146. == ConditionExpressionKind::Wire,
  147. "the first horizontal wire must create a one-cell network");
  148. require(service.removeRung(
  149. logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded,
  150. "the wire-only network must be removable");
  151. const LogicEditorResult first_output = service.setOutput(
  152. logic_id,
  153. {},
  154. CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal});
  155. require(first_output.succeeded
  156. && service.findLogic(logic_id)->rungs.size() == 1U
  157. && service.findLogic(logic_id)->rungs.front().condition.has_value()
  158. && service.findLogic(logic_id)->rungs.front().condition->kind
  159. == ConditionExpressionKind::Wire
  160. && service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan
  161. == ProjectLimits::kMaximumConditionColumns
  162. && service.findLogic(logic_id)->rungs.front().output.has_value(),
  163. "the first output must create a full-width explicit wire network");
  164. }
  165. void testAppendingWireMovesToNextNetworkAfterTenColumns()
  166. {
  167. TestProjectStorage storage;
  168. ProjectService project_service(storage);
  169. LogicEditorService service(project_service);
  170. const std::string logic_id = service.ensureDefaultLogic().id;
  171. const std::string rung_id = makeEmptyRung(service, logic_id);
  172. for (int index = 0; index < ProjectLimits::kMaximumConditionColumns; ++index)
  173. {
  174. require(service.appendWire(logic_id, rung_id).succeeded,
  175. "ten horizontal wire cells must fit in one network");
  176. }
  177. const LogicEditorResult next = service.appendWire(logic_id, rung_id);
  178. require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U,
  179. "the next appended wire must create the following network");
  180. const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id;
  181. require(service.findRung(logic_id, rung_id)->condition.has_value()
  182. && conditionColumns(*service.findRung(logic_id, rung_id)->condition)
  183. == ProjectLimits::kMaximumConditionColumns
  184. && service.findRung(logic_id, next_rung_id)->condition.has_value(),
  185. "automatic network rollover must preserve both network contents");
  186. require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U,
  187. "automatic network rollover must be undone as one edit");
  188. }
  189. void testStructuredEditingAndNormalization()
  190. {
  191. TestProjectStorage storage;
  192. ProjectService project_service(storage);
  193. LogicEditorService service(project_service);
  194. const std::string logic_id = service.ensureDefaultLogic().id;
  195. const std::string rung_id = makeEmptyRung(service, logic_id);
  196. const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0));
  197. const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1));
  198. require(first.succeeded && second.succeeded, "series append must succeed");
  199. const LadderRung *rung = service.findRung(logic_id, rung_id);
  200. require(rung->condition->kind == ConditionExpressionKind::Series
  201. && rung->condition->children.size() == 2U,
  202. "two appended nodes must form a series expression");
  203. const std::string second_expression_id = second.id;
  204. const LogicEditorResult parallel = service.addParallelBranch(
  205. logic_id, rung_id, {second_expression_id}, contact(2));
  206. require(parallel.succeeded, "parallel insertion must succeed");
  207. rung = service.findRung(logic_id, rung_id);
  208. const ConditionExpression *parallel_expression = service.findExpression(
  209. logic_id, rung_id, rung->condition->children.at(1).id);
  210. require(parallel_expression != nullptr
  211. && parallel_expression->kind == ConditionExpressionKind::Parallel,
  212. "selected node must become a parallel expression");
  213. const LogicEditorResult nested_series = service.insertConditionAfter(
  214. logic_id,
  215. rung_id,
  216. parallel.id,
  217. contact(3));
  218. require(nested_series.succeeded, "a parallel branch must accept a series node");
  219. rung = service.findRung(logic_id, rung_id);
  220. require(rung->condition->kind == ConditionExpressionKind::Series,
  221. "root must remain a series expression");
  222. const ConditionExpression &nested_parallel_expression = rung->condition->children.at(1);
  223. require(nested_parallel_expression.kind == ConditionExpressionKind::Parallel
  224. && nested_parallel_expression.children.at(1).kind
  225. == ConditionExpressionKind::Series,
  226. "editor must express A AND (B OR (C AND D))");
  227. require(service.removeNode(logic_id, nested_series.id).succeeded,
  228. "nested series node deletion must succeed");
  229. rung = service.findRung(logic_id, rung_id);
  230. require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel
  231. && rung->condition->children.at(1).children.at(1).kind
  232. == ConditionExpressionKind::Series
  233. && gapColumns(*rung->condition) == 1
  234. && conditionColumns(*rung->condition) == 3,
  235. "deleting a nested node must preserve its column as an explicit gap");
  236. require(service.removeNode(logic_id, parallel.id).succeeded,
  237. "parallel leaf deletion must succeed");
  238. rung = service.findRung(logic_id, rung_id);
  239. require(rung->condition->kind == ConditionExpressionKind::Series
  240. && rung->condition->children.size() == 2U,
  241. "deleting a parallel leaf must preserve the surrounding topology");
  242. require(gapColumns(*rung->condition) == 2
  243. && conditionColumns(*rung->condition) == 3,
  244. "each deleted condition must remain as a one-column gap");
  245. require(service.setOutput(
  246. logic_id,
  247. rung_id,
  248. CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  249. .succeeded,
  250. "output coil must be set");
  251. require(service.findLogic(logic_id)->validate(),
  252. "structured editing result must remain a valid draft");
  253. }
  254. void testRangeParallelInsertion()
  255. {
  256. TestProjectStorage storage;
  257. ProjectService project_service(storage);
  258. LogicEditorService service(project_service);
  259. const std::string logic_id = service.ensureDefaultLogic().id;
  260. const std::string rung_id = makeEmptyRung(service, logic_id);
  261. service.appendCondition(logic_id, rung_id, contact(0));
  262. service.appendCondition(logic_id, rung_id, contact(1));
  263. service.appendCondition(logic_id, rung_id, contact(2));
  264. const LogicEditorResult branch = service.addParallelBranch(
  265. logic_id, rung_id, {"contact-2", "contact-3"}, contact(3));
  266. require(branch.succeeded, "a continuous series range must accept a parallel branch");
  267. const ConditionExpression &root = *service.findRung(logic_id, rung_id)->condition;
  268. require(root.kind == ConditionExpressionKind::Series
  269. && root.children.size() == 2U
  270. && root.children.at(1).kind == ConditionExpressionKind::Parallel
  271. && root.children.at(1).children.front().kind
  272. == ConditionExpressionKind::Series,
  273. "range insertion must express A AND ((B AND C) OR D)");
  274. require(root.validate(), "range insertion must preserve normalized topology");
  275. const LogicEditorResult invalid = service.addParallelBranch(
  276. logic_id, rung_id, {"contact-1", "contact-3"}, contact(4));
  277. require(!invalid.succeeded
  278. && invalid.error == LogicEditorError::InvalidOperation,
  279. "a non-contiguous selection must be rejected");
  280. }
  281. void testParallelBranchGridInsertion()
  282. {
  283. TestProjectStorage storage;
  284. ProjectService project_service(storage);
  285. LogicEditorService service(project_service);
  286. const std::string logic_id = service.ensureDefaultLogic().id;
  287. const std::string rung_id = makeEmptyRung(service, logic_id);
  288. const LogicEditorResult first = service.appendCondition(
  289. logic_id, rung_id, contact(0));
  290. const LogicEditorResult second = service.appendCondition(
  291. logic_id, rung_id, contact(1));
  292. const LogicEditorResult third = service.appendCondition(
  293. logic_id, rung_id, contact(2));
  294. const LogicEditorResult branch = service.addParallelBranch(
  295. logic_id, rung_id, {first.id, second.id, third.id}, contact(10));
  296. require(branch.succeeded,
  297. "parallel grid insertion setup must create a short lower branch");
  298. service.clearHistory();
  299. const LadderRung *padded_rung = service.findRung(logic_id, rung_id);
  300. const ConditionExpression &padded_lower = padded_rung->condition->children.at(1);
  301. require(padded_lower.kind == ConditionExpressionKind::Series
  302. && padded_lower.children.size() == 2U
  303. && padded_lower.children.back().kind == ConditionExpressionKind::Wire
  304. && padded_lower.children.back().wire->columnSpan == 2,
  305. "a short parallel branch must persist its two padding cells as a wire");
  306. const LogicEditorResult inserted = service.replaceWireColumnWithCondition(
  307. logic_id,
  308. rung_id,
  309. padded_lower.children.back().id,
  310. 1,
  311. contact(12));
  312. require(inserted.succeeded,
  313. "a persisted parallel branch wire cell must accept a condition");
  314. const LadderRung *rung = service.findRung(logic_id, rung_id);
  315. require(rung != nullptr && rung->condition.has_value()
  316. && rung->condition->kind == ConditionExpressionKind::Parallel,
  317. "branch grid insertion must preserve the surrounding parallel expression");
  318. const ConditionExpression &lower = rung->condition->children.at(1);
  319. require(lower.kind == ConditionExpressionKind::Series
  320. && lower.children.size() == 3U
  321. && lower.children.at(0).node->id == branch.id
  322. && lower.children.at(1).kind == ConditionExpressionKind::Wire
  323. && lower.children.at(1).wire->columnSpan == 1
  324. && lower.children.at(2).node->id == inserted.id,
  325. "a distant branch cell must persist only the required gap and new condition");
  326. require(service.undo().succeeded,
  327. "parallel branch grid insertion must be one undoable edit");
  328. rung = service.findRung(logic_id, rung_id);
  329. require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series
  330. && rung->condition->children.at(1).children.front().node->id == branch.id
  331. && rung->condition->children.at(1).children.back().kind
  332. == ConditionExpressionKind::Wire
  333. && rung->condition->children.at(1).children.back().wire->columnSpan == 2,
  334. "undo must restore the original branch and its explicit padding wire");
  335. service.clearHistory();
  336. const bool modified_before = project_service.isModified();
  337. const LogicEditorResult invalid = service.insertConditionInBranchAtColumn(
  338. logic_id, rung_id, branch.id, 3, contact(13));
  339. require(!invalid.succeeded && !service.canUndo()
  340. && project_service.isModified() == modified_before
  341. && service.findNode(logic_id, inserted.id) == nullptr,
  342. "a cell outside the visible branch padding must fail atomically");
  343. }
  344. void testStructuredWireEditing()
  345. {
  346. TestProjectStorage storage;
  347. ProjectService project_service(storage);
  348. LogicEditorService service(project_service);
  349. const std::string logic_id = service.ensureDefaultLogic().id;
  350. const std::string rung_id = makeEmptyRung(service, logic_id);
  351. service.appendCondition(logic_id, rung_id, contact(0));
  352. service.appendCondition(logic_id, rung_id, contact(1));
  353. service.appendCondition(logic_id, rung_id, contact(2));
  354. const LogicEditorResult branch = service.addParallelWireBranch(
  355. logic_id, rung_id, {"contact-2", "contact-3"});
  356. require(branch.succeeded && branch.id == "wire-1",
  357. "a continuous range must accept a structured horizontal bypass");
  358. const LadderRung *rung = service.findRung(logic_id, rung_id);
  359. require(rung->condition->kind == ConditionExpressionKind::Series
  360. && rung->condition->children.at(1).kind
  361. == ConditionExpressionKind::Parallel,
  362. "a vertical connection must produce a parallel expression");
  363. const ConditionExpression &wire =
  364. rung->condition->children.at(1).children.at(1);
  365. require(wire.kind == ConditionExpressionKind::Wire
  366. && wire.wire->columnSpan == 2,
  367. "the bypass wire span must match the selected two-column range");
  368. const LogicEditorResult replacement = service.replaceWireWithCondition(
  369. logic_id, rung_id, branch.id, contact(3));
  370. require(replacement.succeeded && replacement.id == "contact-4",
  371. "a selected wire must be replaceable by a configured node type");
  372. const LogicEditorResult extension = service.insertWireAfter(
  373. logic_id, rung_id, replacement.id);
  374. require(extension.succeeded && extension.id == "wire-2",
  375. "replacing one cell of a multi-cell wire must retain the original id on the remaining cell");
  376. rung = service.findRung(logic_id, rung_id);
  377. const ConditionExpression *extended_branch = service.findExpression(
  378. logic_id, rung_id, rung->condition->children.at(1).children.at(1).id);
  379. require(extended_branch != nullptr
  380. && extended_branch->kind == ConditionExpressionKind::Series
  381. && extended_branch->children.at(1).kind
  382. == ConditionExpressionKind::Wire,
  383. "inserting a horizontal wire after a branch node must preserve structure");
  384. const std::string extended_branch_id = extended_branch->id;
  385. require(!service.addParallelWireBranch(
  386. logic_id, rung_id, {"contact-1", "contact-3"}).succeeded,
  387. "non-contiguous wire connection targets must be rejected");
  388. require(service.removeExpressions(
  389. logic_id, rung_id, {extended_branch_id}).succeeded,
  390. "deleting a selected vertical connection branch must succeed atomically");
  391. rung = service.findRung(logic_id, rung_id);
  392. require(rung->condition.has_value()
  393. && conditionNodes(*rung->condition) == 3
  394. && conditionColumns(*rung->condition) == 4
  395. && wireColumns(*rung->condition) == 1
  396. && service.findNode(logic_id, replacement.id) == nullptr,
  397. "removing a bypass branch must keep the materialized padding wire without reconnecting positions");
  398. require(service.undo().succeeded
  399. && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr,
  400. "wire branch deletion must participate in ladder undo history");
  401. }
  402. void testWireCellParallelSelectionUsesExactColumns()
  403. {
  404. TestProjectStorage storage;
  405. ProjectService project_service(storage);
  406. LogicEditorService service(project_service);
  407. const std::string logic_id = service.ensureDefaultLogic().id;
  408. const std::string rung_id = makeEmptyRung(service, logic_id);
  409. const LogicEditorResult source = service.appendWire(
  410. logic_id, rung_id, 3);
  411. require(source.succeeded, "wire-cell parallel setup must create a three-column wire");
  412. const LogicEditorResult branch = service.addParallelWireBranchAtCells(
  413. logic_id,
  414. rung_id,
  415. {{source.id, 0}, {source.id, 1}});
  416. require(branch.succeeded,
  417. "a selected two-cell wire range must create a parallel bypass");
  418. const LadderRung *rung = service.findRung(logic_id, rung_id);
  419. require(rung != nullptr && rung->condition.has_value()
  420. && rung->condition->kind == ConditionExpressionKind::Series
  421. && rung->condition->children.size() == 2U,
  422. "a partial wire selection must preserve the surrounding series layout");
  423. const ConditionExpression &parallel = rung->condition->children.front();
  424. require(parallel.kind == ConditionExpressionKind::Parallel
  425. && parallel.children.size() == 2U
  426. && parallel.children.front().kind == ConditionExpressionKind::Wire
  427. && parallel.children.front().wire->columnSpan == 2
  428. && parallel.children.back().kind == ConditionExpressionKind::Wire
  429. && parallel.children.back().wire->columnSpan == 2
  430. && rung->condition->children.back().kind
  431. == ConditionExpressionKind::Wire
  432. && rung->condition->children.back().wire->columnSpan == 1
  433. && conditionColumns(*rung->condition) == 3,
  434. "the new bypass width must match the selected two cells, not the full source wire");
  435. service.clearHistory();
  436. const bool modified_before = project_service.isModified();
  437. const LogicEditorResult non_contiguous =
  438. service.addParallelWireBranchAtCells(
  439. logic_id,
  440. rung_id,
  441. {{source.id, 0}, {source.id, 2}});
  442. require(!non_contiguous.succeeded
  443. && project_service.isModified() == modified_before
  444. && !service.canUndo(),
  445. "non-contiguous wire-cell selections must fail without mutation");
  446. }
  447. void testWireCellParallelSelectionAcrossAdjacentWires()
  448. {
  449. TestProjectStorage storage;
  450. ProjectService project_service(storage);
  451. LogicEditorService service(project_service);
  452. const std::string logic_id = service.ensureDefaultLogic().id;
  453. const std::string rung_id = makeEmptyRung(service, logic_id);
  454. std::vector<std::string> wire_ids;
  455. for (int index = 0; index < 3; ++index)
  456. {
  457. const LogicEditorResult wire = service.appendWire(logic_id, rung_id);
  458. require(wire.succeeded,
  459. "adjacent wire setup must create each one-column segment");
  460. wire_ids.push_back(wire.id);
  461. }
  462. const LogicEditorResult branch = service.addParallelWireBranchAtCells(
  463. logic_id,
  464. rung_id,
  465. {{wire_ids.at(0), 0}, {wire_ids.at(1), 0}, {wire_ids.at(2), 0}});
  466. require(branch.succeeded,
  467. "visually continuous adjacent wire cells must create one bypass");
  468. const LadderRung *rung = service.findRung(logic_id, rung_id);
  469. require(rung != nullptr && rung->condition.has_value()
  470. && rung->condition->kind == ConditionExpressionKind::Parallel
  471. && rung->condition->children.size() == 2U
  472. && rung->condition->children.front().kind
  473. == ConditionExpressionKind::Wire
  474. && rung->condition->children.front().wire->columnSpan == 3
  475. && rung->condition->children.back().kind
  476. == ConditionExpressionKind::Wire
  477. && rung->condition->children.back().wire->columnSpan == 3,
  478. "three adjacent one-column wires must become a three-column parallel range");
  479. }
  480. void testBatchDeleteAllNodesInParallelBranch()
  481. {
  482. TestProjectStorage storage;
  483. ProjectService project_service(storage);
  484. LogicEditorService service(project_service);
  485. const std::string logic_id = service.ensureDefaultLogic().id;
  486. const std::string rung_id = makeEmptyRung(service, logic_id);
  487. const LogicEditorResult first = service.appendCondition(
  488. logic_id, rung_id, contact(0));
  489. const LogicEditorResult second = service.appendCondition(
  490. logic_id, rung_id, contact(1));
  491. const LogicEditorResult third = service.appendCondition(
  492. logic_id, rung_id, contact(2));
  493. const LogicEditorResult fourth = service.appendCondition(
  494. logic_id, rung_id, contact(3));
  495. require(first.succeeded && second.succeeded && third.succeeded
  496. && fourth.succeeded,
  497. "parallel batch deletion setup contacts must be created");
  498. const LogicEditorResult branch = service.addParallelBranch(
  499. logic_id, rung_id,
  500. {second.id, third.id, fourth.id},
  501. contact(10));
  502. require(branch.succeeded,
  503. "parallel batch deletion setup branch must be created");
  504. require(service.removeNodes(
  505. logic_id, {second.id, third.id, fourth.id})
  506. .succeeded,
  507. "deleting every node in a parallel branch as one batch must succeed");
  508. const LadderRung *rung = service.findRung(logic_id, rung_id);
  509. require(rung != nullptr && rung->condition.has_value()
  510. && rung->validate(),
  511. "batch deletion must leave a valid normalized ladder expression");
  512. require(rung->condition->kind == ConditionExpressionKind::Series
  513. && rung->condition->children.size() == 2U
  514. && rung->condition->children.at(0).kind
  515. == ConditionExpressionKind::Node
  516. && rung->condition->children.at(1).kind
  517. == ConditionExpressionKind::Parallel
  518. && conditionNodes(*rung->condition) == 2
  519. && gapColumns(*rung->condition) == 3
  520. && conditionColumns(*rung->condition) == 4,
  521. "deleted parallel conditions must remain as gaps without collapsing the branch");
  522. require(service.findNode(logic_id, second.id) == nullptr
  523. && service.findNode(logic_id, third.id) == nullptr
  524. && service.findNode(logic_id, fourth.id) == nullptr,
  525. "all selected parallel branch nodes must be removed");
  526. require(service.undo().succeeded,
  527. "parallel batch deletion must be undoable");
  528. require(service.findNode(logic_id, second.id) != nullptr
  529. && service.findNode(logic_id, third.id) != nullptr
  530. && service.findNode(logic_id, fourth.id) != nullptr,
  531. "undo must restore every deleted parallel branch node");
  532. }
  533. void testConditionColumnLimit()
  534. {
  535. TestProjectStorage storage;
  536. ProjectService project_service(storage);
  537. LogicEditorService service(project_service);
  538. const std::string logic_id = service.ensureDefaultLogic().id;
  539. const std::string rung_id = makeEmptyRung(service, logic_id);
  540. for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
  541. {
  542. require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded,
  543. "the first ten condition columns must be editable");
  544. }
  545. require(project_service.saveAs("logic-editor-condition-limit.json").succeeded,
  546. "the ten-column network must be saveable before testing overflow");
  547. require(!project_service.isModified(),
  548. "saving the ten-column network must clear the modified state");
  549. const LogicEditorResult overflow = service.appendCondition(
  550. logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns));
  551. require(!overflow.succeeded
  552. && overflow.error == LogicEditorError::InvalidOperation,
  553. "the eleventh condition column must be rejected by the editor service");
  554. require(!project_service.isModified(),
  555. "a failed eleventh-column edit must preserve the saved state");
  556. const LadderRung *rung = service.findRung(logic_id, rung_id);
  557. require(rung != nullptr && rung->condition.has_value()
  558. && rung->condition->kind == ConditionExpressionKind::Series
  559. && rung->condition->children.size()
  560. == static_cast<std::size_t>(
  561. ProjectLimits::kMaximumConditionColumns),
  562. "a rejected eleventh column must leave the ten-column network unchanged");
  563. require(service.setOutput(
  564. logic_id,
  565. rung_id,
  566. CoilNodeConfig{
  567. RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  568. true)
  569. .succeeded
  570. && project_service.isModified(),
  571. "a successful edit must make the project modified again");
  572. require(!service.appendCondition(
  573. logic_id, rung_id,
  574. contact(ProjectLimits::kMaximumConditionColumns))
  575. .succeeded
  576. && project_service.isModified(),
  577. "a failed edit must preserve an existing modified state");
  578. }
  579. void testUnconditionalOutputEditing()
  580. {
  581. TestProjectStorage storage;
  582. ProjectService project_service(storage);
  583. LogicEditorService service(project_service);
  584. const std::string logic_id = service.ensureDefaultLogic().id;
  585. const std::string rung_id = makeEmptyRung(service, logic_id);
  586. require(service.setOutput(
  587. logic_id,
  588. rung_id,
  589. CoilNodeConfig{
  590. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
  591. true)
  592. .succeeded,
  593. "the editor must allow a coil before any condition is added");
  594. const LadderRung *rung = service.findRung(logic_id, rung_id);
  595. require(rung != nullptr && rung->condition.has_value()
  596. && rung->condition->kind == ConditionExpressionKind::Wire
  597. && rung->condition->wire->columnSpan
  598. == ProjectLimits::kMaximumConditionColumns
  599. && rung->output.has_value() && rung->validateForRunning(),
  600. "an editor-created unconditional network must use a full-width wire");
  601. const std::string contact_rung_id = makeEmptyRung(service, logic_id);
  602. const LogicEditorResult contact_result = service.appendCondition(
  603. logic_id, contact_rung_id, contact(0));
  604. require(contact_result.succeeded
  605. && service.updateNodeConfig(
  606. logic_id, contact_result.id, contact(0)).succeeded
  607. && service.setOutput(
  608. logic_id,
  609. contact_rung_id,
  610. CoilNodeConfig{
  611. RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal},
  612. true).succeeded,
  613. "a contact followed by an output must be editable");
  614. const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id);
  615. require(contact_rung != nullptr && contact_rung->condition.has_value()
  616. && conditionNodes(*contact_rung->condition) == 1
  617. && wireColumns(*contact_rung->condition) == 9
  618. && conditionColumns(*contact_rung->condition) == 10
  619. && contact_rung->validateForRunning(),
  620. "a contact followed by an output must persist the remaining nine wire cells");
  621. }
  622. void testExplicitGapEditing()
  623. {
  624. TestProjectStorage storage;
  625. ProjectService project_service(storage);
  626. LogicEditorService service(project_service);
  627. const std::string logic_id = service.ensureDefaultLogic().id;
  628. const std::string rung_id = makeEmptyRung(service, logic_id);
  629. require(service.setOutput(
  630. logic_id,
  631. rung_id,
  632. CoilNodeConfig{
  633. RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal},
  634. true).succeeded,
  635. "gap editing setup must create an explicit full-width wire");
  636. const LadderRung *rung = service.findRung(logic_id, rung_id);
  637. const std::string full_wire_id = rung->condition->id;
  638. require(service.disconnectWireCells(
  639. logic_id, rung_id, {{full_wire_id, 4}}).succeeded,
  640. "deleting one wire cell must create a gap");
  641. rung = service.findRung(logic_id, rung_id);
  642. require(rung->validate() && !rung->validateForRunning()
  643. && conditionColumns(*rung->condition) == 10
  644. && wireColumns(*rung->condition) == 9
  645. && gapColumns(*rung->condition) == 1,
  646. "a disconnected wire cell must preserve width and block runtime");
  647. const ConditionExpression *gap = firstExpressionOfKind(
  648. *rung->condition, ConditionExpressionKind::Gap);
  649. require(gap != nullptr, "the disconnected cell must expose a gap expression");
  650. const std::string first_gap_id = gap->id;
  651. require(service.replaceGapColumnWithWire(
  652. logic_id, rung_id, first_gap_id, 0).succeeded,
  653. "a gap cell must be repairable with a real wire");
  654. rung = service.findRung(logic_id, rung_id);
  655. require(gapColumns(*rung->condition) == 0
  656. && wireColumns(*rung->condition) == 10
  657. && rung->validateForRunning(),
  658. "repairing the gap with a wire must restore a runnable path");
  659. const ConditionExpression *wire = firstExpressionOfKind(
  660. *rung->condition, ConditionExpressionKind::Wire);
  661. require(wire != nullptr, "the repaired path must expose a wire expression");
  662. const std::string repaired_wire_id = wire->id;
  663. require(service.disconnectWireCells(
  664. logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded,
  665. "a repaired wire cell must be disconnectable again");
  666. rung = service.findRung(logic_id, rung_id);
  667. gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap);
  668. require(gap != nullptr, "the second disconnection must expose a gap expression");
  669. const std::string second_gap_id = gap->id;
  670. const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition(
  671. logic_id, rung_id, second_gap_id, 0, contact(31));
  672. require(inserted_result.succeeded
  673. && service.updateNodeConfig(
  674. logic_id, inserted_result.id, contact(31)).succeeded,
  675. "a gap cell must be replaceable with a contact");
  676. rung = service.findRung(logic_id, rung_id);
  677. const ConditionExpression *inserted = firstExpressionOfKind(
  678. *rung->condition, ConditionExpressionKind::Node);
  679. require(inserted != nullptr && gapColumns(*rung->condition) == 0
  680. && conditionNodes(*rung->condition) == 1
  681. && conditionColumns(*rung->condition) == 10
  682. && rung->validateForRunning(),
  683. "a contact inserted into a gap must preserve the ten-column path");
  684. const std::string inserted_node_id = inserted->node->id;
  685. require(service.removeNode(logic_id, inserted_node_id).succeeded,
  686. "the inserted contact must be removable");
  687. rung = service.findRung(logic_id, rung_id);
  688. require(conditionNodes(*rung->condition) == 0
  689. && gapColumns(*rung->condition) == 1
  690. && conditionColumns(*rung->condition) == 10
  691. && !rung->validateForRunning(),
  692. "deleting a contact must restore a gap instead of reconnecting the path");
  693. const std::string multi_rung_id = makeEmptyRung(service, logic_id);
  694. require(service.setOutput(
  695. logic_id,
  696. multi_rung_id,
  697. CoilNodeConfig{
  698. RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal},
  699. true).succeeded,
  700. "multi-gap setup must create a full-width wire");
  701. const std::string multi_wire_id = service.findRung(
  702. logic_id, multi_rung_id)->condition->id;
  703. require(service.disconnectWireCells(
  704. logic_id,
  705. multi_rung_id,
  706. {{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded,
  707. "multiple cells in one wire must disconnect atomically");
  708. const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id);
  709. require(conditionColumns(*multi_rung->condition) == 10
  710. && wireColumns(*multi_rung->condition) == 8
  711. && gapColumns(*multi_rung->condition) == 2
  712. && !multi_rung->validateForRunning(),
  713. "multiple disconnected cells must retain both explicit gaps");
  714. }
  715. void testColumnTargetedConditionInsertion()
  716. {
  717. TestProjectStorage storage;
  718. ProjectService project_service(storage);
  719. LogicEditorService service(project_service);
  720. const std::string logic_id = service.ensureDefaultLogic().id;
  721. const std::string first_rung_id = makeEmptyRung(service, logic_id);
  722. require(service.insertConditionAtColumn(
  723. logic_id, first_rung_id, 4, contact(4)).succeeded,
  724. "an empty network must accept a condition at the selected fifth column");
  725. const LadderRung *rung = service.findRung(logic_id, first_rung_id);
  726. require(rung != nullptr && rung->condition.has_value()
  727. && rung->condition->kind == ConditionExpressionKind::Series
  728. && rung->condition->children.size() == 2U
  729. && rung->condition->children.front().kind
  730. == ConditionExpressionKind::Wire
  731. && rung->condition->children.front().wire->columnSpan == 4
  732. && rung->condition->children.back().kind
  733. == ConditionExpressionKind::Node
  734. && rung->validate()
  735. && conditionColumns(*rung->condition) == 5,
  736. "column insertion must preserve the requested horizontal position");
  737. require(service.insertConditionAtColumn(
  738. logic_id, first_rung_id, 2, contact(2)).error
  739. == LogicEditorError::InvalidOperation,
  740. "inserting into an already occupied column must be rejected atomically");
  741. require(service.insertConditionAtColumn(
  742. logic_id, first_rung_id, 10, contact(10)).error
  743. == LogicEditorError::InvalidOperation,
  744. "the eleventh condition column must be rejected");
  745. require(service.insertConditionAtColumn(
  746. logic_id, first_rung_id, -1, contact(10)).error
  747. == LogicEditorError::InvalidOperation,
  748. "a negative grid column must be rejected");
  749. require(service.insertConditionAtColumn(
  750. logic_id,
  751. first_rung_id,
  752. 5,
  753. CoilNodeConfig{
  754. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  755. .error == LogicEditorError::InvalidNode,
  756. "a condition grid slot must reject output instructions");
  757. const LogicEditorResult second_rung = service.addRung(logic_id);
  758. require(second_rung.succeeded,
  759. "column insertion test must create a second empty network");
  760. require(service.setOutput(
  761. logic_id,
  762. second_rung.id,
  763. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  764. true)
  765. .succeeded,
  766. "an output-only network must be configurable before adding a condition");
  767. const std::string output_wire_id = service.findRung(
  768. logic_id, second_rung.id)->condition->id;
  769. require(service.replaceWireColumnWithCondition(
  770. logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded,
  771. "the first wire cell must accept a contact on an unconditional network");
  772. const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
  773. require(output_rung != nullptr && output_rung->condition.has_value()
  774. && output_rung->condition->kind == ConditionExpressionKind::Series
  775. && conditionNodes(*output_rung->condition) == 1
  776. && wireColumns(*output_rung->condition) == 9
  777. && conditionColumns(*output_rung->condition) == 10
  778. && output_rung->output.has_value()
  779. && output_rung->validate(),
  780. "condition insertion must keep the independent output slot intact");
  781. const LogicEditorResult last_column_rung = service.addRung(logic_id);
  782. require(last_column_rung.succeeded
  783. && service.insertConditionAtColumn(
  784. logic_id, last_column_rung.id, 9, contact(9)).succeeded,
  785. "the tenth condition column must remain a valid insertion target");
  786. const LadderRung *full_width = service.findRung(
  787. logic_id, last_column_rung.id);
  788. require(full_width != nullptr && full_width->condition.has_value()
  789. && full_width->condition->kind == ConditionExpressionKind::Series
  790. && full_width->condition->children.front().wire->columnSpan == 9
  791. && conditionColumns(*full_width->condition) == 10,
  792. "last-column insertion must fill exactly the ten-column condition area");
  793. require(!service.appendCondition(
  794. logic_id, last_column_rung.id, contact(10)).succeeded
  795. && conditionColumns(*service.findRung(
  796. logic_id, last_column_rung.id)->condition) == 10,
  797. "a full-width grid must reject another condition without partial changes");
  798. }
  799. void testWireColumnReplacement()
  800. {
  801. TestProjectStorage storage;
  802. ProjectService project_service(storage);
  803. LogicEditorService service(project_service);
  804. const std::string logic_id = service.ensureDefaultLogic().id;
  805. const std::string rung_id = makeEmptyRung(service, logic_id);
  806. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
  807. require(wire.succeeded,
  808. "wire-column replacement test must create a four-column wire");
  809. require(service.replaceWireColumnWithCondition(
  810. logic_id, rung_id, wire.id, 1,
  811. ContactNodeConfig{
  812. RegisterAddress{RegisterArea::M, 1},
  813. ContactMode::NormallyClosed})
  814. .succeeded,
  815. "a selected wire cell must be replaceable without removing adjacent cells");
  816. const LadderRung *rung = service.findRung(logic_id, rung_id);
  817. require(rung != nullptr && rung->condition.has_value()
  818. && rung->condition->kind == ConditionExpressionKind::Series
  819. && rung->condition->children.size() == 3U
  820. && rung->condition->children.front().wire->columnSpan == 1
  821. && rung->condition->children.at(1).kind
  822. == ConditionExpressionKind::Node
  823. && std::get<ContactNodeConfig>(
  824. rung->condition->children.at(1).node->config).mode
  825. == ContactMode::NormallyClosed
  826. && rung->condition->children.back().wire->columnSpan == 2
  827. && conditionColumns(*rung->condition) == 4,
  828. "wire-cell replacement must split the wire around the new contact");
  829. const std::vector<ControlLogic> before_invalid =
  830. project_service.project().controlLogics;
  831. require(service.replaceWireColumnWithCondition(
  832. logic_id,
  833. rung_id,
  834. rung->condition->children.front().id,
  835. 1,
  836. contact(2))
  837. .error == LogicEditorError::InvalidOperation,
  838. "an out-of-range wire-cell offset must be rejected");
  839. require(project_service.project().controlLogics.size() == before_invalid.size()
  840. && conditionColumns(*service.findRung(
  841. logic_id, rung_id)->condition) == 4,
  842. "a rejected wire-cell replacement must leave the network width unchanged");
  843. const LogicEditorResult first_cell_rung = service.addRung(logic_id);
  844. const LogicEditorResult first_cell_wire = service.appendWire(
  845. logic_id, first_cell_rung.id, 4);
  846. require(first_cell_rung.succeeded && first_cell_wire.succeeded
  847. && service.replaceWireColumnWithCondition(
  848. logic_id,
  849. first_cell_rung.id,
  850. first_cell_wire.id,
  851. 0,
  852. contact(10))
  853. .succeeded,
  854. "the first cell of a multi-column wire must be replaceable");
  855. const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
  856. require(first_cell != nullptr && first_cell->condition.has_value()
  857. && first_cell->condition->kind == ConditionExpressionKind::Series
  858. && first_cell->condition->children.size() == 2U
  859. && first_cell->condition->children.front().kind
  860. == ConditionExpressionKind::Node
  861. && first_cell->condition->children.back().kind
  862. == ConditionExpressionKind::Wire
  863. && first_cell->condition->children.back().wire->columnSpan == 3,
  864. "first-cell replacement must preserve the trailing wire cells");
  865. require(service.undo().succeeded,
  866. "wire-cell replacement must be one undoable edit");
  867. const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
  868. require(undone != nullptr && undone->condition.has_value()
  869. && undone->condition->kind == ConditionExpressionKind::Wire
  870. && undone->condition->wire->columnSpan == 4,
  871. "undo must restore the original unsplit wire");
  872. require(service.redo().succeeded,
  873. "wire-cell replacement must be redoable");
  874. const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
  875. require(redone != nullptr && redone->condition.has_value()
  876. && redone->condition->kind == ConditionExpressionKind::Series
  877. && conditionColumns(*redone->condition) == 4,
  878. "redo must restore the split wire without changing its width");
  879. const std::string redone_trailing_wire_id =
  880. redone->condition->children.back().id;
  881. const LogicEditorResult last_cell_rung = service.addRung(logic_id);
  882. const LogicEditorResult last_cell_wire = service.appendWire(
  883. logic_id, last_cell_rung.id, 4);
  884. require(last_cell_rung.succeeded && last_cell_wire.succeeded
  885. && service.replaceWireColumnWithCondition(
  886. logic_id,
  887. last_cell_rung.id,
  888. last_cell_wire.id,
  889. 3,
  890. contact(11))
  891. .succeeded,
  892. "the last cell of a multi-column wire must be replaceable");
  893. const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
  894. require(last_cell != nullptr && last_cell->condition.has_value()
  895. && last_cell->condition->kind == ConditionExpressionKind::Series
  896. && last_cell->condition->children.size() == 2U
  897. && last_cell->condition->children.front().kind
  898. == ConditionExpressionKind::Wire
  899. && last_cell->condition->children.front().wire->columnSpan == 3
  900. && last_cell->condition->children.back().kind
  901. == ConditionExpressionKind::Node,
  902. "last-cell replacement must preserve the leading wire cells");
  903. const LogicEditorResult single_cell_rung = service.addRung(logic_id);
  904. const LogicEditorResult single_cell_wire = service.appendWire(
  905. logic_id, single_cell_rung.id, 1);
  906. require(single_cell_rung.succeeded && single_cell_wire.succeeded
  907. && service.replaceWireColumnWithCondition(
  908. logic_id,
  909. single_cell_rung.id,
  910. single_cell_wire.id,
  911. 0,
  912. contact(12))
  913. .succeeded,
  914. "a one-column wire must use the same grid-cell replacement API");
  915. const LadderRung *single_cell = service.findRung(
  916. logic_id, single_cell_rung.id);
  917. require(single_cell != nullptr && single_cell->condition.has_value()
  918. && single_cell->condition->kind == ConditionExpressionKind::Node,
  919. "one-column wire replacement must normalize directly to a condition node");
  920. require(service.replaceWireColumnWithCondition(
  921. logic_id,
  922. first_cell_rung.id,
  923. redone_trailing_wire_id,
  924. 0,
  925. CoilNodeConfig{
  926. RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
  927. .error == LogicEditorError::InvalidNode,
  928. "wire grid cells must reject output instructions");
  929. require(service.replaceWireColumnWithCondition(
  930. logic_id,
  931. first_cell_rung.id,
  932. "missing-wire",
  933. 0,
  934. contact(13))
  935. .error == LogicEditorError::ExpressionNotFound,
  936. "wire grid replacement must reject an unknown wire without mutation");
  937. }
  938. void testSequentialConditionInsertionConsumesFollowingWire()
  939. {
  940. TestProjectStorage storage;
  941. ProjectService project_service(storage);
  942. LogicEditorService service(project_service);
  943. const std::string logic_id = service.ensureDefaultLogic().id;
  944. const std::string rung_id = makeEmptyRung(service, logic_id);
  945. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
  946. require(wire.succeeded,
  947. "sequential wire replacement must start with a full-width wire");
  948. LogicEditorResult inserted = service.replaceWireColumnWithCondition(
  949. logic_id, rung_id, wire.id, 0, contact(0));
  950. require(inserted.succeeded,
  951. "the first condition must replace the first full-wire cell");
  952. std::string selected_node_id = inserted.id;
  953. for (int address = 1; address < 10; ++address)
  954. {
  955. inserted = service.insertConditionAfter(
  956. logic_id, rung_id, selected_node_id, contact(address));
  957. require(inserted.succeeded,
  958. "continuous condition insertion must consume the following wire cell");
  959. selected_node_id = inserted.id;
  960. const LadderRung *rung = service.findRung(logic_id, rung_id);
  961. std::vector<const LogicNode *> nodes;
  962. collectConditionNodes(*rung->condition, &nodes);
  963. require(rung != nullptr && rung->condition.has_value()
  964. && conditionColumns(*rung->condition) == 10
  965. && nodes.size() == static_cast<std::size_t>(address + 1)
  966. && wireColumns(*rung->condition) == 9 - address,
  967. "each continuous insertion must preserve width while consuming one wire cell");
  968. }
  969. const LadderRung *full = service.findRung(logic_id, rung_id);
  970. require(full != nullptr && full->condition.has_value()
  971. && conditionColumns(*full->condition) == 10
  972. && wireColumns(*full->condition) == 0,
  973. "ten continuous insertions must replace the entire wire without expanding it");
  974. require(service.insertConditionAfter(
  975. logic_id, rung_id, selected_node_id, contact(10))
  976. .error == LogicEditorError::InvalidOperation,
  977. "the eleventh condition must still be rejected after all wire cells are consumed");
  978. require(conditionColumns(*service.findRung(
  979. logic_id, rung_id)->condition) == 10,
  980. "a rejected eleventh insertion must leave the full network unchanged");
  981. require(service.undo().succeeded,
  982. "a failed eleventh insertion must not displace the last successful undo step");
  983. const LadderRung *undone = service.findRung(logic_id, rung_id);
  984. std::vector<const LogicNode *> undone_nodes;
  985. collectConditionNodes(*undone->condition, &undone_nodes);
  986. require(undone_nodes.size() == 9U
  987. && wireColumns(*undone->condition) == 1
  988. && conditionColumns(*undone->condition) == 10,
  989. "undo must restore nine contacts followed by one wire cell");
  990. require(service.redo().succeeded,
  991. "the final wire-consuming insertion must be redoable");
  992. const LadderRung *redone = service.findRung(logic_id, rung_id);
  993. std::vector<const LogicNode *> redone_nodes;
  994. collectConditionNodes(*redone->condition, &redone_nodes);
  995. require(redone_nodes.size() == 10U
  996. && wireColumns(*redone->condition) == 0
  997. && conditionColumns(*redone->condition) == 10,
  998. "redo must restore all ten contacts without wire cells");
  999. }
  1000. void testLogicLifecycleAndOrdering()
  1001. {
  1002. TestProjectStorage storage;
  1003. ProjectService project_service(storage);
  1004. LogicEditorService service(project_service);
  1005. const std::string first_id = service.ensureDefaultLogic().id;
  1006. const LogicEditorResult second = service.addLogic("Safety logic");
  1007. const LogicEditorResult third = service.addLogic("Alarm logic");
  1008. require(second.succeeded && third.succeeded,
  1009. "multiple control logic modules must be creatable");
  1010. require(service.renameLogic(second.id, "Interlock logic").succeeded,
  1011. "control logic modules must be renamable by stable id");
  1012. require(service.renameLogic(third.id, "Interlock logic").error
  1013. == LogicEditorError::DuplicateName,
  1014. "control logic names must remain unique");
  1015. require(service.moveLogic(third.id, -1).succeeded
  1016. && project_service.project().controlLogics.at(1).id == third.id,
  1017. "logic scan order must follow editable vector order");
  1018. require(service.setLogicEnabled(second.id, false).succeeded
  1019. && !service.findLogic(second.id)->enabled,
  1020. "a control logic module must support explicit disable and enable");
  1021. require(service.setLogicEnabled(second.id, true).succeeded
  1022. && service.findLogic(second.id)->enabled,
  1023. "a disabled control logic module must be re-enableable");
  1024. require(service.removeLogic(third.id).succeeded,
  1025. "a non-final control logic module must be deletable");
  1026. require(service.removeLogic(second.id).succeeded,
  1027. "logic deletion must preserve the remaining module");
  1028. require(service.removeLogic(first_id).error
  1029. == LogicEditorError::LastLogicRequired,
  1030. "the project must retain at least one control logic module");
  1031. }
  1032. void testEdgeNodesAndRungComments()
  1033. {
  1034. TestProjectStorage storage;
  1035. ProjectService project_service(storage);
  1036. LogicEditorService service(project_service);
  1037. const std::string logic_id = service.ensureDefaultLogic().id;
  1038. const std::string rung_id = makeEmptyRung(service, logic_id);
  1039. const LogicEditorResult rising = service.appendCondition(
  1040. logic_id,
  1041. rung_id,
  1042. EdgeContactNodeConfig{
  1043. RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising});
  1044. require(rising.succeeded && rising.id == "edge-1",
  1045. "the editor must create rising edge nodes with a stable prefix");
  1046. require(service.updateNodeConfig(
  1047. logic_id,
  1048. rising.id,
  1049. EdgeContactNodeConfig{
  1050. RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling})
  1051. .succeeded,
  1052. "the editor must apply edge mode and M address properties");
  1053. require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded,
  1054. "the editor must update a network comment by stable rung id");
  1055. require(!service.updateRungComment(
  1056. logic_id, rung_id, "第一行\n第二行").succeeded,
  1057. "the editor must reject multiline network comments");
  1058. require(!service.updateRungComment(
  1059. logic_id,
  1060. rung_id,
  1061. std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a'))
  1062. .succeeded,
  1063. "the editor must reject oversized network comments");
  1064. const LadderRung *rung = service.findRung(logic_id, rung_id);
  1065. const LogicNode *edge = service.findNode(logic_id, rising.id);
  1066. require(rung != nullptr && rung->comment == "延时启动网络"
  1067. && edge != nullptr
  1068. && std::get<EdgeContactNodeConfig>(edge->config).mode
  1069. == EdgeMode::Falling,
  1070. "edge and rung comment updates must remain in the model");
  1071. }
  1072. void testHistoryAndAtomicBatchDelete()
  1073. {
  1074. TestProjectStorage storage;
  1075. ProjectService project_service(storage);
  1076. LogicEditorService service(project_service);
  1077. const std::string logic_id = service.ensureDefaultLogic().id;
  1078. const std::string first_rung_id = makeEmptyRung(service, logic_id);
  1079. const LogicEditorResult first = service.appendCondition(
  1080. logic_id, first_rung_id, contact(0));
  1081. const LogicEditorResult second = service.appendCondition(
  1082. logic_id, first_rung_id, contact(1));
  1083. const LogicEditorResult second_rung = service.addRung(logic_id);
  1084. const LogicEditorResult third = service.appendCondition(
  1085. logic_id, second_rung.id, contact(2));
  1086. require(first.succeeded && second.succeeded && second_rung.succeeded
  1087. && third.succeeded,
  1088. "nodes for history testing must be created");
  1089. service.clearHistory();
  1090. require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
  1091. "batch node deletion must validate every id before changing the logic");
  1092. require(service.findNode(logic_id, first.id) != nullptr
  1093. && service.findNode(logic_id, third.id) != nullptr
  1094. && !service.canUndo(),
  1095. "failed batch node deletion must be atomic and leave history unchanged");
  1096. require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
  1097. "valid nodes across multiple rungs must be deleted together");
  1098. require(service.findNode(logic_id, first.id) == nullptr
  1099. && service.findNode(logic_id, third.id) == nullptr,
  1100. "all selected nodes must be removed by one batch operation");
  1101. require(service.undo().succeeded
  1102. && service.findNode(logic_id, first.id) != nullptr
  1103. && service.findNode(logic_id, third.id) != nullptr,
  1104. "logic undo must restore a cross-rung batch deletion");
  1105. require(service.redo().succeeded
  1106. && service.findNode(logic_id, first.id) == nullptr
  1107. && service.findNode(logic_id, third.id) == nullptr,
  1108. "logic redo must reapply a cross-rung batch deletion");
  1109. service.clearHistory();
  1110. const ControlLogic *logic = service.findLogic(logic_id);
  1111. require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
  1112. && !service.canUndo(),
  1113. "setting an unchanged logic state must not consume history");
  1114. service.clearHistory();
  1115. for (int index = 1; index <= 101; ++index)
  1116. {
  1117. require(service.updateRungComment(
  1118. logic_id, first_rung_id, "comment-" + std::to_string(index))
  1119. .succeeded,
  1120. "repeated valid rung edits must succeed");
  1121. }
  1122. int undo_count = 0;
  1123. while (service.undo().succeeded)
  1124. {
  1125. ++undo_count;
  1126. }
  1127. require(undo_count == static_cast<int>(EditorHistory<int>::kMaximumEntries),
  1128. "logic history must retain exactly the configured most recent steps");
  1129. require(service.redo().succeeded,
  1130. "logic redo must be available after an undo");
  1131. require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
  1132. "a new logic edit must succeed after undo");
  1133. require(!service.canRedo(),
  1134. "a new logic edit must clear the redo history");
  1135. (void)second;
  1136. }
  1137. void testBatchPasteNodesAndRung()
  1138. {
  1139. TestProjectStorage storage;
  1140. ProjectService project_service(storage);
  1141. LogicEditorService service(project_service);
  1142. const std::string logic_id = service.ensureDefaultLogic().id;
  1143. const std::string rung_id = makeEmptyRung(service, logic_id);
  1144. const LogicEditorResult first = service.appendCondition(
  1145. logic_id, rung_id, contact(10));
  1146. const LogicEditorResult second = service.appendCondition(
  1147. logic_id, rung_id, contact(11));
  1148. require(first.succeeded && second.succeeded,
  1149. "nodes for paste testing must be created");
  1150. const LogicNode first_copy = *service.findNode(logic_id, first.id);
  1151. const LogicNode second_copy = *service.findNode(logic_id, second.id);
  1152. service.clearHistory();
  1153. const LogicEditorResult pasted = service.pasteConditionNodes(
  1154. logic_id, rung_id, {first_copy, second_copy});
  1155. require(pasted.succeeded
  1156. && service.findNode(logic_id, pasted.id) != nullptr
  1157. && service.findRung(logic_id, rung_id)->condition.has_value()
  1158. && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U,
  1159. "batch condition paste must append fresh nodes");
  1160. require(pasted.id != first.id && pasted.id != second.id
  1161. && service.undo().succeeded
  1162. && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U,
  1163. "batch condition paste must use fresh ids and one undo step");
  1164. require(service.areConditionNodesContiguous(
  1165. logic_id, rung_id, {first.id, second.id}),
  1166. "adjacent nodes in one series must be copyable as a range");
  1167. const std::string wire_rung_id = makeEmptyRung(service, logic_id);
  1168. const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3);
  1169. service.clearHistory();
  1170. LogicConditionPasteTarget wire_target;
  1171. wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
  1172. wire_target.expressionId = wire.id;
  1173. wire_target.column = 1;
  1174. const LogicEditorResult pasted_on_wire = service.pasteConditionNodes(
  1175. logic_id, wire_rung_id, {first_copy, second_copy}, wire_target);
  1176. const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id);
  1177. require(pasted_on_wire.succeeded && wire_rung != nullptr
  1178. && wire_rung->condition.has_value()
  1179. && conditionNodes(*wire_rung->condition) == 2
  1180. && conditionColumns(*wire_rung->condition) == 3,
  1181. "condition paste must replace the selected wire cell and consume following wire cells");
  1182. require(service.undo().succeeded
  1183. && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3,
  1184. "wire-targeted paste must be undone as one edit");
  1185. const std::string full_rung_id = makeEmptyRung(service, logic_id);
  1186. for (int address = 0; address < 9; ++address)
  1187. {
  1188. require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded,
  1189. "nine conditions must fit before an atomic paste failure test");
  1190. }
  1191. service.clearHistory();
  1192. require(!service.pasteConditionNodes(
  1193. logic_id, full_rung_id, {first_copy, second_copy}).succeeded
  1194. && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9
  1195. && !service.canUndo(),
  1196. "a multi-node paste that exceeds ten columns must roll back completely");
  1197. const LogicEditorResult third = service.appendCondition(
  1198. logic_id, rung_id, contact(12));
  1199. require(third.succeeded
  1200. && !service.areConditionNodesContiguous(
  1201. logic_id, rung_id, {first.id, third.id}),
  1202. "non-adjacent nodes must not be copied as one condition range");
  1203. const LogicEditorResult output = service.setOutput(
  1204. logic_id,
  1205. rung_id,
  1206. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set},
  1207. true);
  1208. require(output.succeeded, "the source rung must accept an output before copying");
  1209. const LadderRung source = *service.findRung(logic_id, rung_id);
  1210. service.clearHistory();
  1211. const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size();
  1212. const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source);
  1213. require(pasted_rung.succeeded
  1214. && service.findLogic(logic_id)->rungs.size()
  1215. == rung_count_before_paste + 1U
  1216. && pasted_rung.id != source.id,
  1217. "whole rung paste must append a new network");
  1218. const LadderRung *copy = service.findRung(logic_id, pasted_rung.id);
  1219. require(copy != nullptr && copy->condition.has_value()
  1220. && copy->condition->id != source.condition->id
  1221. && copy->output.has_value() && source.output.has_value()
  1222. && copy->output->id != source.output->id
  1223. && std::get<CoilNodeConfig>(copy->output->config).address.index() == 20
  1224. && std::get<CoilNodeConfig>(copy->output->config).mode == CoilMode::Set,
  1225. "whole rung paste must regenerate ids and preserve output configuration");
  1226. }
  1227. void testConditionPasteTargetsAndValidation()
  1228. {
  1229. TestProjectStorage storage;
  1230. ProjectService project_service(storage);
  1231. LogicEditorService service(project_service);
  1232. const std::string logic_id = service.ensureDefaultLogic().id;
  1233. LogicNode source;
  1234. source.id = "copied-condition";
  1235. source.config = contact(90);
  1236. source.configured = true;
  1237. const std::string empty_column_rung = makeEmptyRung(service, logic_id);
  1238. LogicConditionPasteTarget empty_column;
  1239. empty_column.kind = LogicConditionPasteTargetKind::EmptyColumn;
  1240. empty_column.column = 2;
  1241. require(service.pasteConditionNodes(
  1242. logic_id, empty_column_rung, {source}, empty_column).succeeded,
  1243. "condition paste must support an empty grid column");
  1244. require(conditionColumns(*service.findRung(
  1245. logic_id, empty_column_rung)->condition) == 3,
  1246. "empty-column paste must preserve the requested column offset");
  1247. const std::string branch_rung = makeEmptyRung(service, logic_id);
  1248. const LogicEditorResult branch_source = service.appendCondition(
  1249. logic_id, branch_rung, contact(1));
  1250. const LogicEditorResult branch_source_two = service.appendCondition(
  1251. logic_id, branch_rung, contact(2));
  1252. const LogicEditorResult branch_source_three = service.appendCondition(
  1253. logic_id, branch_rung, contact(3));
  1254. require(branch_source.succeeded && branch_source_two.succeeded
  1255. && branch_source_three.succeeded,
  1256. "branch paste setup must add a source range");
  1257. const LogicEditorResult branch = service.addParallelBranch(
  1258. logic_id, branch_rung,
  1259. {branch_source.id, branch_source_two.id, branch_source_three.id},
  1260. contact(4));
  1261. require(branch.succeeded, "branch paste setup must create a parallel branch");
  1262. const ConditionExpression &paste_lower = service.findRung(
  1263. logic_id, branch_rung)->condition->children.at(1);
  1264. require(paste_lower.kind == ConditionExpressionKind::Series
  1265. && paste_lower.children.back().kind == ConditionExpressionKind::Wire,
  1266. "branch paste setup must persist the short branch padding wire");
  1267. LogicConditionPasteTarget branch_target;
  1268. branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
  1269. branch_target.expressionId = paste_lower.children.back().id;
  1270. branch_target.column = 1;
  1271. require(service.pasteConditionNodes(
  1272. logic_id, branch_rung, {source}, branch_target).succeeded,
  1273. "condition paste must support a persisted wire cell in a branch");
  1274. const std::string after_node_rung = makeEmptyRung(service, logic_id);
  1275. const LogicEditorResult after_source = service.appendCondition(
  1276. logic_id, after_node_rung, contact(3));
  1277. require(after_source.succeeded, "after-node paste setup must add a source node");
  1278. LogicConditionPasteTarget after_target;
  1279. after_target.kind = LogicConditionPasteTargetKind::AfterNode;
  1280. after_target.expressionId = after_source.id;
  1281. require(service.pasteConditionNodes(
  1282. logic_id, after_node_rung, {source}, after_target).succeeded,
  1283. "condition paste must support insertion after a selected node");
  1284. require(conditionNodes(*service.findRung(
  1285. logic_id, after_node_rung)->condition) == 2,
  1286. "after-node paste must add exactly one condition");
  1287. const std::string replace_wire_rung = makeEmptyRung(service, logic_id);
  1288. const LogicEditorResult wire = service.appendWire(
  1289. logic_id, replace_wire_rung, 2);
  1290. require(wire.succeeded, "replace-wire paste setup must add a wire");
  1291. LogicConditionPasteTarget replace_wire;
  1292. replace_wire.kind = LogicConditionPasteTargetKind::ReplaceWire;
  1293. replace_wire.expressionId = wire.id;
  1294. require(service.pasteConditionNodes(
  1295. logic_id, replace_wire_rung, {source}, replace_wire).succeeded,
  1296. "condition paste must replace an entire wire expression");
  1297. require(conditionNodes(*service.findRung(
  1298. logic_id, replace_wire_rung)->condition) == 1,
  1299. "whole-wire paste must create one condition node");
  1300. const std::string replace_cell_rung = makeEmptyRung(service, logic_id);
  1301. const LogicEditorResult cell_wire = service.appendWire(
  1302. logic_id, replace_cell_rung, 3);
  1303. require(cell_wire.succeeded, "replace-cell paste setup must add a wire");
  1304. LogicConditionPasteTarget replace_cell;
  1305. replace_cell.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
  1306. replace_cell.expressionId = cell_wire.id;
  1307. replace_cell.column = 1;
  1308. require(service.pasteConditionNodes(
  1309. logic_id, replace_cell_rung, {source}, replace_cell).succeeded,
  1310. "condition paste must replace one selected wire cell");
  1311. require(conditionColumns(*service.findRung(
  1312. logic_id, replace_cell_rung)->condition) == 3,
  1313. "wire-cell paste must preserve the original network width");
  1314. require(!service.pasteConditionNodes(logic_id, after_node_rung, {}).succeeded,
  1315. "an empty condition clipboard must be rejected");
  1316. LogicNode output = source;
  1317. output.config = CoilNodeConfig{RegisterAddress{RegisterArea::M, 91}, CoilMode::Normal};
  1318. require(!service.pasteConditionNodes(
  1319. logic_id, after_node_rung, {output}).succeeded,
  1320. "an output instruction must not be pasted into the condition area");
  1321. require(!service.pasteConditionNodes(
  1322. "missing-logic", after_node_rung, {source}).succeeded,
  1323. "condition paste must reject an unknown logic");
  1324. require(!service.pasteConditionNodes(
  1325. logic_id, "missing-rung", {source}).succeeded,
  1326. "condition paste must reject an unknown rung");
  1327. const LadderRung *before_invalid = service.findRung(
  1328. logic_id, after_node_rung);
  1329. require(before_invalid != nullptr && before_invalid->condition.has_value(),
  1330. "invalid paste setup must retain its target network");
  1331. const int node_count_before_invalid = conditionNodes(*before_invalid->condition);
  1332. LogicConditionPasteTarget invalid_target;
  1333. invalid_target.kind = LogicConditionPasteTargetKind::AfterNode;
  1334. invalid_target.expressionId = "missing-expression";
  1335. service.clearHistory();
  1336. require(!service.pasteConditionNodes(
  1337. logic_id, after_node_rung, {source}, invalid_target).succeeded
  1338. && conditionNodes(*service.findRung(
  1339. logic_id, after_node_rung)->condition) == node_count_before_invalid
  1340. && !service.canUndo(),
  1341. "an invalid paste target must roll back without recording history");
  1342. }
  1343. void testConfiguredLogicLimits()
  1344. {
  1345. TestProjectStorage storage;
  1346. ProjectLimitSettings limits;
  1347. limits.maximumControlLogics = 1U;
  1348. limits.maximumRungsPerLogic = 0U;
  1349. ProjectService project_service(storage, limits);
  1350. LogicEditorService service(project_service);
  1351. const LogicEditorResult initial = service.ensureDefaultLogic();
  1352. require(initial.succeeded,
  1353. "the configured-limit fixture must create its one default logic");
  1354. require(!service.addLogic("Second logic").succeeded,
  1355. "the logic editor must use the configured logic-group limit");
  1356. require(!service.addRung(initial.id).succeeded,
  1357. "the logic editor must use the configured per-logic rung limit");
  1358. }
  1359. } // namespace
  1360. int main()
  1361. {
  1362. try
  1363. {
  1364. testEmptyLogicCreatesNetworksOnFirstEdit();
  1365. testAppendingWireMovesToNextNetworkAfterTenColumns();
  1366. testStructuredEditingAndNormalization();
  1367. testRangeParallelInsertion();
  1368. testParallelBranchGridInsertion();
  1369. testStructuredWireEditing();
  1370. testWireCellParallelSelectionUsesExactColumns();
  1371. testWireCellParallelSelectionAcrossAdjacentWires();
  1372. testBatchDeleteAllNodesInParallelBranch();
  1373. testConditionColumnLimit();
  1374. testUnconditionalOutputEditing();
  1375. testExplicitGapEditing();
  1376. testColumnTargetedConditionInsertion();
  1377. testWireColumnReplacement();
  1378. testSequentialConditionInsertionConsumesFollowingWire();
  1379. testLogicLifecycleAndOrdering();
  1380. testEdgeNodesAndRungComments();
  1381. testHistoryAndAtomicBatchDelete();
  1382. testBatchPasteNodesAndRung();
  1383. testConditionPasteTargetsAndValidation();
  1384. testConfiguredLogicLimits();
  1385. }
  1386. catch (const std::exception &error)
  1387. {
  1388. std::cerr << "logic editor service tests failed: " << error.what() << '\n';
  1389. return 1;
  1390. }
  1391. std::cout << "logic editor service tests passed\n";
  1392. return 0;
  1393. }