综合平台编程器项目的远程存储
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

1530 linhas
64 KiB

  1. #include "domain/project_limits.h"
  2. #include "services/logic_command_service.h"
  3. #include "services/logic_editor_service.h"
  4. #include "services/project_service.h"
  5. #include "support/test_support.h"
  6. #include <algorithm>
  7. #include <iostream>
  8. #include <stdexcept>
  9. #include <string>
  10. #include <vector>
  11. namespace {
  12. using TestSupport::require;
  13. ContactNodeConfig contact(
  14. int address, ContactMode mode = ContactMode::NormallyOpen)
  15. {
  16. return {RegisterAddress{RegisterArea::M, address}, mode};
  17. }
  18. LogicEditorResult setConditionAtColumn(
  19. LogicEditorService &editor,
  20. const std::string &logic_id,
  21. const std::string &rung_id,
  22. int column,
  23. const LogicNodeConfig &config,
  24. bool configured)
  25. {
  26. return editor.applyConditionAndAdvance(
  27. logic_id, {rung_id, column, false}, config, configured).edit;
  28. }
  29. struct Fixture
  30. {
  31. TestSupport::InMemoryProjectStorage storage;
  32. ProjectService projects{storage, defaultProjectLimitSettings()};
  33. LogicEditorService editor{projects};
  34. std::string logicId;
  35. Fixture()
  36. {
  37. const LogicEditorResult result = editor.ensureDefaultLogic();
  38. require(result.succeeded, "fixture must create the default logic");
  39. logicId = result.id;
  40. }
  41. std::string addRung()
  42. {
  43. const LogicEditorResult result = editor.addRung(logicId);
  44. require(result.succeeded, "fixture must create a ladder row");
  45. return result.id;
  46. }
  47. };
  48. const VerticalConnection *connectionAt(
  49. const ControlLogic &logic,
  50. const std::string &upper,
  51. const std::string &lower,
  52. int boundary)
  53. {
  54. const auto found = std::find_if(
  55. logic.verticalConnections.cbegin(),
  56. logic.verticalConnections.cend(),
  57. [&upper, &lower, boundary](const VerticalConnection &connection)
  58. {
  59. return connection.upperRungId == upper
  60. && connection.lowerRungId == lower
  61. && connection.columnBoundary == boundary;
  62. });
  63. return found == logic.verticalConnections.cend() ? nullptr : &*found;
  64. }
  65. void requireEmptyGrid(const LadderRung &rung)
  66. {
  67. require(
  68. rung.cells.size()
  69. == static_cast<std::size_t>(
  70. ProjectLimits::kMaximumConditionColumns),
  71. "every visual row must have exactly ten persisted cells");
  72. for (const LadderCell &cell : rung.cells)
  73. {
  74. require(
  75. !cell.id.empty() && cell.kind == LadderCellKind::Gap
  76. && !cell.node.has_value(),
  77. "a new row must contain stable empty cells");
  78. }
  79. }
  80. void testContinuousGridAndIndependentHorizontalWires()
  81. {
  82. Fixture fixture;
  83. const std::string rung_id = fixture.addRung();
  84. requireEmptyGrid(*fixture.editor.findRung(fixture.logicId, rung_id));
  85. require(
  86. fixture.editor.setHorizontalWireRange(
  87. fixture.logicId, rung_id, 2, 5, true).succeeded,
  88. "drawing a horizontal range must succeed");
  89. const LadderRung *rung = fixture.editor.findRung(
  90. fixture.logicId, rung_id);
  91. for (int column = 0;
  92. column < ProjectLimits::kMaximumConditionColumns;
  93. ++column)
  94. {
  95. const LadderCellKind expected = column >= 2 && column <= 5
  96. ? LadderCellKind::Wire : LadderCellKind::Gap;
  97. require(
  98. rung->cells[static_cast<std::size_t>(column)].kind == expected,
  99. "horizontal wires must be persisted independently per cell");
  100. }
  101. fixture.editor.clearHistory();
  102. fixture.projects.restoreModifiedState(false);
  103. const LogicEditorResult duplicate = fixture.editor.setHorizontalWireRange(
  104. fixture.logicId, rung_id, 2, 5, true);
  105. require(
  106. duplicate.succeeded
  107. && !fixture.projects.isModified()
  108. && !fixture.editor.canUndo(),
  109. "repeating an existing horizontal wire range must not dirty the project or history");
  110. const LogicEditorResult node = setConditionAtColumn(fixture.editor,
  111. fixture.logicId, rung_id, 3, contact(3), true);
  112. require(node.succeeded, "a wire cell must accept a condition node");
  113. require(
  114. fixture.editor.setHorizontalWireRange(
  115. fixture.logicId, rung_id, 2, 5, false).succeeded,
  116. "erasing a horizontal range must succeed");
  117. rung = fixture.editor.findRung(fixture.logicId, rung_id);
  118. require(
  119. rung->cells[3].kind == LadderCellKind::Node
  120. && rung->cells[3].node->id == node.id,
  121. "line erasing must not delete a condition node");
  122. require(
  123. rung->cells[2].kind == LadderCellKind::Gap
  124. && rung->cells[4].kind == LadderCellKind::Gap
  125. && rung->cells[5].kind == LadderCellKind::Gap,
  126. "line erasing must clear each selected wire cell");
  127. }
  128. void testIndependentVerticalConnectionsAndNetworkSplit()
  129. {
  130. Fixture fixture;
  131. const std::string first = fixture.addRung();
  132. const std::string second = fixture.addRung();
  133. const std::string third = fixture.addRung();
  134. require(
  135. fixture.editor.setVerticalConnectionRange(
  136. fixture.logicId, first, third, 4, true).succeeded,
  137. "a vertical gesture must create all adjacent segments");
  138. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  139. require(
  140. logic->verticalConnections.size() == 2U
  141. && connectionAt(*logic, first, second, 4) != nullptr
  142. && connectionAt(*logic, second, third, 4) != nullptr,
  143. "a long vertical line must be represented by independent segments");
  144. fixture.editor.clearHistory();
  145. fixture.projects.restoreModifiedState(false);
  146. const LogicVerticalEditResult duplicate =
  147. fixture.editor.applyVerticalConnectionAndAdvance(
  148. fixture.logicId, first, 4);
  149. require(
  150. duplicate.edit.succeeded
  151. && !duplicate.changed
  152. && duplicate.nextRungId == second
  153. && duplicate.columnBoundary == 4
  154. && fixture.editor.findLogic(fixture.logicId)
  155. ->verticalConnections.size() == 2U
  156. && !fixture.projects.isModified()
  157. && !fixture.editor.canUndo(),
  158. "an existing vertical edge must advance without dirty state or empty history");
  159. const std::string first_segment =
  160. connectionAt(*logic, first, second, 4)->id;
  161. require(
  162. fixture.editor.removeVerticalConnections(
  163. fixture.logicId, {first_segment}).succeeded,
  164. "an individual vertical segment must be removable");
  165. logic = fixture.editor.findLogic(fixture.logicId);
  166. require(
  167. connectionAt(*logic, first, second, 4) == nullptr
  168. && connectionAt(*logic, second, third, 4) != nullptr,
  169. "deleting one segment must split the connected network");
  170. require(
  171. !fixture.editor.setVerticalConnection(
  172. fixture.logicId, first, third, 4, true).succeeded,
  173. "the model must reject vertical edges between non-adjacent rows");
  174. }
  175. void testNetworkCommentsFollowNetworkHeadsAndMergeAtomically()
  176. {
  177. Fixture fixture;
  178. const std::string first = fixture.addRung();
  179. const std::string second = fixture.addRung();
  180. const std::string third = fixture.addRung();
  181. require(
  182. fixture.editor.updateNetworkComment(
  183. fixture.logicId, second, "下方网络注释").succeeded,
  184. "an independent lower network must accept its own comment");
  185. fixture.editor.clearHistory();
  186. const LogicEditorResult merged = fixture.editor.setVerticalConnection(
  187. fixture.logicId, first, second, 4, true);
  188. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  189. require(
  190. merged.succeeded && logic->rungs[0].comment == "下方网络注释"
  191. && logic->rungs[1].comment.empty()
  192. && fixture.editor.findNetworkHeadRung(fixture.logicId, second)->id
  193. == first,
  194. "merging a commented lower network into an empty upper network must move the comment to the new head");
  195. require(
  196. fixture.editor.undo().succeeded
  197. && fixture.editor.findRung(fixture.logicId, first)->comment.empty()
  198. && fixture.editor.findRung(fixture.logicId, second)->comment
  199. == "下方网络注释"
  200. && fixture.editor.redo().succeeded,
  201. "network merge comment movement must belong to the same undo transaction");
  202. fixture.editor.clearHistory();
  203. const LogicEditorResult edited_from_branch =
  204. fixture.editor.updateNetworkComment(
  205. fixture.logicId, second, "从支路编辑后的注释");
  206. require(
  207. edited_from_branch.succeeded && edited_from_branch.id == first
  208. && fixture.editor.findRung(fixture.logicId, first)->comment
  209. == "从支路编辑后的注释"
  210. && fixture.editor.findRung(fixture.logicId, second)->comment.empty(),
  211. "editing from a branch row must update only the network head comment");
  212. require(
  213. fixture.editor.updateNetworkComment(
  214. fixture.logicId, third, "另一个网络注释").succeeded,
  215. "the third independent network must accept a different comment");
  216. fixture.editor.clearHistory();
  217. fixture.projects.restoreModifiedState(false);
  218. const LogicEditorResult conflict = fixture.editor.setVerticalConnection(
  219. fixture.logicId, second, third, 4, true);
  220. logic = fixture.editor.findLogic(fixture.logicId);
  221. require(
  222. !conflict.succeeded
  223. && conflict.message.find("存在不同注释") != std::string::npos
  224. && connectionAt(*logic, second, third, 4) == nullptr
  225. && logic->rungs[0].comment == "从支路编辑后的注释"
  226. && logic->rungs[2].comment == "另一个网络注释"
  227. && !fixture.projects.isModified() && !fixture.editor.canUndo(),
  228. "merging differently commented networks must fail without partial topology or history");
  229. require(
  230. fixture.editor.updateNetworkComment(
  231. fixture.logicId, third, "从支路编辑后的注释").succeeded,
  232. "the lower network comment must be editable before a retry");
  233. fixture.editor.clearHistory();
  234. require(
  235. fixture.editor.setVerticalConnection(
  236. fixture.logicId, second, third, 4, true).succeeded
  237. && fixture.editor.findRung(fixture.logicId, third)->comment.empty(),
  238. "merging equal network comments must keep one comment at the head");
  239. const std::string connection_id = connectionAt(
  240. *fixture.editor.findLogic(fixture.logicId), second, third, 4)->id;
  241. require(
  242. fixture.editor.removeVerticalConnections(
  243. fixture.logicId, {connection_id}).succeeded
  244. && fixture.editor.findRung(fixture.logicId, first)->comment
  245. == "从支路编辑后的注释"
  246. && fixture.editor.findRung(fixture.logicId, third)->comment.empty(),
  247. "splitting a network must not duplicate its head comment");
  248. }
  249. void testInsertRowSplitsVerticalEdges()
  250. {
  251. Fixture fixture;
  252. const std::string upper = fixture.addRung();
  253. const std::string lower = fixture.addRung();
  254. require(
  255. fixture.editor.setVerticalConnection(
  256. fixture.logicId, upper, lower, 2, true).succeeded,
  257. "fixture must connect the original adjacent rows");
  258. require(
  259. fixture.editor.setVerticalConnection(
  260. fixture.logicId, upper, lower, 7, true).succeeded,
  261. "fixture must support more than one boundary between two rows");
  262. const LogicEditorResult inserted = fixture.editor.insertRung(
  263. fixture.logicId, upper, true);
  264. require(inserted.succeeded, "inserting a row must succeed");
  265. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  266. require(logic->rungs.size() == 3U, "the row must be inserted in place");
  267. requireEmptyGrid(*fixture.editor.findRung(fixture.logicId, inserted.id));
  268. for (int boundary : {2, 7})
  269. {
  270. require(
  271. connectionAt(*logic, upper, inserted.id, boundary) != nullptr
  272. && connectionAt(*logic, inserted.id, lower, boundary)
  273. != nullptr
  274. && connectionAt(*logic, upper, lower, boundary) == nullptr,
  275. "insertion must split every crossed vertical edge");
  276. }
  277. }
  278. void testDeleteRowMergesOnlyContinuousEdges()
  279. {
  280. Fixture fixture;
  281. const std::string upper = fixture.addRung();
  282. const std::string middle = fixture.addRung();
  283. const std::string lower = fixture.addRung();
  284. require(
  285. fixture.editor.setVerticalConnection(
  286. fixture.logicId, upper, middle, 1, true).succeeded,
  287. "fixture must add the upper half of a continuous edge");
  288. require(
  289. fixture.editor.setVerticalConnection(
  290. fixture.logicId, middle, lower, 1, true).succeeded,
  291. "fixture must add the lower half of a continuous edge");
  292. require(
  293. fixture.editor.setVerticalConnection(
  294. fixture.logicId, upper, middle, 6, true).succeeded,
  295. "fixture must add an upper-only edge");
  296. require(
  297. fixture.editor.setVerticalConnection(
  298. fixture.logicId, middle, lower, 8, true).succeeded,
  299. "fixture must add a lower-only edge");
  300. require(
  301. fixture.editor.removeRung(fixture.logicId, middle).succeeded,
  302. "deleting the middle row must succeed");
  303. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  304. require(logic->rungs.size() == 2U, "the middle row must be removed");
  305. require(
  306. connectionAt(*logic, upper, lower, 1) != nullptr,
  307. "matching upper and lower segments must merge after row deletion");
  308. require(
  309. connectionAt(*logic, upper, lower, 6) == nullptr
  310. && connectionAt(*logic, upper, lower, 8) == nullptr,
  311. "a one-sided edge must disappear instead of creating a false bridge");
  312. require(
  313. logic->rungs[0].name == "行 1"
  314. && logic->rungs[1].name == "行 2",
  315. "row labels must be renumbered after deletion");
  316. }
  317. void testParallelBranchCreatesConnectedVisualRow()
  318. {
  319. Fixture fixture;
  320. const std::string source = fixture.addRung();
  321. const LogicEditorResult first = setConditionAtColumn(fixture.editor,
  322. fixture.logicId, source, 2, contact(1), true);
  323. const LogicEditorResult second = setConditionAtColumn(fixture.editor,
  324. fixture.logicId, source, 3, contact(2), true);
  325. require(first.succeeded && second.succeeded,
  326. "fixture must place adjacent conditions");
  327. const LogicEditorResult branch = fixture.editor.addParallelBranch(
  328. fixture.logicId,
  329. source,
  330. {first.id, second.id},
  331. contact(3),
  332. true);
  333. require(branch.succeeded, "parallel insertion must create a visual row");
  334. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  335. require(logic->rungs.size() == 2U,
  336. "parallel insertion must add one row to the continuous grid");
  337. const LadderRung &lower = logic->rungs[1];
  338. require(
  339. lower.cells[2].kind == LadderCellKind::Node
  340. && lower.cells[2].node->id == branch.id
  341. && lower.cells[3].kind == LadderCellKind::Wire,
  342. "the branch row must align the new condition with its selected span");
  343. require(
  344. connectionAt(*logic, source, lower.id, 2) != nullptr
  345. && connectionAt(*logic, source, lower.id, 4) != nullptr,
  346. "a parallel branch must be bounded by independent left and right edges");
  347. }
  348. void testParallelBranchReusesExistingEdges()
  349. {
  350. Fixture fixture;
  351. const std::string source = fixture.addRung();
  352. const std::string following = fixture.addRung();
  353. const LogicEditorResult first = setConditionAtColumn(fixture.editor,
  354. fixture.logicId, source, 2, contact(4), true);
  355. const LogicEditorResult second = setConditionAtColumn(fixture.editor,
  356. fixture.logicId, source, 3, contact(5), true);
  357. require(first.succeeded && second.succeeded,
  358. "fixture must place the branch source conditions");
  359. require(
  360. fixture.editor.setVerticalConnection(
  361. fixture.logicId, source, following, 2, true).succeeded
  362. && fixture.editor.setVerticalConnection(
  363. fixture.logicId, source, following, 4, true).succeeded,
  364. "fixture must create edges at the future branch boundaries");
  365. const LogicEditorResult branch = fixture.editor.addParallelBranch(
  366. fixture.logicId, source, {first.id, second.id}, contact(6), true);
  367. require(branch.succeeded,
  368. "parallel insertion must reuse matching split edges");
  369. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  370. require(logic->rungs.size() == 3U,
  371. "parallel insertion must add exactly one row");
  372. const std::string branch_rung = logic->rungs[1].id;
  373. for (int boundary : {2, 4})
  374. {
  375. require(
  376. connectionAt(*logic, source, branch_rung, boundary) != nullptr
  377. && connectionAt(*logic, branch_rung, following, boundary)
  378. != nullptr,
  379. "existing topology and new branch must share one edge per boundary");
  380. }
  381. }
  382. void testNodeDeletionAndHistoryAreAtomic()
  383. {
  384. Fixture fixture;
  385. const std::string rung_id = fixture.addRung();
  386. const LogicEditorResult node = setConditionAtColumn(fixture.editor,
  387. fixture.logicId, rung_id, 0, contact(9), true);
  388. require(node.succeeded, "fixture must place a condition");
  389. fixture.editor.clearHistory();
  390. require(
  391. fixture.editor.setHorizontalWireRange(
  392. fixture.logicId, rung_id, 1, 4, true).succeeded,
  393. "one horizontal drag must be one edit");
  394. require(fixture.editor.canUndo(), "the drag must enter undo history");
  395. require(fixture.editor.undo().succeeded, "the drag must undo atomically");
  396. const LadderRung *rung = fixture.editor.findRung(
  397. fixture.logicId, rung_id);
  398. for (int column = 1; column <= 4; ++column)
  399. {
  400. require(
  401. rung->cells[static_cast<std::size_t>(column)].kind
  402. == LadderCellKind::Gap,
  403. "undo must restore every cell changed by the drag");
  404. }
  405. require(fixture.editor.redo().succeeded, "the drag must redo atomically");
  406. require(
  407. fixture.editor.removeNodes(fixture.logicId, {node.id}).succeeded,
  408. "deleting a condition node must succeed");
  409. rung = fixture.editor.findRung(fixture.logicId, rung_id);
  410. require(
  411. rung->cells[0].kind == LadderCellKind::Gap
  412. && !rung->cells[0].node.has_value(),
  413. "a deleted node must leave an editable gap cell");
  414. const std::size_t rows_before = fixture.editor.findLogic(
  415. fixture.logicId)->rungs.size();
  416. require(
  417. !fixture.editor.removeRungs(
  418. fixture.logicId, {rung_id, "missing-rung"}).succeeded,
  419. "a batch containing an invalid row must fail");
  420. require(
  421. fixture.editor.findLogic(fixture.logicId)->rungs.size()
  422. == rows_before,
  423. "a failed batch edit must not partially mutate the graph");
  424. }
  425. void testSelectionDeletionIsAtomic()
  426. {
  427. Fixture fixture;
  428. const std::string upper = fixture.addRung();
  429. const std::string lower = fixture.addRung();
  430. require(
  431. setConditionAtColumn(fixture.editor,
  432. fixture.logicId, upper, 0, contact(30), true).succeeded
  433. && fixture.editor.setHorizontalWireRange(
  434. fixture.logicId, upper, 1, 1, true).succeeded
  435. && fixture.editor.setOutput(
  436. fixture.logicId,
  437. upper,
  438. CoilNodeConfig{
  439. RegisterAddress{RegisterArea::M, 31}, CoilMode::Normal},
  440. true).succeeded
  441. && fixture.editor.setVerticalConnection(
  442. fixture.logicId, upper, lower, 2, true).succeeded,
  443. "fixture must create every selectable object type");
  444. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  445. require(logic != nullptr && logic->verticalConnections.size() == 1U,
  446. "fixture must expose the vertical connection id");
  447. const std::string connection_id = logic->verticalConnections.front().id;
  448. fixture.editor.clearHistory();
  449. LogicSelectionDeleteRequest selection;
  450. selection.cells = {{upper, 0}, {upper, 1}};
  451. selection.outputRungIds = {upper};
  452. selection.verticalConnectionIds = {connection_id};
  453. require(
  454. fixture.editor.deleteSelection(fixture.logicId, selection).succeeded,
  455. "one selection delete must remove mixed ladder objects");
  456. const LadderRung *rung = fixture.editor.findRung(fixture.logicId, upper);
  457. logic = fixture.editor.findLogic(fixture.logicId);
  458. require(
  459. rung != nullptr
  460. && rung->cells[0].kind == LadderCellKind::Gap
  461. && rung->cells[1].kind == LadderCellKind::Gap
  462. && !rung->output.has_value()
  463. && logic->verticalConnections.empty(),
  464. "selection delete must clear every requested object together");
  465. require(
  466. fixture.editor.undo().succeeded,
  467. "mixed selection deletion must create one undo entry");
  468. rung = fixture.editor.findRung(fixture.logicId, upper);
  469. logic = fixture.editor.findLogic(fixture.logicId);
  470. require(
  471. rung->cells[0].kind == LadderCellKind::Node
  472. && rung->cells[1].kind == LadderCellKind::Wire
  473. && rung->output.has_value()
  474. && logic->verticalConnections.size() == 1U,
  475. "one undo must restore the complete mixed selection");
  476. require(
  477. fixture.editor.redo().succeeded,
  478. "mixed selection deletion must redo as one entry");
  479. rung = fixture.editor.findRung(fixture.logicId, upper);
  480. require(
  481. rung->cells[0].kind == LadderCellKind::Gap
  482. && rung->cells[1].kind == LadderCellKind::Gap
  483. && !rung->output.has_value()
  484. && fixture.editor.findLogic(fixture.logicId)
  485. ->verticalConnections.empty(),
  486. "one redo must delete the complete mixed selection again");
  487. }
  488. void testInvalidSelectionDeletionDoesNotMutateOrRecordHistory()
  489. {
  490. Fixture fixture;
  491. const std::string rung_id = fixture.addRung();
  492. require(
  493. setConditionAtColumn(fixture.editor,
  494. fixture.logicId, rung_id, 0, contact(40), true).succeeded
  495. && fixture.editor.setHorizontalWireRange(
  496. fixture.logicId, rung_id, 1, 1, true).succeeded,
  497. "fixture must create valid objects before an invalid mixed delete");
  498. fixture.editor.clearHistory();
  499. LogicSelectionDeleteRequest selection;
  500. selection.cells = {{rung_id, 0}, {rung_id, 1}};
  501. selection.verticalConnectionIds = {"missing-vertical"};
  502. require(
  503. !fixture.editor.deleteSelection(fixture.logicId, selection).succeeded,
  504. "a mixed selection containing an invalid object must fail");
  505. const LadderRung *rung = fixture.editor.findRung(
  506. fixture.logicId, rung_id);
  507. require(
  508. rung->cells[0].kind == LadderCellKind::Node
  509. && rung->cells[1].kind == LadderCellKind::Wire,
  510. "an invalid mixed deletion must not partially clear valid cells");
  511. require(
  512. !fixture.editor.canUndo(),
  513. "an invalid mixed deletion must not create an undo entry");
  514. }
  515. void testCommandInputMapsToGridCoordinates()
  516. {
  517. Fixture fixture;
  518. LogicCommandService commands(fixture.editor);
  519. const std::string rung_id = fixture.addRung();
  520. require(
  521. LogicCommandService::parse("ldi m4000").succeeded,
  522. "command parsing must remain case-insensitive");
  523. require(
  524. !LogicCommandService::parse("LD D0").succeeded,
  525. "contact commands must reject D addresses");
  526. LogicCommandRequest request;
  527. request.logicId = fixture.logicId;
  528. request.text = "LD M12";
  529. request.target = {
  530. LogicCommandTargetKind::EmptyColumn,
  531. rung_id,
  532. {},
  533. 5};
  534. const LogicCommandResult loaded = commands.execute(request);
  535. require(loaded.succeeded
  536. && loaded.hasNextCursor
  537. && loaded.nextCursor.rungId == rung_id
  538. && loaded.nextCursor.column == 6
  539. && !loaded.nextCursor.output,
  540. "LD must be written to the selected grid cell and advance right");
  541. const LadderCell *cell = fixture.editor.findCell(
  542. fixture.logicId, rung_id, 5);
  543. require(
  544. cell != nullptr && cell->kind == LadderCellKind::Node
  545. && cell->node->id == loaded.id,
  546. "command input must preserve the target row and column");
  547. request.text = "LDI M13";
  548. request.target.kind = LogicCommandTargetKind::ExistingNode;
  549. request.target.expressionId = loaded.id;
  550. const LogicCommandResult replaced = commands.execute(request);
  551. cell = fixture.editor.findCell(fixture.logicId, rung_id, 5);
  552. require(
  553. replaced.succeeded && replaced.id == loaded.id
  554. && cell->node->id == loaded.id
  555. && std::get<ContactNodeConfig>(cell->node->config).mode
  556. == ContactMode::NormallyClosed,
  557. "editing an existing command must preserve its node identity");
  558. request.text = "OUT M20";
  559. request.target.kind = LogicCommandTargetKind::Output;
  560. const LogicCommandResult output = commands.execute(request);
  561. require(output.succeeded
  562. && output.hasNextCursor
  563. && output.nextCursor.column == 0
  564. && output.nextCursor.rungId != rung_id,
  565. "OUT must target the row output slot and advance to the next row");
  566. require(
  567. fixture.editor.findRung(fixture.logicId, rung_id)->output->id
  568. == output.id,
  569. "the command must create the configured output node");
  570. request.text = "AND M21";
  571. require(
  572. !commands.execute(request).succeeded,
  573. "condition commands must not be accepted in the output slot");
  574. }
  575. void testProjectRungLimitAppliesToBranchAndPaste()
  576. {
  577. Fixture fixture;
  578. const std::string source = fixture.addRung();
  579. const LogicEditorResult node = setConditionAtColumn(fixture.editor,
  580. fixture.logicId, source, 0, contact(20), true);
  581. require(node.succeeded, "fixture must create a branch source node");
  582. Project &project = fixture.projects.editProject();
  583. std::size_t remaining = ProjectLimits::kMaximumRungsPerProject - 1U;
  584. std::size_t logic_index = 2U;
  585. std::size_t rung_index = 1U;
  586. while (remaining > 0U)
  587. {
  588. ControlLogic extra;
  589. extra.id = "limit-logic-" + std::to_string(logic_index);
  590. extra.name = "Limit logic " + std::to_string(logic_index);
  591. const std::size_t count = std::min(
  592. remaining, ProjectLimits::kMaximumRungsPerLogic);
  593. for (std::size_t index = 0U; index < count; ++index, ++rung_index)
  594. {
  595. LadderRung rung;
  596. rung.id = "limit-rung-" + std::to_string(rung_index);
  597. rung.name = "Limit row " + std::to_string(rung_index);
  598. for (int column = 0;
  599. column < ProjectLimits::kMaximumConditionColumns;
  600. ++column)
  601. {
  602. rung.cells.push_back({
  603. rung.id + "-cell-" + std::to_string(column),
  604. LadderCellKind::Gap,
  605. std::nullopt});
  606. }
  607. extra.rungs.push_back(std::move(rung));
  608. }
  609. project.controlLogics.push_back(std::move(extra));
  610. remaining -= count;
  611. ++logic_index;
  612. }
  613. require(project.validate(defaultProjectLimitSettings()),
  614. "the project-wide rung limit fixture must itself be valid");
  615. require(
  616. !fixture.editor.addParallelBranch(
  617. fixture.logicId, source, {node.id}, contact(21), true).succeeded,
  618. "parallel insertion must respect the project-wide rung limit");
  619. require(
  620. fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U,
  621. "failed limit checks must not partially add a row");
  622. }
  623. void testConfiguredRowLimit()
  624. {
  625. ProjectLimitSettings limits = defaultProjectLimitSettings();
  626. limits.maximumRungsPerLogic = 1U;
  627. TestSupport::InMemoryProjectStorage storage;
  628. ProjectService projects(storage, limits);
  629. LogicEditorService editor(projects);
  630. const std::string logic_id = editor.ensureDefaultLogic().id;
  631. require(editor.addRung(logic_id).succeeded,
  632. "the configured row limit must permit its boundary value");
  633. require(!editor.addRung(logic_id).succeeded,
  634. "the configured row limit must reject one extra row");
  635. }
  636. void testFirstEditOnEmptyLogicIsAtomic()
  637. {
  638. Fixture fixture;
  639. fixture.editor.clearHistory();
  640. const LogicEditResult condition = fixture.editor.applyConditionAndAdvance(
  641. fixture.logicId, {}, contact(11), true);
  642. require(condition.edit.succeeded,
  643. "placing the first condition must create the first row");
  644. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  645. require(logic->rungs.size() == 1U
  646. && logic->rungs.front().cells.front().node.has_value(),
  647. "the first condition must be stored in the first grid cell");
  648. require(fixture.editor.canUndo(),
  649. "creating the first row and condition must be one history entry");
  650. require(fixture.editor.undo().succeeded
  651. && fixture.editor.findLogic(fixture.logicId)->rungs.empty(),
  652. "undo must remove the atomically created first condition and row");
  653. require(fixture.editor.redo().succeeded
  654. && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U,
  655. "redo must restore the atomically created first condition and row");
  656. fixture.editor.clearHistory();
  657. const std::string wire_rung = fixture.addRung();
  658. const LogicEditResult wire = fixture.editor.applyWireAndAdvance(
  659. fixture.logicId, {wire_rung, 9, false});
  660. require(wire.edit.succeeded,
  661. "a wire edit must succeed on a newly added row");
  662. logic = fixture.editor.findLogic(fixture.logicId);
  663. require(logic->rungs.size() == 2U
  664. && logic->rungs.back().cells.back().kind == LadderCellKind::Wire,
  665. "an empty-target wire must be placed in the new row");
  666. }
  667. void testCursorAdvanceAndOutputTransaction()
  668. {
  669. Fixture fixture;
  670. fixture.editor.clearHistory();
  671. LogicEditResult applied = fixture.editor.applyConditionAndAdvance(
  672. fixture.logicId, {}, contact(50), true);
  673. require(
  674. applied.edit.succeeded
  675. && !applied.nextCursor.output
  676. && applied.nextCursor.column == 1
  677. && !applied.nextCursor.rungId.empty(),
  678. "the first condition must create a row and advance to column two");
  679. const std::string first_rung = applied.nextCursor.rungId;
  680. require(
  681. fixture.editor.findRung(fixture.logicId, first_rung)
  682. ->cells.front().kind == LadderCellKind::Node,
  683. "the first condition must be written to column one");
  684. require(
  685. fixture.editor.undo().succeeded
  686. && fixture.editor.findLogic(fixture.logicId)->rungs.empty(),
  687. "one undo must remove the first condition and its atomically created row");
  688. require(fixture.editor.redo().succeeded,
  689. "the first cursor edit must redo atomically");
  690. fixture.editor.clearHistory();
  691. LogicEditCursor cursor{first_rung, 1, false};
  692. for (int column = 1;
  693. column < ProjectLimits::kMaximumConditionColumns;
  694. ++column)
  695. {
  696. applied = fixture.editor.applyWireAndAdvance(
  697. fixture.logicId, cursor);
  698. require(applied.edit.succeeded,
  699. "each wire cursor edit must succeed");
  700. cursor = applied.nextCursor;
  701. }
  702. require(
  703. cursor.output
  704. && cursor.column == ProjectLimits::kMaximumConditionColumns,
  705. "the tenth condition cell must advance to the output slot");
  706. fixture.editor.clearHistory();
  707. const LogicEditResult output = fixture.editor.applyOutputAndAdvance(
  708. fixture.logicId,
  709. cursor,
  710. CoilNodeConfig{
  711. RegisterAddress{RegisterArea::M, 51}, CoilMode::Normal},
  712. true);
  713. require(
  714. output.edit.succeeded
  715. && !output.nextCursor.output
  716. && output.nextCursor.column == 0
  717. && output.nextCursor.rungId != first_rung
  718. && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 2U,
  719. "a final-network output must append a new row and move to its first cell");
  720. require(
  721. fixture.editor.undo().succeeded
  722. && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U
  723. && !fixture.editor.findRung(fixture.logicId, first_rung)
  724. ->output.has_value(),
  725. "one undo must remove both the output and its automatically appended row");
  726. }
  727. void testOutputAutomaticallyCompletesTrailingWires()
  728. {
  729. Fixture direct;
  730. direct.editor.clearHistory();
  731. const LogicEditResult direct_output = direct.editor.applyOutputAndAdvance(
  732. direct.logicId,
  733. {},
  734. CoilNodeConfig{
  735. RegisterAddress{RegisterArea::M, 54}, CoilMode::Normal},
  736. true);
  737. const ControlLogic *direct_logic = direct.editor.findLogic(direct.logicId);
  738. require(
  739. direct_output.edit.succeeded && direct_logic->rungs.size() == 2U
  740. && direct_logic->rungs.front().output.has_value()
  741. && std::all_of(
  742. direct_logic->rungs.front().cells.cbegin(),
  743. direct_logic->rungs.front().cells.cend(),
  744. [](const LadderCell &cell)
  745. {
  746. return cell.kind == LadderCellKind::Wire
  747. && !cell.node.has_value();
  748. }),
  749. "a direct output on empty logic must atomically create ten wires");
  750. requireEmptyGrid(direct_logic->rungs.back());
  751. require(
  752. direct.editor.undo().succeeded
  753. && direct.editor.findLogic(direct.logicId)->rungs.empty(),
  754. "one undo must remove the direct output, its wires, and the next row");
  755. Fixture trailing;
  756. const std::string rung_id = trailing.addRung();
  757. require(
  758. setConditionAtColumn(trailing.editor,
  759. trailing.logicId, rung_id, 0, contact(55), true).succeeded
  760. && setConditionAtColumn(trailing.editor,
  761. trailing.logicId, rung_id, 2, contact(56), true).succeeded,
  762. "the trailing-wire fixture must leave one intentional middle gap");
  763. trailing.editor.clearHistory();
  764. const LogicEditResult trailing_output = trailing.editor.applyOutputAndAdvance(
  765. trailing.logicId,
  766. {rung_id, ProjectLimits::kMaximumConditionColumns, true},
  767. CoilNodeConfig{
  768. RegisterAddress{RegisterArea::M, 57}, CoilMode::Normal},
  769. true);
  770. const LadderRung *completed = trailing.editor.findRung(
  771. trailing.logicId, rung_id);
  772. require(
  773. trailing_output.edit.succeeded
  774. && completed->cells[1].kind == LadderCellKind::Gap
  775. && std::all_of(
  776. completed->cells.cbegin() + 3,
  777. completed->cells.cend(),
  778. [](const LadderCell &cell)
  779. {
  780. return cell.kind == LadderCellKind::Wire;
  781. }),
  782. "output insertion must fill trailing gaps without bridging a middle gap");
  783. require(
  784. trailing.editor.undo().succeeded
  785. && trailing.editor.findLogic(trailing.logicId)->rungs.size() == 1U
  786. && trailing.editor.findRung(trailing.logicId, rung_id)
  787. ->cells[3].kind == LadderCellKind::Gap
  788. && !trailing.editor.findRung(trailing.logicId, rung_id)
  789. ->output.has_value(),
  790. "trailing wires, output, and appended row must undo together");
  791. }
  792. void testOutputAdvancesPastTheWholeNetworkGroup()
  793. {
  794. Fixture fixture;
  795. const std::string upper = fixture.addRung();
  796. const std::string branch = fixture.addRung();
  797. const std::string next_network = fixture.addRung();
  798. require(
  799. fixture.editor.setVerticalConnection(
  800. fixture.logicId, upper, branch, 0, true).succeeded,
  801. "fixture must connect two rows into one network group");
  802. fixture.editor.clearHistory();
  803. const LogicEditResult output = fixture.editor.applyOutputAndAdvance(
  804. fixture.logicId,
  805. {upper, ProjectLimits::kMaximumConditionColumns, true},
  806. CoilNodeConfig{
  807. RegisterAddress{RegisterArea::M, 52}, CoilMode::Normal},
  808. true);
  809. require(
  810. output.edit.succeeded
  811. && output.nextCursor.rungId == next_network
  812. && output.nextCursor.column == 0
  813. && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 3U,
  814. "an output must jump after every row in its connected network group");
  815. }
  816. void testOutputLimitFailureLeavesNoPartialEdit()
  817. {
  818. ProjectLimitSettings limits = defaultProjectLimitSettings();
  819. limits.maximumRungsPerLogic = 1U;
  820. TestSupport::InMemoryProjectStorage storage;
  821. ProjectService projects(storage, limits);
  822. LogicEditorService editor(projects);
  823. const std::string logic_id = editor.ensureDefaultLogic().id;
  824. const std::string rung_id = editor.addRung(logic_id).id;
  825. editor.clearHistory();
  826. const LogicEditResult output = editor.applyOutputAndAdvance(
  827. logic_id,
  828. {rung_id, ProjectLimits::kMaximumConditionColumns, true},
  829. CoilNodeConfig{
  830. RegisterAddress{RegisterArea::M, 53}, CoilMode::Normal},
  831. true);
  832. require(
  833. !output.edit.succeeded
  834. && !editor.findRung(logic_id, rung_id)->output.has_value()
  835. && editor.findLogic(logic_id)->rungs.size() == 1U
  836. && !editor.canUndo(),
  837. "a row-limit failure must not leave the output or an undo record behind");
  838. }
  839. void testSingleWireClipboardPasteAndUndo()
  840. {
  841. Fixture fixture;
  842. const std::string source = fixture.addRung();
  843. const std::string target = fixture.addRung();
  844. require(
  845. fixture.editor.setHorizontalWireRange(
  846. fixture.logicId, source, 2, 2, true).succeeded,
  847. "fixture must create one copyable wire cell");
  848. LogicSelectionCopyRequest selection;
  849. selection.cells.push_back({source, 2});
  850. const LogicClipboardCopyResult copied = fixture.editor.copySelection(
  851. fixture.logicId, selection);
  852. require(
  853. copied.copy.succeeded
  854. && copied.fragment.mode == LogicClipboardMode::GridObjects
  855. && copied.fragment.cells.size() == 1U
  856. && copied.fragment.cells.front().kind == LadderCellKind::Wire
  857. && copied.fragment.rowSpan == 1
  858. && copied.fragment.columnSpan == 1,
  859. "a selected wire must become a one-cell grid fragment");
  860. fixture.editor.clearHistory();
  861. const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard(
  862. fixture.logicId, copied.fragment, {target, 5, false, false});
  863. require(
  864. pasted.edit.succeeded
  865. && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 2U
  866. && fixture.editor.findCell(fixture.logicId, target, 5)->kind
  867. == LadderCellKind::Wire,
  868. "pasting one wire must change one target cell without adding a row");
  869. require(
  870. fixture.editor.undo().succeeded
  871. && fixture.editor.findCell(fixture.logicId, target, 5)->kind
  872. == LadderCellKind::Gap,
  873. "one undo must restore the complete single-wire paste");
  874. }
  875. void testMixedAndSparseGridClipboardFragments()
  876. {
  877. Fixture fixture;
  878. const std::string source = fixture.addRung();
  879. const std::string target = fixture.addRung();
  880. const LogicEditorResult first = setConditionAtColumn(fixture.editor,
  881. fixture.logicId, source, 0, contact(70), true);
  882. const LogicEditorResult second = setConditionAtColumn(fixture.editor,
  883. fixture.logicId, source, 2, contact(71), true);
  884. require(
  885. first.succeeded && second.succeeded
  886. && fixture.editor.setHorizontalWireRange(
  887. fixture.logicId, source, 1, 1, true).succeeded,
  888. "fixture must create a node-wire-node source fragment");
  889. LogicSelectionCopyRequest selection;
  890. selection.cells = {{source, 2}, {source, 0}, {source, 1}};
  891. const LogicClipboardCopyResult copied = fixture.editor.copySelection(
  892. fixture.logicId, selection);
  893. require(
  894. copied.copy.succeeded && copied.fragment.cells.size() == 3U
  895. && copied.fragment.cells[0].relativeColumn == 0
  896. && copied.fragment.cells[1].relativeColumn == 1
  897. && copied.fragment.cells[2].relativeColumn == 2,
  898. "mixed copied cells must be sorted by visual coordinates");
  899. fixture.editor.clearHistory();
  900. const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard(
  901. fixture.logicId, copied.fragment, {target, 4, false, false});
  902. const LadderCell *first_paste = fixture.editor.findCell(
  903. fixture.logicId, target, 4);
  904. const LadderCell *wire_paste = fixture.editor.findCell(
  905. fixture.logicId, target, 5);
  906. const LadderCell *second_paste = fixture.editor.findCell(
  907. fixture.logicId, target, 6);
  908. require(
  909. pasted.edit.succeeded
  910. && first_paste->kind == LadderCellKind::Node
  911. && wire_paste->kind == LadderCellKind::Wire
  912. && second_paste->kind == LadderCellKind::Node
  913. && first_paste->node->id != first.id
  914. && second_paste->node->id != second.id,
  915. "mixed paste must preserve cell order and allocate new node IDs");
  916. require(
  917. fixture.editor.undo().succeeded
  918. && fixture.editor.findCell(fixture.logicId, target, 4)->kind
  919. == LadderCellKind::Gap
  920. && fixture.editor.findCell(fixture.logicId, target, 5)->kind
  921. == LadderCellKind::Gap
  922. && fixture.editor.findCell(fixture.logicId, target, 6)->kind
  923. == LadderCellKind::Gap,
  924. "one undo must remove every object in a mixed paste");
  925. Fixture sparse;
  926. const std::string sparse_source = sparse.addRung();
  927. const std::string sparse_target = sparse.addRung();
  928. require(
  929. sparse.editor.setHorizontalWireRange(
  930. sparse.logicId, sparse_source, 0, 0, true).succeeded
  931. && setConditionAtColumn(sparse.editor,
  932. sparse.logicId, sparse_source, 2, contact(72), true).succeeded
  933. && sparse.editor.setHorizontalWireRange(
  934. sparse.logicId, sparse_target, 5, 5, true).succeeded,
  935. "fixture must create a sparse source and occupied transparent hole");
  936. LogicSelectionCopyRequest sparse_selection;
  937. sparse_selection.cells = {{sparse_source, 0}, {sparse_source, 2}};
  938. const LogicClipboardCopyResult sparse_copy = sparse.editor.copySelection(
  939. sparse.logicId, sparse_selection);
  940. require(
  941. sparse.editor.pasteClipboard(
  942. sparse.logicId,
  943. sparse_copy.fragment,
  944. {sparse_target, 4, false, false}).edit.succeeded
  945. && sparse.editor.findCell(sparse.logicId, sparse_target, 4)->kind
  946. == LadderCellKind::Wire
  947. && sparse.editor.findCell(sparse.logicId, sparse_target, 5)->kind
  948. == LadderCellKind::Wire
  949. && sparse.editor.findCell(sparse.logicId, sparse_target, 6)->kind
  950. == LadderCellKind::Node,
  951. "an unselected hole must stay transparent and preserve target content");
  952. }
  953. void testGridClipboardFailuresAreAtomic()
  954. {
  955. Fixture fixture;
  956. const std::string source = fixture.addRung();
  957. const std::string target = fixture.addRung();
  958. const std::string last = fixture.addRung();
  959. require(
  960. fixture.editor.setHorizontalWireRange(
  961. fixture.logicId, source, 0, 1, true).succeeded
  962. && setConditionAtColumn(fixture.editor,
  963. fixture.logicId, target, 4, contact(73), true).succeeded
  964. && fixture.editor.setHorizontalWireRange(
  965. fixture.logicId, target, 0, 0, true).succeeded,
  966. "fixture must create clipboard failure targets");
  967. LogicSelectionCopyRequest one_wire_selection;
  968. one_wire_selection.cells = {{source, 0}};
  969. const LogicClipboardFragment one_wire = fixture.editor.copySelection(
  970. fixture.logicId, one_wire_selection).fragment;
  971. fixture.editor.clearHistory();
  972. require(
  973. !fixture.editor.pasteClipboard(
  974. fixture.logicId, one_wire, {target, 4, false, false}).edit.succeeded
  975. && fixture.editor.findCell(fixture.logicId, target, 4)->kind
  976. == LadderCellKind::Node
  977. && !fixture.editor.canUndo(),
  978. "wire paste onto a node must fail without model or history changes");
  979. LogicSelectionCopyRequest two_wire_selection;
  980. two_wire_selection.cells = {{source, 0}, {source, 1}};
  981. const LogicClipboardFragment two_wires = fixture.editor.copySelection(
  982. fixture.logicId, two_wire_selection).fragment;
  983. require(
  984. !fixture.editor.pasteClipboard(
  985. fixture.logicId, two_wires, {target, 9, false, false}).edit.succeeded
  986. && fixture.editor.findCell(fixture.logicId, target, 9)->kind
  987. == LadderCellKind::Gap
  988. && !fixture.editor.canUndo(),
  989. "a fragment crossing the tenth condition column must fail atomically");
  990. LogicSelectionCopyRequest cross_row_selection;
  991. cross_row_selection.cells = {{target, 0}, {last, 0}};
  992. require(
  993. fixture.editor.setHorizontalWireRange(
  994. fixture.logicId, last, 0, 0, true).succeeded,
  995. "fixture must complete a two-row source fragment");
  996. const LogicClipboardFragment cross_rows = fixture.editor.copySelection(
  997. fixture.logicId, cross_row_selection).fragment;
  998. fixture.editor.clearHistory();
  999. require(
  1000. !fixture.editor.pasteClipboard(
  1001. fixture.logicId, cross_rows, {last, 2, false, false}).edit.succeeded
  1002. && fixture.editor.findCell(fixture.logicId, last, 2)->kind
  1003. == LadderCellKind::Gap
  1004. && !fixture.editor.canUndo(),
  1005. "cross-row paste without enough target rows must fail atomically");
  1006. }
  1007. void testOutputAndVerticalClipboardRules()
  1008. {
  1009. Fixture fixture;
  1010. const std::string first = fixture.addRung();
  1011. const std::string second = fixture.addRung();
  1012. const std::string third = fixture.addRung();
  1013. const std::string fourth = fixture.addRung();
  1014. require(
  1015. fixture.editor.setOutput(
  1016. fixture.logicId,
  1017. first,
  1018. CoilNodeConfig{
  1019. RegisterAddress{RegisterArea::M, 80}, CoilMode::Normal},
  1020. true).succeeded
  1021. && fixture.editor.setOutput(
  1022. fixture.logicId,
  1023. second,
  1024. CoilNodeConfig{
  1025. RegisterAddress{RegisterArea::M, 81}, CoilMode::Set},
  1026. true).succeeded,
  1027. "fixture must create source and target outputs");
  1028. LogicSelectionCopyRequest output_selection;
  1029. output_selection.outputRungIds = {first};
  1030. const LogicClipboardFragment output = fixture.editor.copySelection(
  1031. fixture.logicId, output_selection).fragment;
  1032. require(
  1033. !fixture.editor.pasteClipboard(
  1034. fixture.logicId, output, {second, 0, false, false}).edit.succeeded,
  1035. "a copied output must reject a condition-grid target");
  1036. const std::string old_output_id = fixture.editor.findRung(
  1037. fixture.logicId, second)->output->id;
  1038. fixture.editor.clearHistory();
  1039. const LogicClipboardPasteResult output_paste = fixture.editor.pasteClipboard(
  1040. fixture.logicId,
  1041. output,
  1042. {second, ProjectLimits::kMaximumConditionColumns, true, false});
  1043. require(
  1044. output_paste.edit.succeeded
  1045. && fixture.editor.findRung(fixture.logicId, second)->output->id
  1046. != old_output_id
  1047. && std::get<CoilNodeConfig>(
  1048. fixture.editor.findRung(fixture.logicId, second)->output->config)
  1049. .address.index() == 80,
  1050. "an explicitly selected single output may replace a target output");
  1051. require(
  1052. fixture.editor.undo().succeeded
  1053. && fixture.editor.findRung(fixture.logicId, second)->output->id
  1054. == old_output_id,
  1055. "one undo must restore the replaced output");
  1056. require(
  1057. fixture.editor.setHorizontalWireRange(
  1058. fixture.logicId, first, 8, 8, true).succeeded,
  1059. "fixture must add a condition-grid object beside the copied output");
  1060. LogicSelectionCopyRequest mixed_output_selection;
  1061. mixed_output_selection.cells = {{first, 8}};
  1062. mixed_output_selection.outputRungIds = {first};
  1063. const LogicClipboardFragment mixed_output = fixture.editor.copySelection(
  1064. fixture.logicId, mixed_output_selection).fragment;
  1065. fixture.editor.clearHistory();
  1066. require(
  1067. !fixture.editor.pasteClipboard(
  1068. fixture.logicId,
  1069. mixed_output,
  1070. {second, 8, false, false}).edit.succeeded
  1071. && fixture.editor.findCell(fixture.logicId, second, 8)->kind
  1072. == LadderCellKind::Gap
  1073. && fixture.editor.findRung(fixture.logicId, second)->output->id
  1074. == old_output_id
  1075. && !fixture.editor.canUndo(),
  1076. "a mixed fragment must not silently replace an occupied output slot");
  1077. require(
  1078. fixture.editor.setVerticalConnection(
  1079. fixture.logicId,
  1080. first,
  1081. second,
  1082. ProjectLimits::kMaximumConditionColumns,
  1083. true).succeeded,
  1084. "fixture must create an output-side vertical edge");
  1085. LogicSelectionCopyRequest output_edge_selection;
  1086. output_edge_selection.outputRungIds = {first};
  1087. output_edge_selection.verticalConnectionIds = {
  1088. connectionAt(
  1089. *fixture.editor.findLogic(fixture.logicId),
  1090. first,
  1091. second,
  1092. ProjectLimits::kMaximumConditionColumns)->id};
  1093. const LogicClipboardFragment output_edge = fixture.editor.copySelection(
  1094. fixture.logicId, output_edge_selection).fragment;
  1095. fixture.editor.clearHistory();
  1096. require(
  1097. fixture.editor.pasteClipboard(
  1098. fixture.logicId,
  1099. output_edge,
  1100. {third, ProjectLimits::kMaximumConditionColumns, true, false})
  1101. .edit.succeeded
  1102. && fixture.editor.findRung(fixture.logicId, third)
  1103. ->output.has_value()
  1104. && connectionAt(
  1105. *fixture.editor.findLogic(fixture.logicId),
  1106. third,
  1107. fourth,
  1108. ProjectLimits::kMaximumConditionColumns) != nullptr,
  1109. "an output plus its right-side vertical edge must paste from the output anchor");
  1110. require(
  1111. fixture.editor.undo().succeeded,
  1112. "one undo must remove the mixed output-edge paste");
  1113. const LogicEditorResult vertical = fixture.editor.setVerticalConnection(
  1114. fixture.logicId, first, second, 2, true);
  1115. require(vertical.succeeded, "fixture must create a copyable vertical edge");
  1116. LogicSelectionCopyRequest vertical_selection;
  1117. vertical_selection.verticalConnectionIds = {
  1118. connectionAt(
  1119. *fixture.editor.findLogic(fixture.logicId), first, second, 2)->id};
  1120. const LogicClipboardFragment edge = fixture.editor.copySelection(
  1121. fixture.logicId, vertical_selection).fragment;
  1122. fixture.editor.clearHistory();
  1123. require(
  1124. fixture.editor.pasteClipboard(
  1125. fixture.logicId, edge, {second, 6, false, true}).edit.succeeded
  1126. && connectionAt(
  1127. *fixture.editor.findLogic(fixture.logicId), second, third, 6)
  1128. != nullptr,
  1129. "a vertical edge must paste onto a valid adjacent-row boundary");
  1130. const std::size_t connection_count = fixture.editor.findLogic(
  1131. fixture.logicId)->verticalConnections.size();
  1132. require(
  1133. fixture.editor.pasteClipboard(
  1134. fixture.logicId, edge, {second, 6, false, true}).edit.succeeded
  1135. && fixture.editor.findLogic(fixture.logicId)
  1136. ->verticalConnections.size() == connection_count,
  1137. "pasting an existing vertical edge must be idempotent");
  1138. require(
  1139. !fixture.editor.pasteClipboard(
  1140. fixture.logicId, edge, {fourth, 6, false, true}).edit.succeeded,
  1141. "a vertical edge must not paste below the final row");
  1142. }
  1143. void testWholeRowClipboardInsertionAndLimit()
  1144. {
  1145. Fixture fixture;
  1146. const std::string first = fixture.addRung();
  1147. const std::string second = fixture.addRung();
  1148. const std::string target = fixture.addRung();
  1149. const LogicEditorResult source_node = setConditionAtColumn(fixture.editor,
  1150. fixture.logicId, first, 0, contact(90), true);
  1151. require(
  1152. source_node.succeeded
  1153. && fixture.editor.setHorizontalWireRange(
  1154. fixture.logicId, second, 0, 1, true).succeeded
  1155. && fixture.editor.updateNetworkComment(
  1156. fixture.logicId, first, "整行复制注释").succeeded
  1157. && fixture.editor.setVerticalConnection(
  1158. fixture.logicId, first, second, 2, true).succeeded,
  1159. "fixture must create two connected rows for whole-row copy");
  1160. LogicSelectionCopyRequest selection;
  1161. selection.wholeRungIds = {second, first};
  1162. const LogicClipboardCopyResult copied = fixture.editor.copySelection(
  1163. fixture.logicId, selection);
  1164. require(
  1165. copied.copy.succeeded
  1166. && copied.fragment.mode == LogicClipboardMode::WholeRows
  1167. && copied.fragment.rows.size() == 2U
  1168. && copied.fragment.verticalConnections.size() == 1U,
  1169. "explicit row headers must copy complete consecutive rows and internal edges");
  1170. fixture.editor.clearHistory();
  1171. const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard(
  1172. fixture.logicId, copied.fragment, {target, 0, false, false});
  1173. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  1174. require(
  1175. pasted.edit.succeeded && pasted.wholeRungIds.size() == 2U
  1176. && logic->rungs.size() == 5U
  1177. && logic->rungs[3].id == pasted.wholeRungIds[0]
  1178. && logic->rungs[4].id == pasted.wholeRungIds[1]
  1179. && logic->rungs[3].comment == "整行复制注释"
  1180. && logic->rungs[3].cells[0].node->id != source_node.id
  1181. && connectionAt(
  1182. *logic,
  1183. pasted.wholeRungIds[0],
  1184. pasted.wholeRungIds[1],
  1185. 2) != nullptr,
  1186. "whole rows must insert after the selected row with new identities");
  1187. require(
  1188. fixture.editor.undo().succeeded
  1189. && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 3U,
  1190. "one undo must remove every row in one whole-row paste");
  1191. ProjectLimitSettings limits = defaultProjectLimitSettings();
  1192. limits.maximumRungsPerLogic = 2U;
  1193. TestSupport::InMemoryProjectStorage storage;
  1194. ProjectService projects(storage, limits);
  1195. LogicEditorService editor(projects);
  1196. const std::string logic_id = editor.ensureDefaultLogic().id;
  1197. const std::string source = editor.addRung(logic_id).id;
  1198. const std::string destination = editor.addRung(logic_id).id;
  1199. LogicSelectionCopyRequest limit_selection;
  1200. limit_selection.wholeRungIds = {source};
  1201. const LogicClipboardFragment row = editor.copySelection(
  1202. logic_id, limit_selection).fragment;
  1203. editor.clearHistory();
  1204. require(
  1205. !editor.pasteClipboard(
  1206. logic_id, row, {destination, 0, false, false}).edit.succeeded
  1207. && editor.findLogic(logic_id)->rungs.size() == 2U
  1208. && !editor.canUndo(),
  1209. "whole-row paste at the row limit must leave no partial edit or history");
  1210. }
  1211. void testSyntaxCheckNormalizesUnusedWiresAsOneEdit()
  1212. {
  1213. Fixture fixture;
  1214. const std::string output_rung = fixture.addRung();
  1215. const std::string dangling_branch = fixture.addRung();
  1216. const std::string isolated_rung = fixture.addRung();
  1217. require(
  1218. fixture.editor.setHorizontalWireRange(
  1219. fixture.logicId, output_rung, 0, 9, true).succeeded
  1220. && fixture.editor.setOutput(
  1221. fixture.logicId,
  1222. output_rung,
  1223. CoilNodeConfig{
  1224. RegisterAddress{RegisterArea::M, 100}, CoilMode::Normal},
  1225. true).succeeded
  1226. && fixture.editor.setHorizontalWireRange(
  1227. fixture.logicId, dangling_branch, 2, 5, true).succeeded
  1228. && fixture.editor.setVerticalConnection(
  1229. fixture.logicId,
  1230. output_rung,
  1231. dangling_branch,
  1232. 3,
  1233. true).succeeded
  1234. && fixture.editor.setHorizontalWireRange(
  1235. fixture.logicId, isolated_rung, 7, 8, true).succeeded,
  1236. "fixture must contain one valid output and several unused line fragments");
  1237. fixture.editor.clearHistory();
  1238. const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax(
  1239. fixture.logicId);
  1240. const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId);
  1241. require(
  1242. checked.completed && checked.valid && checked.changed
  1243. && checked.removedWireCells == 6U
  1244. && checked.removedVerticalConnections == 1U
  1245. && logic->verticalConnections.empty(),
  1246. "syntax check must remove every unused horizontal and vertical line; wires="
  1247. + std::to_string(checked.removedWireCells)
  1248. + ", verticals="
  1249. + std::to_string(checked.removedVerticalConnections)
  1250. + ", valid=" + std::to_string(checked.valid)
  1251. + ", changed=" + std::to_string(checked.changed));
  1252. for (int column = 0;
  1253. column < ProjectLimits::kMaximumConditionColumns;
  1254. ++column)
  1255. {
  1256. require(
  1257. fixture.editor.findCell(
  1258. fixture.logicId, output_rung, column)->kind
  1259. == LadderCellKind::Wire,
  1260. "syntax normalization must preserve the complete output path");
  1261. }
  1262. requireEmptyGrid(*fixture.editor.findRung(
  1263. fixture.logicId, dangling_branch));
  1264. requireEmptyGrid(*fixture.editor.findRung(
  1265. fixture.logicId, isolated_rung));
  1266. require(
  1267. fixture.editor.canUndo() && fixture.editor.undo().succeeded
  1268. && fixture.editor.findLogic(fixture.logicId)
  1269. ->verticalConnections.size() == 1U
  1270. && fixture.editor.findCell(
  1271. fixture.logicId, dangling_branch, 2)->kind
  1272. == LadderCellKind::Wire
  1273. && fixture.editor.findCell(
  1274. fixture.logicId, isolated_rung, 7)->kind
  1275. == LadderCellKind::Wire
  1276. && !fixture.editor.canUndo(),
  1277. "all syntax normalization changes must be restored by one undo");
  1278. }
  1279. void testSyntaxCheckPreservesValidParallelPath()
  1280. {
  1281. Fixture fixture;
  1282. const std::string upper = fixture.addRung();
  1283. const std::string lower = fixture.addRung();
  1284. require(
  1285. fixture.editor.setHorizontalWireRange(
  1286. fixture.logicId, upper, 0, 9, true).succeeded
  1287. && fixture.editor.setOutput(
  1288. fixture.logicId,
  1289. upper,
  1290. CoilNodeConfig{
  1291. RegisterAddress{RegisterArea::M, 101}, CoilMode::Normal},
  1292. true).succeeded
  1293. && fixture.editor.setHorizontalWireRange(
  1294. fixture.logicId, upper, 5, 5, false).succeeded
  1295. && fixture.editor.setHorizontalWireRange(
  1296. fixture.logicId, lower, 0, 5, true).succeeded
  1297. && fixture.editor.setVerticalConnection(
  1298. fixture.logicId, upper, lower, 0, true).succeeded
  1299. && fixture.editor.setVerticalConnection(
  1300. fixture.logicId, upper, lower, 6, true).succeeded,
  1301. "fixture must create a parallel path around one broken upper cell");
  1302. fixture.editor.clearHistory();
  1303. const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax(
  1304. fixture.logicId);
  1305. require(
  1306. checked.completed && checked.valid
  1307. && checked.removedWireCells == 5U
  1308. && checked.removedVerticalConnections == 0U
  1309. && fixture.editor.findLogic(fixture.logicId)
  1310. ->verticalConnections.size() == 2U,
  1311. "syntax normalization must keep every line used by the valid parallel path");
  1312. for (int column = 0; column <= 5; ++column)
  1313. {
  1314. require(
  1315. fixture.editor.findCell(
  1316. fixture.logicId, lower, column)->kind
  1317. == LadderCellKind::Wire,
  1318. "the lower bypass path must remain complete");
  1319. }
  1320. require(
  1321. fixture.editor.findLogic(fixture.logicId)->validateForRunning(),
  1322. "the normalized parallel network must remain runnable");
  1323. }
  1324. void testSyntaxCheckKeepsBrokenOutputForErrorLocation()
  1325. {
  1326. Fixture fixture;
  1327. const std::string rung_id = fixture.addRung();
  1328. require(
  1329. fixture.editor.setHorizontalWireRange(
  1330. fixture.logicId, rung_id, 2, 9, true).succeeded
  1331. && fixture.editor.setOutput(
  1332. fixture.logicId,
  1333. rung_id,
  1334. CoilNodeConfig{
  1335. RegisterAddress{RegisterArea::M, 102}, CoilMode::Normal},
  1336. true).succeeded,
  1337. "fixture must create an output connected only on its right side");
  1338. fixture.editor.clearHistory();
  1339. const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax(
  1340. fixture.logicId);
  1341. require(
  1342. checked.completed && !checked.valid && !checked.changed
  1343. && checked.location.has_value()
  1344. && checked.location->logicId == fixture.logicId
  1345. && checked.location->rungId == rung_id
  1346. && checked.location->network == 1
  1347. && checked.location->row == 1
  1348. && checked.location->column
  1349. == ProjectLimits::kMaximumLadderColumns
  1350. && checked.message.find("第 11 列") != std::string::npos
  1351. && checked.message.find("第 1 列起断开") != std::string::npos
  1352. && fixture.editor.findRung(fixture.logicId, rung_id)
  1353. ->output.has_value()
  1354. && fixture.editor.findCell(
  1355. fixture.logicId, rung_id, 2)->kind == LadderCellKind::Wire
  1356. && !fixture.editor.canUndo(),
  1357. "a broken output network must stay visible and report its output slot");
  1358. }
  1359. void testDoubleCoilCheckRemainsIndependent()
  1360. {
  1361. Fixture fixture;
  1362. const std::string first = fixture.addRung();
  1363. const std::string second = fixture.addRung();
  1364. require(
  1365. fixture.editor.setHorizontalWireRange(
  1366. fixture.logicId, first, 0, 9, true).succeeded
  1367. && fixture.editor.setHorizontalWireRange(
  1368. fixture.logicId, second, 0, 9, true).succeeded
  1369. && fixture.editor.setOutput(
  1370. fixture.logicId,
  1371. first,
  1372. CoilNodeConfig{
  1373. RegisterAddress{RegisterArea::M, 120}, CoilMode::Normal},
  1374. true).succeeded
  1375. && fixture.editor.setOutput(
  1376. fixture.logicId,
  1377. second,
  1378. CoilNodeConfig{
  1379. RegisterAddress{RegisterArea::M, 120}, CoilMode::Set},
  1380. true).succeeded,
  1381. "fixture must create two outputs for the same M address");
  1382. fixture.editor.clearHistory();
  1383. const LogicSyntaxCheckResult syntax = fixture.editor.checkSyntax(
  1384. fixture.logicId);
  1385. const LogicSyntaxCheckResult double_coil =
  1386. fixture.editor.checkDoubleCoils(fixture.logicId);
  1387. require(
  1388. syntax.completed && syntax.valid && !syntax.changed
  1389. && double_coil.completed && !double_coil.valid
  1390. && double_coil.location.has_value()
  1391. && double_coil.location->rungId == second
  1392. && double_coil.location->row == 2
  1393. && double_coil.location->column
  1394. == ProjectLimits::kMaximumLadderColumns
  1395. && double_coil.message.find("M120") != std::string::npos
  1396. && double_coil.message.find("第 1 行") != std::string::npos
  1397. && !fixture.editor.canUndo(),
  1398. "double coils must be reported only by the independent check");
  1399. }
  1400. } // namespace
  1401. int main()
  1402. {
  1403. try
  1404. {
  1405. testContinuousGridAndIndependentHorizontalWires();
  1406. testIndependentVerticalConnectionsAndNetworkSplit();
  1407. testNetworkCommentsFollowNetworkHeadsAndMergeAtomically();
  1408. testInsertRowSplitsVerticalEdges();
  1409. testDeleteRowMergesOnlyContinuousEdges();
  1410. testParallelBranchCreatesConnectedVisualRow();
  1411. testParallelBranchReusesExistingEdges();
  1412. testNodeDeletionAndHistoryAreAtomic();
  1413. testSelectionDeletionIsAtomic();
  1414. testInvalidSelectionDeletionDoesNotMutateOrRecordHistory();
  1415. testCommandInputMapsToGridCoordinates();
  1416. testProjectRungLimitAppliesToBranchAndPaste();
  1417. testConfiguredRowLimit();
  1418. testFirstEditOnEmptyLogicIsAtomic();
  1419. testCursorAdvanceAndOutputTransaction();
  1420. testOutputAutomaticallyCompletesTrailingWires();
  1421. testOutputAdvancesPastTheWholeNetworkGroup();
  1422. testOutputLimitFailureLeavesNoPartialEdit();
  1423. testSingleWireClipboardPasteAndUndo();
  1424. testMixedAndSparseGridClipboardFragments();
  1425. testGridClipboardFailuresAreAtomic();
  1426. testOutputAndVerticalClipboardRules();
  1427. testWholeRowClipboardInsertionAndLimit();
  1428. testSyntaxCheckNormalizesUnusedWiresAsOneEdit();
  1429. testSyntaxCheckPreservesValidParallelPath();
  1430. testSyntaxCheckKeepsBrokenOutputForErrorLocation();
  1431. testDoubleCoilCheckRemainsIndependent();
  1432. }
  1433. catch (const std::exception &error)
  1434. {
  1435. std::cerr << "logic editor service tests failed: "
  1436. << error.what() << '\n';
  1437. return 1;
  1438. }
  1439. std::cout << "logic editor service tests passed\n";
  1440. return 0;
  1441. }