综合平台编程器项目的远程存储
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

840 строки
40 KiB

  1. #include "domain/project_storage.h"
  2. #include "domain/project_limits.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. namespace {
  10. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  11. using TestSupport::require;
  12. ContactNodeConfig contact(int address)
  13. {
  14. return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
  15. }
  16. int conditionColumns(const ConditionExpression &expression)
  17. {
  18. if (expression.kind == ConditionExpressionKind::Node)
  19. {
  20. return 1;
  21. }
  22. if (expression.kind == ConditionExpressionKind::Wire)
  23. {
  24. return expression.wire->columnSpan;
  25. }
  26. int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
  27. for (const ConditionExpression &child : expression.children)
  28. {
  29. const int child_columns = conditionColumns(child);
  30. columns = expression.kind == ConditionExpressionKind::Series
  31. ? columns + child_columns : std::max(columns, child_columns);
  32. }
  33. return columns;
  34. }
  35. int wireColumns(const ConditionExpression &expression)
  36. {
  37. if (expression.kind == ConditionExpressionKind::Node)
  38. {
  39. return 0;
  40. }
  41. if (expression.kind == ConditionExpressionKind::Wire)
  42. {
  43. return expression.wire->columnSpan;
  44. }
  45. int columns = 0;
  46. for (const ConditionExpression &child : expression.children)
  47. {
  48. columns += wireColumns(child);
  49. }
  50. return columns;
  51. }
  52. void testStructuredEditingAndNormalization()
  53. {
  54. TestProjectStorage storage;
  55. ProjectService project_service(storage);
  56. LogicEditorService service(project_service);
  57. const std::string logic_id = service.ensureDefaultLogic().id;
  58. const std::string rung_id = service.firstRungId(logic_id);
  59. const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0));
  60. const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1));
  61. require(first.succeeded && second.succeeded, "series append must succeed");
  62. const LadderRung *rung = service.findRung(logic_id, rung_id);
  63. require(rung->condition->kind == ConditionExpressionKind::Series
  64. && rung->condition->children.size() == 2U,
  65. "two appended nodes must form a series expression");
  66. const std::string second_expression_id = second.id;
  67. const LogicEditorResult parallel = service.addParallelBranch(
  68. logic_id, rung_id, {second_expression_id}, contact(2));
  69. require(parallel.succeeded, "parallel insertion must succeed");
  70. rung = service.findRung(logic_id, rung_id);
  71. const ConditionExpression *parallel_expression = service.findExpression(
  72. logic_id, rung_id, rung->condition->children.at(1).id);
  73. require(parallel_expression != nullptr
  74. && parallel_expression->kind == ConditionExpressionKind::Parallel,
  75. "selected node must become a parallel expression");
  76. const LogicEditorResult nested_series = service.insertConditionAfter(
  77. logic_id,
  78. rung_id,
  79. parallel.id,
  80. contact(3));
  81. require(nested_series.succeeded, "a parallel branch must accept a series node");
  82. rung = service.findRung(logic_id, rung_id);
  83. require(rung->condition->kind == ConditionExpressionKind::Series,
  84. "root must remain a series expression");
  85. const ConditionExpression &nested_parallel_expression = rung->condition->children.at(1);
  86. require(nested_parallel_expression.kind == ConditionExpressionKind::Parallel
  87. && nested_parallel_expression.children.at(1).kind
  88. == ConditionExpressionKind::Series,
  89. "editor must express A AND (B OR (C AND D))");
  90. require(service.removeNode(logic_id, nested_series.id).succeeded,
  91. "nested series node deletion must succeed");
  92. rung = service.findRung(logic_id, rung_id);
  93. require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel
  94. && rung->condition->children.at(1).children.at(1).kind
  95. == ConditionExpressionKind::Node,
  96. "single-child series container must collapse after deletion");
  97. require(service.removeNode(logic_id, parallel.id).succeeded,
  98. "parallel leaf deletion must succeed");
  99. rung = service.findRung(logic_id, rung_id);
  100. require(rung->condition->kind == ConditionExpressionKind::Series
  101. && rung->condition->children.size() == 2U,
  102. "single-child parallel container must collapse after deletion");
  103. require(service.setOutput(
  104. logic_id,
  105. rung_id,
  106. CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  107. .succeeded,
  108. "output coil must be set");
  109. require(service.findLogic(logic_id)->validate(),
  110. "structured editing result must remain a valid draft");
  111. }
  112. void testRangeParallelInsertion()
  113. {
  114. TestProjectStorage storage;
  115. ProjectService project_service(storage);
  116. LogicEditorService service(project_service);
  117. const std::string logic_id = service.ensureDefaultLogic().id;
  118. const std::string rung_id = service.firstRungId(logic_id);
  119. service.appendCondition(logic_id, rung_id, contact(0));
  120. service.appendCondition(logic_id, rung_id, contact(1));
  121. service.appendCondition(logic_id, rung_id, contact(2));
  122. const LogicEditorResult branch = service.addParallelBranch(
  123. logic_id, rung_id, {"contact-2", "contact-3"}, contact(3));
  124. require(branch.succeeded, "a continuous series range must accept a parallel branch");
  125. const ConditionExpression &root = *service.findRung(logic_id, rung_id)->condition;
  126. require(root.kind == ConditionExpressionKind::Series
  127. && root.children.size() == 2U
  128. && root.children.at(1).kind == ConditionExpressionKind::Parallel
  129. && root.children.at(1).children.front().kind
  130. == ConditionExpressionKind::Series,
  131. "range insertion must express A AND ((B AND C) OR D)");
  132. require(root.validate(), "range insertion must preserve normalized topology");
  133. const LogicEditorResult invalid = service.addParallelBranch(
  134. logic_id, rung_id, {"contact-1", "contact-3"}, contact(4));
  135. require(!invalid.succeeded
  136. && invalid.error == LogicEditorError::InvalidOperation,
  137. "a non-contiguous selection must be rejected");
  138. }
  139. void testStructuredWireEditing()
  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 = service.firstRungId(logic_id);
  146. service.appendCondition(logic_id, rung_id, contact(0));
  147. service.appendCondition(logic_id, rung_id, contact(1));
  148. service.appendCondition(logic_id, rung_id, contact(2));
  149. const LogicEditorResult branch = service.addParallelWireBranch(
  150. logic_id, rung_id, {"contact-2", "contact-3"});
  151. require(branch.succeeded && branch.id == "wire-1",
  152. "a continuous range must accept a structured horizontal bypass");
  153. const LadderRung *rung = service.findRung(logic_id, rung_id);
  154. require(rung->condition->kind == ConditionExpressionKind::Series
  155. && rung->condition->children.at(1).kind
  156. == ConditionExpressionKind::Parallel,
  157. "a vertical connection must produce a parallel expression");
  158. const ConditionExpression &wire =
  159. rung->condition->children.at(1).children.at(1);
  160. require(wire.kind == ConditionExpressionKind::Wire
  161. && wire.wire->columnSpan == 2,
  162. "the bypass wire span must match the selected two-column range");
  163. const LogicEditorResult replacement = service.replaceWireWithCondition(
  164. logic_id, rung_id, branch.id, contact(3));
  165. require(replacement.succeeded && replacement.id == "contact-4",
  166. "a selected wire must be replaceable by a configured node type");
  167. const LogicEditorResult extension = service.insertWireAfter(
  168. logic_id, rung_id, replacement.id);
  169. require(extension.succeeded && extension.id == "wire-1",
  170. "a wire id must become reusable after its expression is replaced");
  171. rung = service.findRung(logic_id, rung_id);
  172. const ConditionExpression *extended_branch = service.findExpression(
  173. logic_id, rung_id, rung->condition->children.at(1).children.at(1).id);
  174. require(extended_branch != nullptr
  175. && extended_branch->kind == ConditionExpressionKind::Series
  176. && extended_branch->children.at(1).kind
  177. == ConditionExpressionKind::Wire,
  178. "inserting a horizontal wire after a branch node must preserve structure");
  179. const std::string extended_branch_id = extended_branch->id;
  180. require(!service.addParallelWireBranch(
  181. logic_id, rung_id, {"contact-1", "contact-3"}).succeeded,
  182. "non-contiguous wire connection targets must be rejected");
  183. require(service.removeExpressions(
  184. logic_id, rung_id, {extended_branch_id}).succeeded,
  185. "deleting a selected vertical connection branch must succeed atomically");
  186. rung = service.findRung(logic_id, rung_id);
  187. require(rung->condition->kind == ConditionExpressionKind::Series
  188. && rung->condition->children.size() == 3U,
  189. "removing a bypass branch must normalize back to the original series");
  190. require(service.undo().succeeded
  191. && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr,
  192. "wire branch deletion must participate in ladder undo history");
  193. }
  194. void testBatchDeleteAllNodesInParallelBranch()
  195. {
  196. TestProjectStorage storage;
  197. ProjectService project_service(storage);
  198. LogicEditorService service(project_service);
  199. const std::string logic_id = service.ensureDefaultLogic().id;
  200. const std::string rung_id = service.firstRungId(logic_id);
  201. const LogicEditorResult first = service.appendCondition(
  202. logic_id, rung_id, contact(0));
  203. const LogicEditorResult second = service.appendCondition(
  204. logic_id, rung_id, contact(1));
  205. const LogicEditorResult third = service.appendCondition(
  206. logic_id, rung_id, contact(2));
  207. const LogicEditorResult fourth = service.appendCondition(
  208. logic_id, rung_id, contact(3));
  209. require(first.succeeded && second.succeeded && third.succeeded
  210. && fourth.succeeded,
  211. "parallel batch deletion setup contacts must be created");
  212. const LogicEditorResult branch = service.addParallelBranch(
  213. logic_id, rung_id,
  214. {second.id, third.id, fourth.id},
  215. contact(10));
  216. require(branch.succeeded,
  217. "parallel batch deletion setup branch must be created");
  218. require(service.removeNodes(
  219. logic_id, {second.id, third.id, fourth.id})
  220. .succeeded,
  221. "deleting every node in a parallel branch as one batch must succeed");
  222. const LadderRung *rung = service.findRung(logic_id, rung_id);
  223. require(rung != nullptr && rung->condition.has_value()
  224. && rung->validate(),
  225. "batch deletion must leave a valid normalized ladder expression");
  226. require(rung->condition->kind == ConditionExpressionKind::Series
  227. && rung->condition->children.size() == 2U
  228. && rung->condition->children.at(0).kind
  229. == ConditionExpressionKind::Node
  230. && rung->condition->children.at(1).kind
  231. == ConditionExpressionKind::Node,
  232. "an empty parallel branch must collapse into the remaining branch");
  233. require(service.findNode(logic_id, second.id) == nullptr
  234. && service.findNode(logic_id, third.id) == nullptr
  235. && service.findNode(logic_id, fourth.id) == nullptr,
  236. "all selected parallel branch nodes must be removed");
  237. require(service.undo().succeeded,
  238. "parallel batch deletion must be undoable");
  239. require(service.findNode(logic_id, second.id) != nullptr
  240. && service.findNode(logic_id, third.id) != nullptr
  241. && service.findNode(logic_id, fourth.id) != nullptr,
  242. "undo must restore every deleted parallel branch node");
  243. }
  244. void testConditionColumnLimit()
  245. {
  246. TestProjectStorage storage;
  247. ProjectService project_service(storage);
  248. LogicEditorService service(project_service);
  249. const std::string logic_id = service.ensureDefaultLogic().id;
  250. const std::string rung_id = service.firstRungId(logic_id);
  251. for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
  252. {
  253. require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded,
  254. "the first ten condition columns must be editable");
  255. }
  256. require(project_service.saveAs("logic-editor-condition-limit.json").succeeded,
  257. "the ten-column network must be saveable before testing overflow");
  258. require(!project_service.isModified(),
  259. "saving the ten-column network must clear the modified state");
  260. const LogicEditorResult overflow = service.appendCondition(
  261. logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns));
  262. require(!overflow.succeeded
  263. && overflow.error == LogicEditorError::InvalidOperation,
  264. "the eleventh condition column must be rejected by the editor service");
  265. require(!project_service.isModified(),
  266. "a failed eleventh-column edit must preserve the saved state");
  267. const LadderRung *rung = service.findRung(logic_id, rung_id);
  268. require(rung != nullptr && rung->condition.has_value()
  269. && rung->condition->kind == ConditionExpressionKind::Series
  270. && rung->condition->children.size()
  271. == static_cast<std::size_t>(
  272. ProjectLimits::kMaximumConditionColumns),
  273. "a rejected eleventh column must leave the ten-column network unchanged");
  274. require(service.setOutput(
  275. logic_id,
  276. rung_id,
  277. CoilNodeConfig{
  278. RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  279. true)
  280. .succeeded
  281. && project_service.isModified(),
  282. "a successful edit must make the project modified again");
  283. require(!service.appendCondition(
  284. logic_id, rung_id,
  285. contact(ProjectLimits::kMaximumConditionColumns))
  286. .succeeded
  287. && project_service.isModified(),
  288. "a failed edit must preserve an existing modified state");
  289. }
  290. void testUnconditionalOutputEditing()
  291. {
  292. TestProjectStorage storage;
  293. ProjectService project_service(storage);
  294. LogicEditorService service(project_service);
  295. const std::string logic_id = service.ensureDefaultLogic().id;
  296. const std::string rung_id = service.firstRungId(logic_id);
  297. require(service.setOutput(
  298. logic_id,
  299. rung_id,
  300. CoilNodeConfig{
  301. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
  302. true)
  303. .succeeded,
  304. "the editor must allow a coil before any condition is added");
  305. const LadderRung *rung = service.findRung(logic_id, rung_id);
  306. require(rung != nullptr && !rung->condition.has_value()
  307. && rung->output.has_value() && rung->validateForRunning(),
  308. "an editor-created output-only network must be runnable as unconditional");
  309. }
  310. void testColumnTargetedConditionInsertion()
  311. {
  312. TestProjectStorage storage;
  313. ProjectService project_service(storage);
  314. LogicEditorService service(project_service);
  315. const std::string logic_id = service.ensureDefaultLogic().id;
  316. const std::string first_rung_id = service.firstRungId(logic_id);
  317. require(service.insertConditionAtColumn(
  318. logic_id, first_rung_id, 4, contact(4)).succeeded,
  319. "an empty network must accept a condition at the selected fifth column");
  320. const LadderRung *rung = service.findRung(logic_id, first_rung_id);
  321. require(rung != nullptr && rung->condition.has_value()
  322. && rung->condition->kind == ConditionExpressionKind::Series
  323. && rung->condition->children.size() == 2U
  324. && rung->condition->children.front().kind
  325. == ConditionExpressionKind::Wire
  326. && rung->condition->children.front().wire->columnSpan == 4
  327. && rung->condition->children.back().kind
  328. == ConditionExpressionKind::Node
  329. && rung->validate()
  330. && conditionColumns(*rung->condition) == 5,
  331. "column insertion must preserve the requested horizontal position");
  332. require(service.insertConditionAtColumn(
  333. logic_id, first_rung_id, 2, contact(2)).error
  334. == LogicEditorError::InvalidOperation,
  335. "inserting into an already occupied column must be rejected atomically");
  336. require(service.insertConditionAtColumn(
  337. logic_id, first_rung_id, 10, contact(10)).error
  338. == LogicEditorError::InvalidOperation,
  339. "the eleventh condition column must be rejected");
  340. require(service.insertConditionAtColumn(
  341. logic_id, first_rung_id, -1, contact(10)).error
  342. == LogicEditorError::InvalidOperation,
  343. "a negative grid column must be rejected");
  344. require(service.insertConditionAtColumn(
  345. logic_id,
  346. first_rung_id,
  347. 5,
  348. CoilNodeConfig{
  349. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  350. .error == LogicEditorError::InvalidNode,
  351. "a condition grid slot must reject output instructions");
  352. const LogicEditorResult second_rung = service.addRung(logic_id);
  353. require(second_rung.succeeded,
  354. "column insertion test must create a second empty network");
  355. require(service.setOutput(
  356. logic_id,
  357. second_rung.id,
  358. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  359. true)
  360. .succeeded,
  361. "an output-only network must be configurable before adding a condition");
  362. require(service.insertConditionAtColumn(
  363. logic_id, second_rung.id, 0, contact(0)).succeeded,
  364. "a selected first grid slot must work on an output-only network");
  365. const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
  366. require(output_rung != nullptr && output_rung->condition.has_value()
  367. && output_rung->condition->kind == ConditionExpressionKind::Node
  368. && output_rung->output.has_value()
  369. && output_rung->validate(),
  370. "condition insertion must keep the independent output slot intact");
  371. const LogicEditorResult last_column_rung = service.addRung(logic_id);
  372. require(last_column_rung.succeeded
  373. && service.insertConditionAtColumn(
  374. logic_id, last_column_rung.id, 9, contact(9)).succeeded,
  375. "the tenth condition column must remain a valid insertion target");
  376. const LadderRung *full_width = service.findRung(
  377. logic_id, last_column_rung.id);
  378. require(full_width != nullptr && full_width->condition.has_value()
  379. && full_width->condition->kind == ConditionExpressionKind::Series
  380. && full_width->condition->children.front().wire->columnSpan == 9
  381. && conditionColumns(*full_width->condition) == 10,
  382. "last-column insertion must fill exactly the ten-column condition area");
  383. require(!service.appendCondition(
  384. logic_id, last_column_rung.id, contact(10)).succeeded
  385. && conditionColumns(*service.findRung(
  386. logic_id, last_column_rung.id)->condition) == 10,
  387. "a full-width grid must reject another condition without partial changes");
  388. }
  389. void testWireColumnReplacement()
  390. {
  391. TestProjectStorage storage;
  392. ProjectService project_service(storage);
  393. LogicEditorService service(project_service);
  394. const std::string logic_id = service.ensureDefaultLogic().id;
  395. const std::string rung_id = service.firstRungId(logic_id);
  396. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
  397. require(wire.succeeded,
  398. "wire-column replacement test must create a four-column wire");
  399. require(service.replaceWireColumnWithCondition(
  400. logic_id, rung_id, wire.id, 1,
  401. ContactNodeConfig{
  402. RegisterAddress{RegisterArea::M, 1},
  403. ContactMode::NormallyClosed})
  404. .succeeded,
  405. "a selected wire cell must be replaceable without removing adjacent cells");
  406. const LadderRung *rung = service.findRung(logic_id, rung_id);
  407. require(rung != nullptr && rung->condition.has_value()
  408. && rung->condition->kind == ConditionExpressionKind::Series
  409. && rung->condition->children.size() == 3U
  410. && rung->condition->children.front().wire->columnSpan == 1
  411. && rung->condition->children.at(1).kind
  412. == ConditionExpressionKind::Node
  413. && std::get<ContactNodeConfig>(
  414. rung->condition->children.at(1).node->config).mode
  415. == ContactMode::NormallyClosed
  416. && rung->condition->children.back().wire->columnSpan == 2
  417. && conditionColumns(*rung->condition) == 4,
  418. "wire-cell replacement must split the wire around the new contact");
  419. const std::vector<ControlLogic> before_invalid =
  420. project_service.project().controlLogics;
  421. require(service.replaceWireColumnWithCondition(
  422. logic_id,
  423. rung_id,
  424. rung->condition->children.front().id,
  425. 1,
  426. contact(2))
  427. .error == LogicEditorError::InvalidOperation,
  428. "an out-of-range wire-cell offset must be rejected");
  429. require(project_service.project().controlLogics.size() == before_invalid.size()
  430. && conditionColumns(*service.findRung(
  431. logic_id, rung_id)->condition) == 4,
  432. "a rejected wire-cell replacement must leave the network width unchanged");
  433. const LogicEditorResult first_cell_rung = service.addRung(logic_id);
  434. const LogicEditorResult first_cell_wire = service.appendWire(
  435. logic_id, first_cell_rung.id, 4);
  436. require(first_cell_rung.succeeded && first_cell_wire.succeeded
  437. && service.replaceWireColumnWithCondition(
  438. logic_id,
  439. first_cell_rung.id,
  440. first_cell_wire.id,
  441. 0,
  442. contact(10))
  443. .succeeded,
  444. "the first cell of a multi-column wire must be replaceable");
  445. const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
  446. require(first_cell != nullptr && first_cell->condition.has_value()
  447. && first_cell->condition->kind == ConditionExpressionKind::Series
  448. && first_cell->condition->children.size() == 2U
  449. && first_cell->condition->children.front().kind
  450. == ConditionExpressionKind::Node
  451. && first_cell->condition->children.back().kind
  452. == ConditionExpressionKind::Wire
  453. && first_cell->condition->children.back().wire->columnSpan == 3,
  454. "first-cell replacement must preserve the trailing wire cells");
  455. require(service.undo().succeeded,
  456. "wire-cell replacement must be one undoable edit");
  457. const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
  458. require(undone != nullptr && undone->condition.has_value()
  459. && undone->condition->kind == ConditionExpressionKind::Wire
  460. && undone->condition->wire->columnSpan == 4,
  461. "undo must restore the original unsplit wire");
  462. require(service.redo().succeeded,
  463. "wire-cell replacement must be redoable");
  464. const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
  465. require(redone != nullptr && redone->condition.has_value()
  466. && redone->condition->kind == ConditionExpressionKind::Series
  467. && conditionColumns(*redone->condition) == 4,
  468. "redo must restore the split wire without changing its width");
  469. const std::string redone_trailing_wire_id =
  470. redone->condition->children.back().id;
  471. const LogicEditorResult last_cell_rung = service.addRung(logic_id);
  472. const LogicEditorResult last_cell_wire = service.appendWire(
  473. logic_id, last_cell_rung.id, 4);
  474. require(last_cell_rung.succeeded && last_cell_wire.succeeded
  475. && service.replaceWireColumnWithCondition(
  476. logic_id,
  477. last_cell_rung.id,
  478. last_cell_wire.id,
  479. 3,
  480. contact(11))
  481. .succeeded,
  482. "the last cell of a multi-column wire must be replaceable");
  483. const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
  484. require(last_cell != nullptr && last_cell->condition.has_value()
  485. && last_cell->condition->kind == ConditionExpressionKind::Series
  486. && last_cell->condition->children.size() == 2U
  487. && last_cell->condition->children.front().kind
  488. == ConditionExpressionKind::Wire
  489. && last_cell->condition->children.front().wire->columnSpan == 3
  490. && last_cell->condition->children.back().kind
  491. == ConditionExpressionKind::Node,
  492. "last-cell replacement must preserve the leading wire cells");
  493. const LogicEditorResult single_cell_rung = service.addRung(logic_id);
  494. const LogicEditorResult single_cell_wire = service.appendWire(
  495. logic_id, single_cell_rung.id, 1);
  496. require(single_cell_rung.succeeded && single_cell_wire.succeeded
  497. && service.replaceWireColumnWithCondition(
  498. logic_id,
  499. single_cell_rung.id,
  500. single_cell_wire.id,
  501. 0,
  502. contact(12))
  503. .succeeded,
  504. "a one-column wire must use the same grid-cell replacement API");
  505. const LadderRung *single_cell = service.findRung(
  506. logic_id, single_cell_rung.id);
  507. require(single_cell != nullptr && single_cell->condition.has_value()
  508. && single_cell->condition->kind == ConditionExpressionKind::Node,
  509. "one-column wire replacement must normalize directly to a condition node");
  510. require(service.replaceWireColumnWithCondition(
  511. logic_id,
  512. first_cell_rung.id,
  513. redone_trailing_wire_id,
  514. 0,
  515. CoilNodeConfig{
  516. RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
  517. .error == LogicEditorError::InvalidNode,
  518. "wire grid cells must reject output instructions");
  519. require(service.replaceWireColumnWithCondition(
  520. logic_id,
  521. first_cell_rung.id,
  522. "missing-wire",
  523. 0,
  524. contact(13))
  525. .error == LogicEditorError::ExpressionNotFound,
  526. "wire grid replacement must reject an unknown wire without mutation");
  527. }
  528. void testSequentialConditionInsertionConsumesFollowingWire()
  529. {
  530. TestProjectStorage storage;
  531. ProjectService project_service(storage);
  532. LogicEditorService service(project_service);
  533. const std::string logic_id = service.ensureDefaultLogic().id;
  534. const std::string rung_id = service.firstRungId(logic_id);
  535. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
  536. require(wire.succeeded,
  537. "sequential wire replacement must start with a full-width wire");
  538. LogicEditorResult inserted = service.replaceWireColumnWithCondition(
  539. logic_id, rung_id, wire.id, 0, contact(0));
  540. require(inserted.succeeded,
  541. "the first condition must replace the first full-wire cell");
  542. std::string selected_node_id = inserted.id;
  543. for (int address = 1; address < 10; ++address)
  544. {
  545. inserted = service.insertConditionAfter(
  546. logic_id, rung_id, selected_node_id, contact(address));
  547. require(inserted.succeeded,
  548. "continuous condition insertion must consume the following wire cell");
  549. selected_node_id = inserted.id;
  550. const LadderRung *rung = service.findRung(logic_id, rung_id);
  551. std::vector<const LogicNode *> nodes;
  552. collectConditionNodes(*rung->condition, &nodes);
  553. require(rung != nullptr && rung->condition.has_value()
  554. && conditionColumns(*rung->condition) == 10
  555. && nodes.size() == static_cast<std::size_t>(address + 1)
  556. && wireColumns(*rung->condition) == 9 - address,
  557. "each continuous insertion must preserve width while consuming one wire cell");
  558. }
  559. const LadderRung *full = service.findRung(logic_id, rung_id);
  560. require(full != nullptr && full->condition.has_value()
  561. && conditionColumns(*full->condition) == 10
  562. && wireColumns(*full->condition) == 0,
  563. "ten continuous insertions must replace the entire wire without expanding it");
  564. require(service.insertConditionAfter(
  565. logic_id, rung_id, selected_node_id, contact(10))
  566. .error == LogicEditorError::InvalidOperation,
  567. "the eleventh condition must still be rejected after all wire cells are consumed");
  568. require(conditionColumns(*service.findRung(
  569. logic_id, rung_id)->condition) == 10,
  570. "a rejected eleventh insertion must leave the full network unchanged");
  571. require(service.undo().succeeded,
  572. "a failed eleventh insertion must not displace the last successful undo step");
  573. const LadderRung *undone = service.findRung(logic_id, rung_id);
  574. std::vector<const LogicNode *> undone_nodes;
  575. collectConditionNodes(*undone->condition, &undone_nodes);
  576. require(undone_nodes.size() == 9U
  577. && wireColumns(*undone->condition) == 1
  578. && conditionColumns(*undone->condition) == 10,
  579. "undo must restore nine contacts followed by one wire cell");
  580. require(service.redo().succeeded,
  581. "the final wire-consuming insertion must be redoable");
  582. const LadderRung *redone = service.findRung(logic_id, rung_id);
  583. std::vector<const LogicNode *> redone_nodes;
  584. collectConditionNodes(*redone->condition, &redone_nodes);
  585. require(redone_nodes.size() == 10U
  586. && wireColumns(*redone->condition) == 0
  587. && conditionColumns(*redone->condition) == 10,
  588. "redo must restore all ten contacts without wire cells");
  589. }
  590. void testLogicLifecycleAndOrdering()
  591. {
  592. TestProjectStorage storage;
  593. ProjectService project_service(storage);
  594. LogicEditorService service(project_service);
  595. const std::string first_id = service.ensureDefaultLogic().id;
  596. const LogicEditorResult second = service.addLogic("Safety logic");
  597. const LogicEditorResult third = service.addLogic("Alarm logic");
  598. require(second.succeeded && third.succeeded,
  599. "multiple control logic modules must be creatable");
  600. require(service.renameLogic(second.id, "Interlock logic").succeeded,
  601. "control logic modules must be renamable by stable id");
  602. require(service.renameLogic(third.id, "Interlock logic").error
  603. == LogicEditorError::DuplicateName,
  604. "control logic names must remain unique");
  605. require(service.moveLogic(third.id, -1).succeeded
  606. && project_service.project().controlLogics.at(1).id == third.id,
  607. "logic scan order must follow editable vector order");
  608. require(service.setLogicEnabled(second.id, false).succeeded
  609. && !service.findLogic(second.id)->enabled,
  610. "a control logic module must support explicit disable and enable");
  611. require(service.setLogicEnabled(second.id, true).succeeded
  612. && service.findLogic(second.id)->enabled,
  613. "a disabled control logic module must be re-enableable");
  614. require(service.removeLogic(third.id).succeeded,
  615. "a non-final control logic module must be deletable");
  616. require(service.removeLogic(second.id).succeeded,
  617. "logic deletion must preserve the remaining module");
  618. require(service.removeLogic(first_id).error
  619. == LogicEditorError::LastLogicRequired,
  620. "the project must retain at least one control logic module");
  621. }
  622. void testEdgeTimerNodesAndRungComments()
  623. {
  624. TestProjectStorage storage;
  625. ProjectService project_service(storage);
  626. LogicEditorService service(project_service);
  627. const std::string logic_id = service.ensureDefaultLogic().id;
  628. const std::string rung_id = service.firstRungId(logic_id);
  629. const LogicEditorResult rising = service.appendCondition(
  630. logic_id,
  631. rung_id,
  632. EdgeContactNodeConfig{
  633. RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising});
  634. require(rising.succeeded && rising.id == "edge-1",
  635. "the editor must create rising edge nodes with a stable prefix");
  636. const LogicEditorResult timer = service.addParallelBranch(
  637. logic_id,
  638. rung_id,
  639. {rising.id},
  640. TimerContactNodeConfig{TimerAddress{2}, ContactMode::NormallyOpen});
  641. require(timer.succeeded && timer.id == "timer-contact-1",
  642. "the editor must insert T contacts as conditions");
  643. const LogicEditorResult ton = service.setOutput(
  644. logic_id, rung_id, TonNodeConfig{TimerAddress{2}, 500});
  645. require(ton.succeeded && ton.id == "ton-1",
  646. "the editor must create TON outputs with a stable prefix");
  647. require(service.updateNodeConfig(
  648. logic_id,
  649. rising.id,
  650. EdgeContactNodeConfig{
  651. RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling})
  652. .succeeded,
  653. "the editor must apply edge mode and M address properties");
  654. require(service.updateNodeConfig(
  655. logic_id,
  656. timer.id,
  657. TimerContactNodeConfig{TimerAddress{4}, ContactMode::NormallyClosed})
  658. .succeeded,
  659. "the editor must apply T contact mode and address properties");
  660. require(service.updateNodeConfig(
  661. logic_id,
  662. ton.id,
  663. TonNodeConfig{TimerAddress{4}, 750})
  664. .succeeded,
  665. "the editor must apply TON preset properties");
  666. require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded,
  667. "the editor must update a network comment by stable rung id");
  668. require(!service.updateRungComment(
  669. logic_id, rung_id, "第一行\n第二行").succeeded,
  670. "the editor must reject multiline network comments");
  671. require(!service.updateRungComment(
  672. logic_id,
  673. rung_id,
  674. std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a'))
  675. .succeeded,
  676. "the editor must reject oversized network comments");
  677. const LadderRung *rung = service.findRung(logic_id, rung_id);
  678. require(rung != nullptr && rung->comment == "延时启动网络"
  679. && std::get<EdgeContactNodeConfig>(
  680. rung->condition->children.front().node->config).mode
  681. == EdgeMode::Falling
  682. && std::get<TimerContactNodeConfig>(
  683. rung->condition->children.at(1).node->config).address
  684. == TimerAddress{4}
  685. && std::get<TonNodeConfig>(rung->output->config).presetMs == 750,
  686. "edge, T contact, TON and rung comment updates must remain in the model");
  687. }
  688. void testHistoryAndAtomicBatchDelete()
  689. {
  690. TestProjectStorage storage;
  691. ProjectService project_service(storage);
  692. LogicEditorService service(project_service);
  693. const std::string logic_id = service.ensureDefaultLogic().id;
  694. const std::string first_rung_id = service.firstRungId(logic_id);
  695. const LogicEditorResult first = service.appendCondition(
  696. logic_id, first_rung_id, contact(0));
  697. const LogicEditorResult second = service.appendCondition(
  698. logic_id, first_rung_id, contact(1));
  699. const LogicEditorResult second_rung = service.addRung(logic_id);
  700. const LogicEditorResult third = service.appendCondition(
  701. logic_id, second_rung.id, contact(2));
  702. require(first.succeeded && second.succeeded && second_rung.succeeded
  703. && third.succeeded,
  704. "nodes for history testing must be created");
  705. service.clearHistory();
  706. require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
  707. "batch node deletion must validate every id before changing the logic");
  708. require(service.findNode(logic_id, first.id) != nullptr
  709. && service.findNode(logic_id, third.id) != nullptr
  710. && !service.canUndo(),
  711. "failed batch node deletion must be atomic and leave history unchanged");
  712. require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
  713. "valid nodes across multiple rungs must be deleted together");
  714. require(service.findNode(logic_id, first.id) == nullptr
  715. && service.findNode(logic_id, third.id) == nullptr,
  716. "all selected nodes must be removed by one batch operation");
  717. require(service.undo().succeeded
  718. && service.findNode(logic_id, first.id) != nullptr
  719. && service.findNode(logic_id, third.id) != nullptr,
  720. "logic undo must restore a cross-rung batch deletion");
  721. require(service.redo().succeeded
  722. && service.findNode(logic_id, first.id) == nullptr
  723. && service.findNode(logic_id, third.id) == nullptr,
  724. "logic redo must reapply a cross-rung batch deletion");
  725. service.clearHistory();
  726. const ControlLogic *logic = service.findLogic(logic_id);
  727. require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
  728. && !service.canUndo(),
  729. "setting an unchanged logic state must not consume history");
  730. service.clearHistory();
  731. for (int index = 1; index <= 101; ++index)
  732. {
  733. require(service.updateRungComment(
  734. logic_id, first_rung_id, "comment-" + std::to_string(index))
  735. .succeeded,
  736. "repeated valid rung edits must succeed");
  737. }
  738. int undo_count = 0;
  739. while (service.undo().succeeded)
  740. {
  741. ++undo_count;
  742. }
  743. require(undo_count == 100,
  744. "logic history must retain exactly the configured 100 most recent steps");
  745. require(service.redo().succeeded,
  746. "logic redo must be available after an undo");
  747. require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
  748. "a new logic edit must succeed after undo");
  749. require(!service.canRedo(),
  750. "a new logic edit must clear the redo history");
  751. (void)second;
  752. }
  753. } // namespace
  754. int main()
  755. {
  756. try
  757. {
  758. testStructuredEditingAndNormalization();
  759. testRangeParallelInsertion();
  760. testStructuredWireEditing();
  761. testBatchDeleteAllNodesInParallelBranch();
  762. testConditionColumnLimit();
  763. testUnconditionalOutputEditing();
  764. testColumnTargetedConditionInsertion();
  765. testWireColumnReplacement();
  766. testSequentialConditionInsertionConsumesFollowingWire();
  767. testLogicLifecycleAndOrdering();
  768. testEdgeTimerNodesAndRungComments();
  769. testHistoryAndAtomicBatchDelete();
  770. }
  771. catch (const std::exception &error)
  772. {
  773. std::cerr << "logic editor service tests failed: " << error.what() << '\n';
  774. return 1;
  775. }
  776. std::cout << "logic editor service tests passed\n";
  777. return 0;
  778. }