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

1268 lines
61 KiB

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