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

1059 строки
50 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 testWireCellParallelSelectionUsesExactColumns()
  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 source = service.appendWire(
  320. logic_id, rung_id, 3);
  321. require(source.succeeded, "wire-cell parallel setup must create a three-column wire");
  322. const LogicEditorResult branch = service.addParallelWireBranchAtCells(
  323. logic_id,
  324. rung_id,
  325. {{source.id, 0}, {source.id, 1}});
  326. require(branch.succeeded,
  327. "a selected two-cell wire range must create a parallel bypass");
  328. const LadderRung *rung = service.findRung(logic_id, rung_id);
  329. require(rung != nullptr && rung->condition.has_value()
  330. && rung->condition->kind == ConditionExpressionKind::Series
  331. && rung->condition->children.size() == 2U,
  332. "a partial wire selection must preserve the surrounding series layout");
  333. const ConditionExpression &parallel = rung->condition->children.front();
  334. require(parallel.kind == ConditionExpressionKind::Parallel
  335. && parallel.children.size() == 2U
  336. && parallel.children.front().kind == ConditionExpressionKind::Wire
  337. && parallel.children.front().wire->columnSpan == 2
  338. && parallel.children.back().kind == ConditionExpressionKind::Wire
  339. && parallel.children.back().wire->columnSpan == 2
  340. && rung->condition->children.back().kind
  341. == ConditionExpressionKind::Wire
  342. && rung->condition->children.back().wire->columnSpan == 1
  343. && conditionColumns(*rung->condition) == 3,
  344. "the new bypass width must match the selected two cells, not the full source wire");
  345. service.clearHistory();
  346. const bool modified_before = project_service.isModified();
  347. const LogicEditorResult non_contiguous =
  348. service.addParallelWireBranchAtCells(
  349. logic_id,
  350. rung_id,
  351. {{source.id, 0}, {source.id, 2}});
  352. require(!non_contiguous.succeeded
  353. && project_service.isModified() == modified_before
  354. && !service.canUndo(),
  355. "non-contiguous wire-cell selections must fail without mutation");
  356. }
  357. void testWireCellParallelSelectionAcrossAdjacentWires()
  358. {
  359. TestProjectStorage storage;
  360. ProjectService project_service(storage);
  361. LogicEditorService service(project_service);
  362. const std::string logic_id = service.ensureDefaultLogic().id;
  363. const std::string rung_id = makeEmptyRung(service, logic_id);
  364. std::vector<std::string> wire_ids;
  365. for (int index = 0; index < 3; ++index)
  366. {
  367. const LogicEditorResult wire = service.appendWire(logic_id, rung_id);
  368. require(wire.succeeded,
  369. "adjacent wire setup must create each one-column segment");
  370. wire_ids.push_back(wire.id);
  371. }
  372. const LogicEditorResult branch = service.addParallelWireBranchAtCells(
  373. logic_id,
  374. rung_id,
  375. {{wire_ids.at(0), 0}, {wire_ids.at(1), 0}, {wire_ids.at(2), 0}});
  376. require(branch.succeeded,
  377. "visually continuous adjacent wire cells must create one bypass");
  378. const LadderRung *rung = service.findRung(logic_id, rung_id);
  379. require(rung != nullptr && rung->condition.has_value()
  380. && rung->condition->kind == ConditionExpressionKind::Parallel
  381. && rung->condition->children.size() == 2U
  382. && rung->condition->children.front().kind
  383. == ConditionExpressionKind::Wire
  384. && rung->condition->children.front().wire->columnSpan == 3
  385. && rung->condition->children.back().kind
  386. == ConditionExpressionKind::Wire
  387. && rung->condition->children.back().wire->columnSpan == 3,
  388. "three adjacent one-column wires must become a three-column parallel range");
  389. }
  390. void testBatchDeleteAllNodesInParallelBranch()
  391. {
  392. TestProjectStorage storage;
  393. ProjectService project_service(storage);
  394. LogicEditorService service(project_service);
  395. const std::string logic_id = service.ensureDefaultLogic().id;
  396. const std::string rung_id = makeEmptyRung(service, logic_id);
  397. const LogicEditorResult first = service.appendCondition(
  398. logic_id, rung_id, contact(0));
  399. const LogicEditorResult second = service.appendCondition(
  400. logic_id, rung_id, contact(1));
  401. const LogicEditorResult third = service.appendCondition(
  402. logic_id, rung_id, contact(2));
  403. const LogicEditorResult fourth = service.appendCondition(
  404. logic_id, rung_id, contact(3));
  405. require(first.succeeded && second.succeeded && third.succeeded
  406. && fourth.succeeded,
  407. "parallel batch deletion setup contacts must be created");
  408. const LogicEditorResult branch = service.addParallelBranch(
  409. logic_id, rung_id,
  410. {second.id, third.id, fourth.id},
  411. contact(10));
  412. require(branch.succeeded,
  413. "parallel batch deletion setup branch must be created");
  414. require(service.removeNodes(
  415. logic_id, {second.id, third.id, fourth.id})
  416. .succeeded,
  417. "deleting every node in a parallel branch as one batch must succeed");
  418. const LadderRung *rung = service.findRung(logic_id, rung_id);
  419. require(rung != nullptr && rung->condition.has_value()
  420. && rung->validate(),
  421. "batch deletion must leave a valid normalized ladder expression");
  422. require(rung->condition->kind == ConditionExpressionKind::Series
  423. && rung->condition->children.size() == 2U
  424. && rung->condition->children.at(0).kind
  425. == ConditionExpressionKind::Node
  426. && rung->condition->children.at(1).kind
  427. == ConditionExpressionKind::Node,
  428. "an empty parallel branch must collapse into the remaining branch");
  429. require(service.findNode(logic_id, second.id) == nullptr
  430. && service.findNode(logic_id, third.id) == nullptr
  431. && service.findNode(logic_id, fourth.id) == nullptr,
  432. "all selected parallel branch nodes must be removed");
  433. require(service.undo().succeeded,
  434. "parallel batch deletion must be undoable");
  435. require(service.findNode(logic_id, second.id) != nullptr
  436. && service.findNode(logic_id, third.id) != nullptr
  437. && service.findNode(logic_id, fourth.id) != nullptr,
  438. "undo must restore every deleted parallel branch node");
  439. }
  440. void testConditionColumnLimit()
  441. {
  442. TestProjectStorage storage;
  443. ProjectService project_service(storage);
  444. LogicEditorService service(project_service);
  445. const std::string logic_id = service.ensureDefaultLogic().id;
  446. const std::string rung_id = makeEmptyRung(service, logic_id);
  447. for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
  448. {
  449. require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded,
  450. "the first ten condition columns must be editable");
  451. }
  452. require(project_service.saveAs("logic-editor-condition-limit.json").succeeded,
  453. "the ten-column network must be saveable before testing overflow");
  454. require(!project_service.isModified(),
  455. "saving the ten-column network must clear the modified state");
  456. const LogicEditorResult overflow = service.appendCondition(
  457. logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns));
  458. require(!overflow.succeeded
  459. && overflow.error == LogicEditorError::InvalidOperation,
  460. "the eleventh condition column must be rejected by the editor service");
  461. require(!project_service.isModified(),
  462. "a failed eleventh-column edit must preserve the saved state");
  463. const LadderRung *rung = service.findRung(logic_id, rung_id);
  464. require(rung != nullptr && rung->condition.has_value()
  465. && rung->condition->kind == ConditionExpressionKind::Series
  466. && rung->condition->children.size()
  467. == static_cast<std::size_t>(
  468. ProjectLimits::kMaximumConditionColumns),
  469. "a rejected eleventh column must leave the ten-column network unchanged");
  470. require(service.setOutput(
  471. logic_id,
  472. rung_id,
  473. CoilNodeConfig{
  474. RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  475. true)
  476. .succeeded
  477. && project_service.isModified(),
  478. "a successful edit must make the project modified again");
  479. require(!service.appendCondition(
  480. logic_id, rung_id,
  481. contact(ProjectLimits::kMaximumConditionColumns))
  482. .succeeded
  483. && project_service.isModified(),
  484. "a failed edit must preserve an existing modified state");
  485. }
  486. void testUnconditionalOutputEditing()
  487. {
  488. TestProjectStorage storage;
  489. ProjectService project_service(storage);
  490. LogicEditorService service(project_service);
  491. const std::string logic_id = service.ensureDefaultLogic().id;
  492. const std::string rung_id = makeEmptyRung(service, logic_id);
  493. require(service.setOutput(
  494. logic_id,
  495. rung_id,
  496. CoilNodeConfig{
  497. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
  498. true)
  499. .succeeded,
  500. "the editor must allow a coil before any condition is added");
  501. const LadderRung *rung = service.findRung(logic_id, rung_id);
  502. require(rung != nullptr && !rung->condition.has_value()
  503. && rung->output.has_value() && rung->validateForRunning(),
  504. "an editor-created output-only network must be runnable as unconditional");
  505. }
  506. void testColumnTargetedConditionInsertion()
  507. {
  508. TestProjectStorage storage;
  509. ProjectService project_service(storage);
  510. LogicEditorService service(project_service);
  511. const std::string logic_id = service.ensureDefaultLogic().id;
  512. const std::string first_rung_id = makeEmptyRung(service, logic_id);
  513. require(service.insertConditionAtColumn(
  514. logic_id, first_rung_id, 4, contact(4)).succeeded,
  515. "an empty network must accept a condition at the selected fifth column");
  516. const LadderRung *rung = service.findRung(logic_id, first_rung_id);
  517. require(rung != nullptr && rung->condition.has_value()
  518. && rung->condition->kind == ConditionExpressionKind::Series
  519. && rung->condition->children.size() == 2U
  520. && rung->condition->children.front().kind
  521. == ConditionExpressionKind::Wire
  522. && rung->condition->children.front().wire->columnSpan == 4
  523. && rung->condition->children.back().kind
  524. == ConditionExpressionKind::Node
  525. && rung->validate()
  526. && conditionColumns(*rung->condition) == 5,
  527. "column insertion must preserve the requested horizontal position");
  528. require(service.insertConditionAtColumn(
  529. logic_id, first_rung_id, 2, contact(2)).error
  530. == LogicEditorError::InvalidOperation,
  531. "inserting into an already occupied column must be rejected atomically");
  532. require(service.insertConditionAtColumn(
  533. logic_id, first_rung_id, 10, contact(10)).error
  534. == LogicEditorError::InvalidOperation,
  535. "the eleventh condition column must be rejected");
  536. require(service.insertConditionAtColumn(
  537. logic_id, first_rung_id, -1, contact(10)).error
  538. == LogicEditorError::InvalidOperation,
  539. "a negative grid column must be rejected");
  540. require(service.insertConditionAtColumn(
  541. logic_id,
  542. first_rung_id,
  543. 5,
  544. CoilNodeConfig{
  545. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  546. .error == LogicEditorError::InvalidNode,
  547. "a condition grid slot must reject output instructions");
  548. const LogicEditorResult second_rung = service.addRung(logic_id);
  549. require(second_rung.succeeded,
  550. "column insertion test must create a second empty network");
  551. require(service.setOutput(
  552. logic_id,
  553. second_rung.id,
  554. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  555. true)
  556. .succeeded,
  557. "an output-only network must be configurable before adding a condition");
  558. require(service.insertConditionAtColumn(
  559. logic_id, second_rung.id, 0, contact(0)).succeeded,
  560. "a selected first grid slot must work on an output-only network");
  561. const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
  562. require(output_rung != nullptr && output_rung->condition.has_value()
  563. && output_rung->condition->kind == ConditionExpressionKind::Node
  564. && output_rung->output.has_value()
  565. && output_rung->validate(),
  566. "condition insertion must keep the independent output slot intact");
  567. const LogicEditorResult last_column_rung = service.addRung(logic_id);
  568. require(last_column_rung.succeeded
  569. && service.insertConditionAtColumn(
  570. logic_id, last_column_rung.id, 9, contact(9)).succeeded,
  571. "the tenth condition column must remain a valid insertion target");
  572. const LadderRung *full_width = service.findRung(
  573. logic_id, last_column_rung.id);
  574. require(full_width != nullptr && full_width->condition.has_value()
  575. && full_width->condition->kind == ConditionExpressionKind::Series
  576. && full_width->condition->children.front().wire->columnSpan == 9
  577. && conditionColumns(*full_width->condition) == 10,
  578. "last-column insertion must fill exactly the ten-column condition area");
  579. require(!service.appendCondition(
  580. logic_id, last_column_rung.id, contact(10)).succeeded
  581. && conditionColumns(*service.findRung(
  582. logic_id, last_column_rung.id)->condition) == 10,
  583. "a full-width grid must reject another condition without partial changes");
  584. }
  585. void testWireColumnReplacement()
  586. {
  587. TestProjectStorage storage;
  588. ProjectService project_service(storage);
  589. LogicEditorService service(project_service);
  590. const std::string logic_id = service.ensureDefaultLogic().id;
  591. const std::string rung_id = makeEmptyRung(service, logic_id);
  592. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
  593. require(wire.succeeded,
  594. "wire-column replacement test must create a four-column wire");
  595. require(service.replaceWireColumnWithCondition(
  596. logic_id, rung_id, wire.id, 1,
  597. ContactNodeConfig{
  598. RegisterAddress{RegisterArea::M, 1},
  599. ContactMode::NormallyClosed})
  600. .succeeded,
  601. "a selected wire cell must be replaceable without removing adjacent cells");
  602. const LadderRung *rung = service.findRung(logic_id, rung_id);
  603. require(rung != nullptr && rung->condition.has_value()
  604. && rung->condition->kind == ConditionExpressionKind::Series
  605. && rung->condition->children.size() == 3U
  606. && rung->condition->children.front().wire->columnSpan == 1
  607. && rung->condition->children.at(1).kind
  608. == ConditionExpressionKind::Node
  609. && std::get<ContactNodeConfig>(
  610. rung->condition->children.at(1).node->config).mode
  611. == ContactMode::NormallyClosed
  612. && rung->condition->children.back().wire->columnSpan == 2
  613. && conditionColumns(*rung->condition) == 4,
  614. "wire-cell replacement must split the wire around the new contact");
  615. const std::vector<ControlLogic> before_invalid =
  616. project_service.project().controlLogics;
  617. require(service.replaceWireColumnWithCondition(
  618. logic_id,
  619. rung_id,
  620. rung->condition->children.front().id,
  621. 1,
  622. contact(2))
  623. .error == LogicEditorError::InvalidOperation,
  624. "an out-of-range wire-cell offset must be rejected");
  625. require(project_service.project().controlLogics.size() == before_invalid.size()
  626. && conditionColumns(*service.findRung(
  627. logic_id, rung_id)->condition) == 4,
  628. "a rejected wire-cell replacement must leave the network width unchanged");
  629. const LogicEditorResult first_cell_rung = service.addRung(logic_id);
  630. const LogicEditorResult first_cell_wire = service.appendWire(
  631. logic_id, first_cell_rung.id, 4);
  632. require(first_cell_rung.succeeded && first_cell_wire.succeeded
  633. && service.replaceWireColumnWithCondition(
  634. logic_id,
  635. first_cell_rung.id,
  636. first_cell_wire.id,
  637. 0,
  638. contact(10))
  639. .succeeded,
  640. "the first cell of a multi-column wire must be replaceable");
  641. const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
  642. require(first_cell != nullptr && first_cell->condition.has_value()
  643. && first_cell->condition->kind == ConditionExpressionKind::Series
  644. && first_cell->condition->children.size() == 2U
  645. && first_cell->condition->children.front().kind
  646. == ConditionExpressionKind::Node
  647. && first_cell->condition->children.back().kind
  648. == ConditionExpressionKind::Wire
  649. && first_cell->condition->children.back().wire->columnSpan == 3,
  650. "first-cell replacement must preserve the trailing wire cells");
  651. require(service.undo().succeeded,
  652. "wire-cell replacement must be one undoable edit");
  653. const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
  654. require(undone != nullptr && undone->condition.has_value()
  655. && undone->condition->kind == ConditionExpressionKind::Wire
  656. && undone->condition->wire->columnSpan == 4,
  657. "undo must restore the original unsplit wire");
  658. require(service.redo().succeeded,
  659. "wire-cell replacement must be redoable");
  660. const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
  661. require(redone != nullptr && redone->condition.has_value()
  662. && redone->condition->kind == ConditionExpressionKind::Series
  663. && conditionColumns(*redone->condition) == 4,
  664. "redo must restore the split wire without changing its width");
  665. const std::string redone_trailing_wire_id =
  666. redone->condition->children.back().id;
  667. const LogicEditorResult last_cell_rung = service.addRung(logic_id);
  668. const LogicEditorResult last_cell_wire = service.appendWire(
  669. logic_id, last_cell_rung.id, 4);
  670. require(last_cell_rung.succeeded && last_cell_wire.succeeded
  671. && service.replaceWireColumnWithCondition(
  672. logic_id,
  673. last_cell_rung.id,
  674. last_cell_wire.id,
  675. 3,
  676. contact(11))
  677. .succeeded,
  678. "the last cell of a multi-column wire must be replaceable");
  679. const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
  680. require(last_cell != nullptr && last_cell->condition.has_value()
  681. && last_cell->condition->kind == ConditionExpressionKind::Series
  682. && last_cell->condition->children.size() == 2U
  683. && last_cell->condition->children.front().kind
  684. == ConditionExpressionKind::Wire
  685. && last_cell->condition->children.front().wire->columnSpan == 3
  686. && last_cell->condition->children.back().kind
  687. == ConditionExpressionKind::Node,
  688. "last-cell replacement must preserve the leading wire cells");
  689. const LogicEditorResult single_cell_rung = service.addRung(logic_id);
  690. const LogicEditorResult single_cell_wire = service.appendWire(
  691. logic_id, single_cell_rung.id, 1);
  692. require(single_cell_rung.succeeded && single_cell_wire.succeeded
  693. && service.replaceWireColumnWithCondition(
  694. logic_id,
  695. single_cell_rung.id,
  696. single_cell_wire.id,
  697. 0,
  698. contact(12))
  699. .succeeded,
  700. "a one-column wire must use the same grid-cell replacement API");
  701. const LadderRung *single_cell = service.findRung(
  702. logic_id, single_cell_rung.id);
  703. require(single_cell != nullptr && single_cell->condition.has_value()
  704. && single_cell->condition->kind == ConditionExpressionKind::Node,
  705. "one-column wire replacement must normalize directly to a condition node");
  706. require(service.replaceWireColumnWithCondition(
  707. logic_id,
  708. first_cell_rung.id,
  709. redone_trailing_wire_id,
  710. 0,
  711. CoilNodeConfig{
  712. RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
  713. .error == LogicEditorError::InvalidNode,
  714. "wire grid cells must reject output instructions");
  715. require(service.replaceWireColumnWithCondition(
  716. logic_id,
  717. first_cell_rung.id,
  718. "missing-wire",
  719. 0,
  720. contact(13))
  721. .error == LogicEditorError::ExpressionNotFound,
  722. "wire grid replacement must reject an unknown wire without mutation");
  723. }
  724. void testSequentialConditionInsertionConsumesFollowingWire()
  725. {
  726. TestProjectStorage storage;
  727. ProjectService project_service(storage);
  728. LogicEditorService service(project_service);
  729. const std::string logic_id = service.ensureDefaultLogic().id;
  730. const std::string rung_id = makeEmptyRung(service, logic_id);
  731. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
  732. require(wire.succeeded,
  733. "sequential wire replacement must start with a full-width wire");
  734. LogicEditorResult inserted = service.replaceWireColumnWithCondition(
  735. logic_id, rung_id, wire.id, 0, contact(0));
  736. require(inserted.succeeded,
  737. "the first condition must replace the first full-wire cell");
  738. std::string selected_node_id = inserted.id;
  739. for (int address = 1; address < 10; ++address)
  740. {
  741. inserted = service.insertConditionAfter(
  742. logic_id, rung_id, selected_node_id, contact(address));
  743. require(inserted.succeeded,
  744. "continuous condition insertion must consume the following wire cell");
  745. selected_node_id = inserted.id;
  746. const LadderRung *rung = service.findRung(logic_id, rung_id);
  747. std::vector<const LogicNode *> nodes;
  748. collectConditionNodes(*rung->condition, &nodes);
  749. require(rung != nullptr && rung->condition.has_value()
  750. && conditionColumns(*rung->condition) == 10
  751. && nodes.size() == static_cast<std::size_t>(address + 1)
  752. && wireColumns(*rung->condition) == 9 - address,
  753. "each continuous insertion must preserve width while consuming one wire cell");
  754. }
  755. const LadderRung *full = service.findRung(logic_id, rung_id);
  756. require(full != nullptr && full->condition.has_value()
  757. && conditionColumns(*full->condition) == 10
  758. && wireColumns(*full->condition) == 0,
  759. "ten continuous insertions must replace the entire wire without expanding it");
  760. require(service.insertConditionAfter(
  761. logic_id, rung_id, selected_node_id, contact(10))
  762. .error == LogicEditorError::InvalidOperation,
  763. "the eleventh condition must still be rejected after all wire cells are consumed");
  764. require(conditionColumns(*service.findRung(
  765. logic_id, rung_id)->condition) == 10,
  766. "a rejected eleventh insertion must leave the full network unchanged");
  767. require(service.undo().succeeded,
  768. "a failed eleventh insertion must not displace the last successful undo step");
  769. const LadderRung *undone = service.findRung(logic_id, rung_id);
  770. std::vector<const LogicNode *> undone_nodes;
  771. collectConditionNodes(*undone->condition, &undone_nodes);
  772. require(undone_nodes.size() == 9U
  773. && wireColumns(*undone->condition) == 1
  774. && conditionColumns(*undone->condition) == 10,
  775. "undo must restore nine contacts followed by one wire cell");
  776. require(service.redo().succeeded,
  777. "the final wire-consuming insertion must be redoable");
  778. const LadderRung *redone = service.findRung(logic_id, rung_id);
  779. std::vector<const LogicNode *> redone_nodes;
  780. collectConditionNodes(*redone->condition, &redone_nodes);
  781. require(redone_nodes.size() == 10U
  782. && wireColumns(*redone->condition) == 0
  783. && conditionColumns(*redone->condition) == 10,
  784. "redo must restore all ten contacts without wire cells");
  785. }
  786. void testLogicLifecycleAndOrdering()
  787. {
  788. TestProjectStorage storage;
  789. ProjectService project_service(storage);
  790. LogicEditorService service(project_service);
  791. const std::string first_id = service.ensureDefaultLogic().id;
  792. const LogicEditorResult second = service.addLogic("Safety logic");
  793. const LogicEditorResult third = service.addLogic("Alarm logic");
  794. require(second.succeeded && third.succeeded,
  795. "multiple control logic modules must be creatable");
  796. require(service.renameLogic(second.id, "Interlock logic").succeeded,
  797. "control logic modules must be renamable by stable id");
  798. require(service.renameLogic(third.id, "Interlock logic").error
  799. == LogicEditorError::DuplicateName,
  800. "control logic names must remain unique");
  801. require(service.moveLogic(third.id, -1).succeeded
  802. && project_service.project().controlLogics.at(1).id == third.id,
  803. "logic scan order must follow editable vector order");
  804. require(service.setLogicEnabled(second.id, false).succeeded
  805. && !service.findLogic(second.id)->enabled,
  806. "a control logic module must support explicit disable and enable");
  807. require(service.setLogicEnabled(second.id, true).succeeded
  808. && service.findLogic(second.id)->enabled,
  809. "a disabled control logic module must be re-enableable");
  810. require(service.removeLogic(third.id).succeeded,
  811. "a non-final control logic module must be deletable");
  812. require(service.removeLogic(second.id).succeeded,
  813. "logic deletion must preserve the remaining module");
  814. require(service.removeLogic(first_id).error
  815. == LogicEditorError::LastLogicRequired,
  816. "the project must retain at least one control logic module");
  817. }
  818. void testEdgeTimerNodesAndRungComments()
  819. {
  820. TestProjectStorage storage;
  821. ProjectService project_service(storage);
  822. LogicEditorService service(project_service);
  823. const std::string logic_id = service.ensureDefaultLogic().id;
  824. const std::string rung_id = makeEmptyRung(service, logic_id);
  825. const LogicEditorResult rising = service.appendCondition(
  826. logic_id,
  827. rung_id,
  828. EdgeContactNodeConfig{
  829. RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising});
  830. require(rising.succeeded && rising.id == "edge-1",
  831. "the editor must create rising edge nodes with a stable prefix");
  832. const LogicEditorResult timer = service.addParallelBranch(
  833. logic_id,
  834. rung_id,
  835. {rising.id},
  836. TimerContactNodeConfig{TimerAddress{2}, ContactMode::NormallyOpen});
  837. require(timer.succeeded && timer.id == "timer-contact-1",
  838. "the editor must insert T contacts as conditions");
  839. const LogicEditorResult ton = service.setOutput(
  840. logic_id, rung_id, TonNodeConfig{TimerAddress{2}, 500});
  841. require(ton.succeeded && ton.id == "ton-1",
  842. "the editor must create TON outputs with a stable prefix");
  843. require(service.updateNodeConfig(
  844. logic_id,
  845. rising.id,
  846. EdgeContactNodeConfig{
  847. RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling})
  848. .succeeded,
  849. "the editor must apply edge mode and M address properties");
  850. require(service.updateNodeConfig(
  851. logic_id,
  852. timer.id,
  853. TimerContactNodeConfig{TimerAddress{4}, ContactMode::NormallyClosed})
  854. .succeeded,
  855. "the editor must apply T contact mode and address properties");
  856. require(service.updateNodeConfig(
  857. logic_id,
  858. ton.id,
  859. TonNodeConfig{TimerAddress{4}, 750})
  860. .succeeded,
  861. "the editor must apply TON preset properties");
  862. require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded,
  863. "the editor must update a network comment by stable rung id");
  864. require(!service.updateRungComment(
  865. logic_id, rung_id, "第一行\n第二行").succeeded,
  866. "the editor must reject multiline network comments");
  867. require(!service.updateRungComment(
  868. logic_id,
  869. rung_id,
  870. std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a'))
  871. .succeeded,
  872. "the editor must reject oversized network comments");
  873. const LadderRung *rung = service.findRung(logic_id, rung_id);
  874. require(rung != nullptr && rung->comment == "延时启动网络"
  875. && std::get<EdgeContactNodeConfig>(
  876. rung->condition->children.front().node->config).mode
  877. == EdgeMode::Falling
  878. && std::get<TimerContactNodeConfig>(
  879. rung->condition->children.at(1).node->config).address
  880. == TimerAddress{4}
  881. && std::get<TonNodeConfig>(rung->output->config).presetMs == 750,
  882. "edge, T contact, TON and rung comment updates must remain in the model");
  883. }
  884. void testHistoryAndAtomicBatchDelete()
  885. {
  886. TestProjectStorage storage;
  887. ProjectService project_service(storage);
  888. LogicEditorService service(project_service);
  889. const std::string logic_id = service.ensureDefaultLogic().id;
  890. const std::string first_rung_id = makeEmptyRung(service, logic_id);
  891. const LogicEditorResult first = service.appendCondition(
  892. logic_id, first_rung_id, contact(0));
  893. const LogicEditorResult second = service.appendCondition(
  894. logic_id, first_rung_id, contact(1));
  895. const LogicEditorResult second_rung = service.addRung(logic_id);
  896. const LogicEditorResult third = service.appendCondition(
  897. logic_id, second_rung.id, contact(2));
  898. require(first.succeeded && second.succeeded && second_rung.succeeded
  899. && third.succeeded,
  900. "nodes for history testing must be created");
  901. service.clearHistory();
  902. require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
  903. "batch node deletion must validate every id before changing the logic");
  904. require(service.findNode(logic_id, first.id) != nullptr
  905. && service.findNode(logic_id, third.id) != nullptr
  906. && !service.canUndo(),
  907. "failed batch node deletion must be atomic and leave history unchanged");
  908. require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
  909. "valid nodes across multiple rungs must be deleted together");
  910. require(service.findNode(logic_id, first.id) == nullptr
  911. && service.findNode(logic_id, third.id) == nullptr,
  912. "all selected nodes must be removed by one batch operation");
  913. require(service.undo().succeeded
  914. && service.findNode(logic_id, first.id) != nullptr
  915. && service.findNode(logic_id, third.id) != nullptr,
  916. "logic undo must restore a cross-rung batch deletion");
  917. require(service.redo().succeeded
  918. && service.findNode(logic_id, first.id) == nullptr
  919. && service.findNode(logic_id, third.id) == nullptr,
  920. "logic redo must reapply a cross-rung batch deletion");
  921. service.clearHistory();
  922. const ControlLogic *logic = service.findLogic(logic_id);
  923. require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
  924. && !service.canUndo(),
  925. "setting an unchanged logic state must not consume history");
  926. service.clearHistory();
  927. for (int index = 1; index <= 101; ++index)
  928. {
  929. require(service.updateRungComment(
  930. logic_id, first_rung_id, "comment-" + std::to_string(index))
  931. .succeeded,
  932. "repeated valid rung edits must succeed");
  933. }
  934. int undo_count = 0;
  935. while (service.undo().succeeded)
  936. {
  937. ++undo_count;
  938. }
  939. require(undo_count == 100,
  940. "logic history must retain exactly the configured 100 most recent steps");
  941. require(service.redo().succeeded,
  942. "logic redo must be available after an undo");
  943. require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
  944. "a new logic edit must succeed after undo");
  945. require(!service.canRedo(),
  946. "a new logic edit must clear the redo history");
  947. (void)second;
  948. }
  949. } // namespace
  950. int main()
  951. {
  952. try
  953. {
  954. testEmptyLogicCreatesNetworksOnFirstEdit();
  955. testAppendingWireMovesToNextNetworkAfterTenColumns();
  956. testStructuredEditingAndNormalization();
  957. testRangeParallelInsertion();
  958. testParallelBranchGridInsertion();
  959. testStructuredWireEditing();
  960. testWireCellParallelSelectionUsesExactColumns();
  961. testWireCellParallelSelectionAcrossAdjacentWires();
  962. testBatchDeleteAllNodesInParallelBranch();
  963. testConditionColumnLimit();
  964. testUnconditionalOutputEditing();
  965. testColumnTargetedConditionInsertion();
  966. testWireColumnReplacement();
  967. testSequentialConditionInsertionConsumesFollowingWire();
  968. testLogicLifecycleAndOrdering();
  969. testEdgeTimerNodesAndRungComments();
  970. testHistoryAndAtomicBatchDelete();
  971. }
  972. catch (const std::exception &error)
  973. {
  974. std::cerr << "logic editor service tests failed: " << error.what() << '\n';
  975. return 1;
  976. }
  977. std::cout << "logic editor service tests passed\n";
  978. return 0;
  979. }