综合平台编程器项目的远程存储
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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