综合平台编程器项目的远程存储
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

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