综合平台编程器项目的远程存储
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

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