综合平台编程器项目的远程存储
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 

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