综合平台编程器项目的远程存储
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

1801 wiersze
86 KiB

  1. #include "domain/project_storage.h"
  2. #include "domain/project_limits.h"
  3. #include "services/logic_editor_service.h"
  4. #include "services/logic_command_service.h"
  5. #include "services/editor_history.h"
  6. #include "services/project_service.h"
  7. #include "support/test_support.h"
  8. #include <algorithm>
  9. #include <iostream>
  10. #include <stdexcept>
  11. namespace {
  12. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  13. using TestSupport::require;
  14. ContactNodeConfig contact(int address)
  15. {
  16. return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
  17. }
  18. std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id)
  19. {
  20. const LogicEditorResult result = service.addRung(logic_id);
  21. require(result.succeeded, "test fixture must create an empty network");
  22. return result.id;
  23. }
  24. int conditionColumns(const ConditionExpression &expression)
  25. {
  26. if (expression.kind == ConditionExpressionKind::Node)
  27. {
  28. return 1;
  29. }
  30. if (expression.kind == ConditionExpressionKind::Wire)
  31. {
  32. return expression.wire->columnSpan;
  33. }
  34. if (expression.kind == ConditionExpressionKind::Gap)
  35. {
  36. return expression.gap->columnSpan;
  37. }
  38. int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
  39. for (const ConditionExpression &child : expression.children)
  40. {
  41. const int child_columns = conditionColumns(child);
  42. columns = expression.kind == ConditionExpressionKind::Series
  43. ? columns + child_columns : std::max(columns, child_columns);
  44. }
  45. return columns;
  46. }
  47. int wireColumns(const ConditionExpression &expression)
  48. {
  49. if (expression.kind == ConditionExpressionKind::Node)
  50. {
  51. return 0;
  52. }
  53. if (expression.kind == ConditionExpressionKind::Wire)
  54. {
  55. return expression.wire->columnSpan;
  56. }
  57. if (expression.kind == ConditionExpressionKind::Gap)
  58. {
  59. return 0;
  60. }
  61. int columns = 0;
  62. for (const ConditionExpression &child : expression.children)
  63. {
  64. columns += wireColumns(child);
  65. }
  66. return columns;
  67. }
  68. int gapColumns(const ConditionExpression &expression)
  69. {
  70. if (expression.kind == ConditionExpressionKind::Gap)
  71. {
  72. return expression.gap->columnSpan;
  73. }
  74. if (expression.kind == ConditionExpressionKind::Node
  75. || expression.kind == ConditionExpressionKind::Wire)
  76. {
  77. return 0;
  78. }
  79. int columns = 0;
  80. for (const ConditionExpression &child : expression.children)
  81. {
  82. columns += gapColumns(child);
  83. }
  84. return columns;
  85. }
  86. int conditionNodes(const ConditionExpression &expression)
  87. {
  88. if (expression.kind == ConditionExpressionKind::Node)
  89. {
  90. return 1;
  91. }
  92. if (expression.kind == ConditionExpressionKind::Wire)
  93. {
  94. return 0;
  95. }
  96. if (expression.kind == ConditionExpressionKind::Gap)
  97. {
  98. return 0;
  99. }
  100. int count = 0;
  101. for (const ConditionExpression &child : expression.children)
  102. {
  103. count += conditionNodes(child);
  104. }
  105. return count;
  106. }
  107. const ConditionExpression *firstExpressionOfKind(
  108. const ConditionExpression &expression, ConditionExpressionKind kind)
  109. {
  110. if (expression.kind == kind)
  111. {
  112. return &expression;
  113. }
  114. for (const ConditionExpression &child : expression.children)
  115. {
  116. if (const ConditionExpression *found = firstExpressionOfKind(child, kind))
  117. {
  118. return found;
  119. }
  120. }
  121. return nullptr;
  122. }
  123. void testLogicCommandParsing()
  124. {
  125. const LogicCommandParseResult load = LogicCommandService::parse("ldi m4000");
  126. require(load.succeeded
  127. && load.command.opcode == LogicCommandOpcode::LoadInverse
  128. && std::get<ContactNodeConfig>(load.command.config).address.index() == 4000
  129. && std::get<ContactNodeConfig>(load.command.config).mode
  130. == ContactMode::NormallyClosed,
  131. "LDI must parse case-insensitively into a configured contact");
  132. const LogicCommandParseResult add = LogicCommandService::parse(
  133. "ADD D0 D1 D4000");
  134. require(add.succeeded
  135. && add.command.opcode == LogicCommandOpcode::Add
  136. && std::get<ArithmeticNodeConfig>(add.command.config)
  137. .destination.index() == 4000,
  138. "ADD must parse three D operands through the shared address rules");
  139. const LogicCommandParseResult compare = LogicCommandService::parse(
  140. "LD<= D12 -3");
  141. require(compare.succeeded
  142. && compare.command.opcode
  143. == LogicCommandOpcode::CompareLessThanOrEqual
  144. && std::get<CompareNodeConfig>(compare.command.config).address.index() == 12
  145. && std::get<CompareNodeConfig>(compare.command.config).value == -3,
  146. "LD comparison commands must parse a D address and signed constant");
  147. const LogicCommandParseResult move_constant = LogicCommandService::parse(
  148. "MOV -7 D20");
  149. require(move_constant.succeeded
  150. && std::get<MoveNodeConfig>(move_constant.command.config).source.kind
  151. == WordOperandKind::Constant
  152. && std::get<MoveNodeConfig>(move_constant.command.config).source.constant == -7,
  153. "MOV must preserve constant sources when an existing output is reopened");
  154. const LogicCommandParseResult add_constant = LogicCommandService::parse(
  155. "ADD D0 1 D2");
  156. require(add_constant.succeeded
  157. && std::get<ArithmeticNodeConfig>(add_constant.command.config).right.kind
  158. == WordOperandKind::Constant,
  159. "ADD must accept constant operands used by the instruction dialog");
  160. require(!LogicCommandService::parse("LD D0").succeeded,
  161. "contact commands must reject D addresses");
  162. require(!LogicCommandService::parse("ADD D0 D1 D4001").succeeded,
  163. "data commands must reject out-of-range D addresses");
  164. require(!LogicCommandService::parse("TON T0").succeeded,
  165. "unsupported commands must be rejected explicitly");
  166. require(!LogicCommandService::parse("OUT M0 M1").succeeded,
  167. "commands with the wrong operand count must be rejected");
  168. TestProjectStorage storage;
  169. ProjectService project_service(storage);
  170. LogicEditorService editor(project_service);
  171. LogicCommandService commands(editor);
  172. const std::string logic_id = editor.ensureDefaultLogic().id;
  173. const std::string rung_id = makeEmptyRung(editor, logic_id);
  174. LogicCommandRequest and_without_load;
  175. and_without_load.logicId = logic_id;
  176. and_without_load.text = "AND M0";
  177. and_without_load.target = {
  178. LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0};
  179. require(!commands.execute(and_without_load).succeeded,
  180. "AND without a preceding LD must be rejected");
  181. }
  182. void testContinuousLogicCommands()
  183. {
  184. TestProjectStorage storage;
  185. ProjectService project_service(storage);
  186. LogicEditorService editor(project_service);
  187. LogicCommandService commands(editor);
  188. const std::string logic_id = editor.ensureDefaultLogic().id;
  189. const std::string rung_id = makeEmptyRung(editor, logic_id);
  190. LogicCommandRequest request;
  191. request.logicId = logic_id;
  192. request.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0};
  193. request.text = "LD M1";
  194. const LogicCommandResult load = commands.execute(request);
  195. require(load.succeeded && load.rungId == rung_id,
  196. "LD on an empty cell must create the first condition in that network");
  197. request.continuing = true;
  198. request.currentRungId = load.rungId;
  199. request.text = "AND M2";
  200. const LogicCommandResult and_result = commands.execute(request);
  201. require(and_result.succeeded,
  202. "AND must append to the current command-input network");
  203. request.text = "OR M3";
  204. const LogicCommandResult or_result = commands.execute(request);
  205. require(or_result.succeeded,
  206. "OR must parallel the complete accumulated condition: "
  207. + or_result.message);
  208. const LadderRung *rung = editor.findRung(logic_id, rung_id);
  209. const ConditionExpression *parallel = firstExpressionOfKind(
  210. *rung->condition, ConditionExpressionKind::Parallel);
  211. require(parallel != nullptr && parallel->children.size() == 2U
  212. && parallel->children.front().kind == ConditionExpressionKind::Series
  213. && conditionNodes(parallel->children.front()) == 2
  214. && conditionNodes(parallel->children.back()) == 1,
  215. "LD M1, AND M2, OR M3 must form (M1 AND M2) OR M3");
  216. request.text = "OUT M4";
  217. const LogicCommandResult output = commands.execute(request);
  218. rung = editor.findRung(logic_id, rung_id);
  219. require(output.succeeded && rung != nullptr && rung->validateForRunning(),
  220. "command-created contacts and output must be configured and runnable");
  221. request.text = "LDI M5";
  222. const LogicCommandResult next_load = commands.execute(request);
  223. require(next_load.succeeded && next_load.rungId != rung_id
  224. && editor.findLogic(logic_id)->rungs.size() == 2U,
  225. "a consecutive LD after an output must create the next network");
  226. }
  227. void testExistingNodeCommandReplacement()
  228. {
  229. TestProjectStorage storage;
  230. ProjectService project_service(storage);
  231. LogicEditorService editor(project_service);
  232. LogicCommandService commands(editor);
  233. const std::string logic_id = editor.ensureDefaultLogic().id;
  234. const std::string rung_id = makeEmptyRung(editor, logic_id);
  235. const LogicEditorResult contact_result = editor.appendCondition(
  236. logic_id, rung_id, contact(10), true);
  237. require(contact_result.succeeded,
  238. "existing-node command fixture must create a contact");
  239. LogicCommandRequest replace_contact;
  240. replace_contact.logicId = logic_id;
  241. replace_contact.text = "LDI M11";
  242. replace_contact.target = {
  243. LogicCommandTargetKind::ExistingNode,
  244. rung_id,
  245. contact_result.id,
  246. 0};
  247. const LogicCommandResult replaced_contact = commands.execute(replace_contact);
  248. const LogicNode *contact_node = editor.findNode(logic_id, contact_result.id);
  249. require(replaced_contact.succeeded && contact_node != nullptr
  250. && std::holds_alternative<ContactNodeConfig>(contact_node->config)
  251. && std::get<ContactNodeConfig>(contact_node->config).address.index() == 11
  252. && std::get<ContactNodeConfig>(contact_node->config).mode
  253. == ContactMode::NormallyClosed,
  254. "an existing contact must accept a load command replacement");
  255. const LogicEditorResult output_result = editor.setOutput(
  256. logic_id,
  257. rung_id,
  258. CoilNodeConfig{
  259. RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  260. true);
  261. require(output_result.succeeded,
  262. "existing-node command fixture must create an output");
  263. LogicCommandRequest replace_output;
  264. replace_output.logicId = logic_id;
  265. replace_output.text = "SET M21";
  266. replace_output.target = {
  267. LogicCommandTargetKind::ExistingNode,
  268. rung_id,
  269. output_result.id,
  270. 0};
  271. const LogicCommandResult replaced_output = commands.execute(replace_output);
  272. const LogicNode *output_node = editor.findNode(logic_id, output_result.id);
  273. require(replaced_output.succeeded && output_node != nullptr
  274. && std::holds_alternative<CoilNodeConfig>(output_node->config)
  275. && std::get<CoilNodeConfig>(output_node->config).address.index() == 21
  276. && std::get<CoilNodeConfig>(output_node->config).mode == CoilMode::Set,
  277. "an existing output must accept an output command replacement");
  278. LogicCommandRequest wrong_type = replace_contact;
  279. wrong_type.text = "OUT M30";
  280. require(!commands.execute(wrong_type).succeeded,
  281. "an existing condition must reject an output command replacement");
  282. }
  283. void testPositionedLogicCommandsAndAtomicFailures()
  284. {
  285. TestProjectStorage storage;
  286. ProjectService project_service(storage);
  287. LogicEditorService editor(project_service);
  288. LogicCommandService commands(editor);
  289. const std::string logic_id = editor.ensureDefaultLogic().id;
  290. const std::string rung_id = makeEmptyRung(editor, logic_id);
  291. const LogicEditorResult first = editor.appendCondition(
  292. logic_id, rung_id, contact(10), true);
  293. const LogicEditorResult second = editor.appendCondition(
  294. logic_id, rung_id, contact(11), true);
  295. LogicCommandRequest selected_or;
  296. selected_or.logicId = logic_id;
  297. selected_or.text = "ORI M12";
  298. selected_or.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 2};
  299. selected_or.parallelNodeIds = {first.id, second.id};
  300. require(commands.execute(selected_or).succeeded,
  301. "ORI on an existing network must use the selected continuous range");
  302. const std::string wire_rung = makeEmptyRung(editor, logic_id);
  303. const LogicEditorResult wire = editor.appendWire(logic_id, wire_rung, 3);
  304. LogicCommandRequest wire_command;
  305. wire_command.logicId = logic_id;
  306. wire_command.text = "LDP M20";
  307. wire_command.target = {
  308. LogicCommandTargetKind::WireColumn, wire_rung, wire.id, 1};
  309. const LogicCommandResult inserted = commands.execute(wire_command);
  310. require(inserted.succeeded
  311. && editor.findNode(logic_id, inserted.id)->isConfigured(),
  312. "a command on a wire cell must replace exactly that cell");
  313. const std::string gap_rung = makeEmptyRung(editor, logic_id);
  314. const LogicEditorResult gap_wire = editor.appendWire(logic_id, gap_rung, 2);
  315. require(editor.disconnectWireCells(logic_id, gap_rung, {{gap_wire.id, 0}}).succeeded,
  316. "gap command fixture must disconnect one wire cell");
  317. const ConditionExpression *gap = firstExpressionOfKind(
  318. *editor.findRung(logic_id, gap_rung)->condition,
  319. ConditionExpressionKind::Gap);
  320. LogicCommandRequest gap_command;
  321. gap_command.logicId = logic_id;
  322. gap_command.text = "LD M21";
  323. gap_command.target = {
  324. LogicCommandTargetKind::GapColumn, gap_rung, gap->id, 0};
  325. require(commands.execute(gap_command).succeeded
  326. && gapColumns(*editor.findRung(logic_id, gap_rung)->condition) == 0,
  327. "a command on a gap cell must repair that exact gap");
  328. editor.clearHistory();
  329. const std::size_t rung_count = editor.findLogic(logic_id)->rungs.size();
  330. LogicCommandRequest invalid = gap_command;
  331. invalid.text = "ADD D0 D1 D4001";
  332. require(!commands.execute(invalid).succeeded
  333. && editor.findLogic(logic_id)->rungs.size() == rung_count
  334. && !editor.canUndo(),
  335. "an invalid command must not change the project or undo history");
  336. LogicCommandRequest wrong_position = gap_command;
  337. wrong_position.text = "OUT M30";
  338. require(!commands.execute(wrong_position).succeeded
  339. && !editor.canUndo(),
  340. "an output command in the condition area must be rejected atomically");
  341. }
  342. void testEmptyLogicCreatesNetworksOnFirstEdit()
  343. {
  344. TestProjectStorage storage;
  345. ProjectService project_service(storage);
  346. LogicEditorService service(project_service);
  347. const std::string logic_id = service.ensureDefaultLogic().id;
  348. require(service.findLogic(logic_id)->rungs.empty(),
  349. "a default control logic must start without an empty network");
  350. const LogicEditorResult first_condition = service.appendCondition(
  351. logic_id, {}, contact(0));
  352. require(first_condition.succeeded
  353. && service.findLogic(logic_id)->rungs.size() == 1U,
  354. "the first condition must create network 1 atomically");
  355. const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front();
  356. require(condition_rung.condition.has_value()
  357. && condition_rung.condition->kind == ConditionExpressionKind::Node,
  358. "the first condition must not create an implicit horizontal wire");
  359. require(service.removeRung(logic_id, condition_rung.id).succeeded
  360. && service.findLogic(logic_id)->rungs.empty(),
  361. "the last network must be removable back to an empty logic");
  362. const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1);
  363. require(first_wire.succeeded
  364. && service.findLogic(logic_id)->rungs.size() == 1U
  365. && service.findLogic(logic_id)->rungs.front().condition->kind
  366. == ConditionExpressionKind::Wire,
  367. "the first horizontal wire must create a one-cell network");
  368. require(service.removeRung(
  369. logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded,
  370. "the wire-only network must be removable");
  371. const LogicEditorResult first_output = service.setOutput(
  372. logic_id,
  373. {},
  374. CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal});
  375. require(first_output.succeeded
  376. && service.findLogic(logic_id)->rungs.size() == 1U
  377. && service.findLogic(logic_id)->rungs.front().condition.has_value()
  378. && service.findLogic(logic_id)->rungs.front().condition->kind
  379. == ConditionExpressionKind::Wire
  380. && service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan
  381. == ProjectLimits::kMaximumConditionColumns
  382. && service.findLogic(logic_id)->rungs.front().output.has_value(),
  383. "the first output must create a full-width explicit wire network");
  384. }
  385. void testBatchRungDeleteIsAtomic()
  386. {
  387. TestProjectStorage storage;
  388. ProjectService project_service(storage);
  389. LogicEditorService service(project_service);
  390. const std::string logic_id = service.ensureDefaultLogic().id;
  391. const std::string first = service.addRung(logic_id).id;
  392. const std::string second = service.addRung(logic_id).id;
  393. const std::string third = service.addRung(logic_id).id;
  394. require(service.findLogic(logic_id)->rungs.size() == 3U,
  395. "the fixture must create three networks");
  396. require(service.removeRungs(logic_id, {first, second}).succeeded
  397. && service.findLogic(logic_id)->rungs.size() == 1U
  398. && service.findRung(logic_id, third) != nullptr,
  399. "a selected group of networks must be deleted together");
  400. require(service.undo().succeeded
  401. && service.findLogic(logic_id)->rungs.size() == 3U,
  402. "undo must restore a batch network deletion as one edit");
  403. service.clearHistory();
  404. require(!service.removeRungs(logic_id, {first, first}).succeeded
  405. && service.findLogic(logic_id)->rungs.size() == 3U
  406. && !service.canUndo(),
  407. "duplicate network selection must fail without mutation or history");
  408. }
  409. void testAppendingWireMovesToNextNetworkAfterTenColumns()
  410. {
  411. TestProjectStorage storage;
  412. ProjectService project_service(storage);
  413. LogicEditorService service(project_service);
  414. const std::string logic_id = service.ensureDefaultLogic().id;
  415. const std::string rung_id = makeEmptyRung(service, logic_id);
  416. for (int index = 0; index < ProjectLimits::kMaximumConditionColumns; ++index)
  417. {
  418. require(service.appendWire(logic_id, rung_id).succeeded,
  419. "ten horizontal wire cells must fit in one network");
  420. }
  421. const LogicEditorResult next = service.appendWire(logic_id, rung_id);
  422. require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U,
  423. "the next appended wire must create the following network");
  424. const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id;
  425. require(service.findRung(logic_id, rung_id)->condition.has_value()
  426. && conditionColumns(*service.findRung(logic_id, rung_id)->condition)
  427. == ProjectLimits::kMaximumConditionColumns
  428. && service.findRung(logic_id, next_rung_id)->condition.has_value(),
  429. "automatic network rollover must preserve both network contents");
  430. require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U,
  431. "automatic network rollover must be undone as one edit");
  432. }
  433. void testAppendingWireReusesDeletedGapCells()
  434. {
  435. TestProjectStorage storage;
  436. ProjectService project_service(storage);
  437. LogicEditorService service(project_service);
  438. const std::string logic_id = service.ensureDefaultLogic().id;
  439. const std::string rung_id = makeEmptyRung(service, logic_id);
  440. const LogicEditorResult full_wire = service.appendWire(
  441. logic_id, rung_id, ProjectLimits::kMaximumConditionColumns);
  442. require(full_wire.succeeded, "gap reuse setup must create a full-width wire");
  443. require(service.disconnectWires(logic_id, rung_id, {full_wire.id}).succeeded,
  444. "deleting the full-width wire must create reusable gaps");
  445. LogicEditorResult current = service.appendWire(logic_id, rung_id);
  446. require(current.succeeded,
  447. "the first appended wire must reuse the first deleted gap cell");
  448. for (int index = 1; index < ProjectLimits::kMaximumConditionColumns; ++index)
  449. {
  450. current = service.insertWireAfter(logic_id, rung_id, current.id);
  451. require(current.succeeded,
  452. "inserting after a selected wire must reuse the next deleted gap cell");
  453. require(service.findLogic(logic_id)->rungs.size() == 1U,
  454. "reusing a gap must not create a new network");
  455. }
  456. const LadderRung *rung = service.findRung(logic_id, rung_id);
  457. require(rung != nullptr && rung->condition.has_value()
  458. && conditionColumns(*rung->condition)
  459. == ProjectLimits::kMaximumConditionColumns
  460. && gapColumns(*rung->condition) == 0,
  461. "all deleted gap cells must be restored as horizontal wires");
  462. }
  463. void testStructuredEditingAndNormalization()
  464. {
  465. TestProjectStorage storage;
  466. ProjectService project_service(storage);
  467. LogicEditorService service(project_service);
  468. const std::string logic_id = service.ensureDefaultLogic().id;
  469. const std::string rung_id = makeEmptyRung(service, logic_id);
  470. const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0));
  471. const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1));
  472. require(first.succeeded && second.succeeded, "series append must succeed");
  473. const LadderRung *rung = service.findRung(logic_id, rung_id);
  474. require(rung->condition->kind == ConditionExpressionKind::Series
  475. && rung->condition->children.size() == 2U,
  476. "two appended nodes must form a series expression");
  477. const std::string second_expression_id = second.id;
  478. const LogicEditorResult parallel = service.addParallelBranch(
  479. logic_id, rung_id, {second_expression_id}, contact(2));
  480. require(parallel.succeeded, "parallel insertion must succeed");
  481. rung = service.findRung(logic_id, rung_id);
  482. const ConditionExpression *parallel_expression = service.findExpression(
  483. logic_id, rung_id, rung->condition->children.at(1).id);
  484. require(parallel_expression != nullptr
  485. && parallel_expression->kind == ConditionExpressionKind::Parallel,
  486. "selected node must become a parallel expression");
  487. const LogicEditorResult nested_series = service.insertConditionAfter(
  488. logic_id,
  489. rung_id,
  490. parallel.id,
  491. contact(3));
  492. require(nested_series.succeeded, "a parallel branch must accept a series node");
  493. rung = service.findRung(logic_id, rung_id);
  494. require(rung->condition->kind == ConditionExpressionKind::Series,
  495. "root must remain a series expression");
  496. const ConditionExpression &nested_parallel_expression = rung->condition->children.at(1);
  497. require(nested_parallel_expression.kind == ConditionExpressionKind::Parallel
  498. && nested_parallel_expression.children.at(1).kind
  499. == ConditionExpressionKind::Series,
  500. "editor must express A AND (B OR (C AND D))");
  501. require(service.removeNode(logic_id, nested_series.id).succeeded,
  502. "nested series node deletion must succeed");
  503. rung = service.findRung(logic_id, rung_id);
  504. require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel
  505. && rung->condition->children.at(1).children.at(1).kind
  506. == ConditionExpressionKind::Series
  507. && gapColumns(*rung->condition) == 1
  508. && conditionColumns(*rung->condition) == 3,
  509. "deleting a nested node must preserve its column as an explicit gap");
  510. require(service.removeNode(logic_id, parallel.id).succeeded,
  511. "parallel leaf deletion must succeed");
  512. rung = service.findRung(logic_id, rung_id);
  513. require(rung->condition->kind == ConditionExpressionKind::Series
  514. && rung->condition->children.size() == 2U,
  515. "deleting a parallel leaf must preserve the surrounding topology");
  516. require(gapColumns(*rung->condition) == 2
  517. && conditionColumns(*rung->condition) == 3,
  518. "each deleted condition must remain as a one-column gap");
  519. require(service.setOutput(
  520. logic_id,
  521. rung_id,
  522. CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  523. .succeeded,
  524. "output coil must be set");
  525. require(service.findLogic(logic_id)->validate(),
  526. "structured editing result must remain a valid draft");
  527. }
  528. void testRangeParallelInsertion()
  529. {
  530. TestProjectStorage storage;
  531. ProjectService project_service(storage);
  532. LogicEditorService service(project_service);
  533. const std::string logic_id = service.ensureDefaultLogic().id;
  534. const std::string rung_id = makeEmptyRung(service, logic_id);
  535. service.appendCondition(logic_id, rung_id, contact(0));
  536. service.appendCondition(logic_id, rung_id, contact(1));
  537. service.appendCondition(logic_id, rung_id, contact(2));
  538. const LogicEditorResult branch = service.addParallelBranch(
  539. logic_id, rung_id, {"contact-2", "contact-3"}, contact(3));
  540. require(branch.succeeded, "a continuous series range must accept a parallel branch");
  541. const ConditionExpression &root = *service.findRung(logic_id, rung_id)->condition;
  542. require(root.kind == ConditionExpressionKind::Series
  543. && root.children.size() == 2U
  544. && root.children.at(1).kind == ConditionExpressionKind::Parallel
  545. && root.children.at(1).children.front().kind
  546. == ConditionExpressionKind::Series,
  547. "range insertion must express A AND ((B AND C) OR D)");
  548. require(root.validate(), "range insertion must preserve normalized topology");
  549. const LogicEditorResult invalid = service.addParallelBranch(
  550. logic_id, rung_id, {"contact-1", "contact-3"}, contact(4));
  551. require(!invalid.succeeded
  552. && invalid.error == LogicEditorError::InvalidOperation,
  553. "a non-contiguous selection must be rejected");
  554. }
  555. void testParallelBranchGridInsertion()
  556. {
  557. TestProjectStorage storage;
  558. ProjectService project_service(storage);
  559. LogicEditorService service(project_service);
  560. const std::string logic_id = service.ensureDefaultLogic().id;
  561. const std::string rung_id = makeEmptyRung(service, logic_id);
  562. const LogicEditorResult first = service.appendCondition(
  563. logic_id, rung_id, contact(0));
  564. const LogicEditorResult second = service.appendCondition(
  565. logic_id, rung_id, contact(1));
  566. const LogicEditorResult third = service.appendCondition(
  567. logic_id, rung_id, contact(2));
  568. const LogicEditorResult branch = service.addParallelBranch(
  569. logic_id, rung_id, {first.id, second.id, third.id}, contact(10));
  570. require(branch.succeeded,
  571. "parallel grid insertion setup must create a short lower branch");
  572. service.clearHistory();
  573. const LadderRung *padded_rung = service.findRung(logic_id, rung_id);
  574. const ConditionExpression &padded_lower = padded_rung->condition->children.at(1);
  575. require(padded_lower.kind == ConditionExpressionKind::Series
  576. && padded_lower.children.size() == 2U
  577. && padded_lower.children.back().kind == ConditionExpressionKind::Wire
  578. && padded_lower.children.back().wire->columnSpan == 2,
  579. "a short parallel branch must persist its two padding cells as a wire");
  580. const LogicEditorResult inserted = service.replaceWireColumnWithCondition(
  581. logic_id,
  582. rung_id,
  583. padded_lower.children.back().id,
  584. 1,
  585. contact(12));
  586. require(inserted.succeeded,
  587. "a persisted parallel branch wire cell must accept a condition");
  588. const LadderRung *rung = service.findRung(logic_id, rung_id);
  589. require(rung != nullptr && rung->condition.has_value()
  590. && rung->condition->kind == ConditionExpressionKind::Parallel,
  591. "branch grid insertion must preserve the surrounding parallel expression");
  592. const ConditionExpression &lower = rung->condition->children.at(1);
  593. require(lower.kind == ConditionExpressionKind::Series
  594. && lower.children.size() == 3U
  595. && lower.children.at(0).node->id == branch.id
  596. && lower.children.at(1).kind == ConditionExpressionKind::Wire
  597. && lower.children.at(1).wire->columnSpan == 1
  598. && lower.children.at(2).node->id == inserted.id,
  599. "a distant branch cell must persist only the required gap and new condition");
  600. require(service.undo().succeeded,
  601. "parallel branch grid insertion must be one undoable edit");
  602. rung = service.findRung(logic_id, rung_id);
  603. require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series
  604. && rung->condition->children.at(1).children.front().node->id == branch.id
  605. && rung->condition->children.at(1).children.back().kind
  606. == ConditionExpressionKind::Wire
  607. && rung->condition->children.at(1).children.back().wire->columnSpan == 2,
  608. "undo must restore the original branch and its explicit padding wire");
  609. service.clearHistory();
  610. const bool modified_before = project_service.isModified();
  611. const LogicEditorResult invalid = service.insertConditionInBranchAtColumn(
  612. logic_id, rung_id, branch.id, 3, contact(13));
  613. require(!invalid.succeeded && !service.canUndo()
  614. && project_service.isModified() == modified_before
  615. && service.findNode(logic_id, inserted.id) == nullptr,
  616. "a cell outside the visible branch padding must fail atomically");
  617. }
  618. void testStructuredWireEditing()
  619. {
  620. TestProjectStorage storage;
  621. ProjectService project_service(storage);
  622. LogicEditorService service(project_service);
  623. const std::string logic_id = service.ensureDefaultLogic().id;
  624. const std::string rung_id = makeEmptyRung(service, logic_id);
  625. service.appendCondition(logic_id, rung_id, contact(0));
  626. service.appendCondition(logic_id, rung_id, contact(1));
  627. service.appendCondition(logic_id, rung_id, contact(2));
  628. const LogicEditorResult branch = service.addParallelWireBranch(
  629. logic_id, rung_id, {"contact-2", "contact-3"});
  630. require(branch.succeeded && branch.id == "wire-1",
  631. "a continuous range must accept a structured horizontal bypass");
  632. const LadderRung *rung = service.findRung(logic_id, rung_id);
  633. require(rung->condition->kind == ConditionExpressionKind::Series
  634. && rung->condition->children.at(1).kind
  635. == ConditionExpressionKind::Parallel,
  636. "a vertical connection must produce a parallel expression");
  637. const ConditionExpression &wire =
  638. rung->condition->children.at(1).children.at(1);
  639. require(wire.kind == ConditionExpressionKind::Wire
  640. && wire.wire->columnSpan == 2,
  641. "the bypass wire span must match the selected two-column range");
  642. const LogicEditorResult replacement = service.replaceWireWithCondition(
  643. logic_id, rung_id, branch.id, contact(3));
  644. require(replacement.succeeded && replacement.id == "contact-4",
  645. "a selected wire must be replaceable by a configured node type");
  646. const LogicEditorResult extension = service.insertWireAfter(
  647. logic_id, rung_id, replacement.id);
  648. require(extension.succeeded && extension.id == "wire-2",
  649. "replacing one cell of a multi-cell wire must retain the original id on the remaining cell");
  650. rung = service.findRung(logic_id, rung_id);
  651. const ConditionExpression *extended_branch = service.findExpression(
  652. logic_id, rung_id, rung->condition->children.at(1).children.at(1).id);
  653. require(extended_branch != nullptr
  654. && extended_branch->kind == ConditionExpressionKind::Series
  655. && extended_branch->children.at(1).kind
  656. == ConditionExpressionKind::Wire,
  657. "inserting a horizontal wire after a branch node must preserve structure");
  658. const std::string extended_branch_id = extended_branch->id;
  659. require(!service.addParallelWireBranch(
  660. logic_id, rung_id, {"contact-1", "contact-3"}).succeeded,
  661. "non-contiguous wire connection targets must be rejected");
  662. require(service.removeExpressions(
  663. logic_id, rung_id, {extended_branch_id}).succeeded,
  664. "deleting a selected vertical connection branch must succeed atomically");
  665. rung = service.findRung(logic_id, rung_id);
  666. require(rung->condition.has_value()
  667. && conditionNodes(*rung->condition) == 3
  668. && conditionColumns(*rung->condition) == 4
  669. && wireColumns(*rung->condition) == 1
  670. && service.findNode(logic_id, replacement.id) == nullptr,
  671. "removing a bypass branch must keep the materialized padding wire without reconnecting positions");
  672. require(service.undo().succeeded
  673. && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr,
  674. "wire branch deletion must participate in ladder undo history");
  675. }
  676. void testWireCellParallelSelectionUsesExactColumns()
  677. {
  678. TestProjectStorage storage;
  679. ProjectService project_service(storage);
  680. LogicEditorService service(project_service);
  681. const std::string logic_id = service.ensureDefaultLogic().id;
  682. const std::string rung_id = makeEmptyRung(service, logic_id);
  683. const LogicEditorResult source = service.appendWire(
  684. logic_id, rung_id, 3);
  685. require(source.succeeded, "wire-cell parallel setup must create a three-column wire");
  686. const LogicEditorResult branch = service.addParallelWireBranchAtCells(
  687. logic_id,
  688. rung_id,
  689. {{source.id, 0}, {source.id, 1}});
  690. require(branch.succeeded,
  691. "a selected two-cell wire range must create a parallel bypass");
  692. const LadderRung *rung = service.findRung(logic_id, rung_id);
  693. require(rung != nullptr && rung->condition.has_value()
  694. && rung->condition->kind == ConditionExpressionKind::Series
  695. && rung->condition->children.size() == 2U,
  696. "a partial wire selection must preserve the surrounding series layout");
  697. const ConditionExpression &parallel = rung->condition->children.front();
  698. require(parallel.kind == ConditionExpressionKind::Parallel
  699. && parallel.children.size() == 2U
  700. && parallel.children.front().kind == ConditionExpressionKind::Wire
  701. && parallel.children.front().wire->columnSpan == 2
  702. && parallel.children.back().kind == ConditionExpressionKind::Wire
  703. && parallel.children.back().wire->columnSpan == 2
  704. && rung->condition->children.back().kind
  705. == ConditionExpressionKind::Wire
  706. && rung->condition->children.back().wire->columnSpan == 1
  707. && conditionColumns(*rung->condition) == 3,
  708. "the new bypass width must match the selected two cells, not the full source wire");
  709. service.clearHistory();
  710. const bool modified_before = project_service.isModified();
  711. const LogicEditorResult non_contiguous =
  712. service.addParallelWireBranchAtCells(
  713. logic_id,
  714. rung_id,
  715. {{source.id, 0}, {source.id, 2}});
  716. require(!non_contiguous.succeeded
  717. && project_service.isModified() == modified_before
  718. && !service.canUndo(),
  719. "non-contiguous wire-cell selections must fail without mutation");
  720. }
  721. void testWireCellParallelSelectionAcrossAdjacentWires()
  722. {
  723. TestProjectStorage storage;
  724. ProjectService project_service(storage);
  725. LogicEditorService service(project_service);
  726. const std::string logic_id = service.ensureDefaultLogic().id;
  727. const std::string rung_id = makeEmptyRung(service, logic_id);
  728. std::vector<std::string> wire_ids;
  729. for (int index = 0; index < 3; ++index)
  730. {
  731. const LogicEditorResult wire = service.appendWire(logic_id, rung_id);
  732. require(wire.succeeded,
  733. "adjacent wire setup must create each one-column segment");
  734. wire_ids.push_back(wire.id);
  735. }
  736. const LogicEditorResult branch = service.addParallelWireBranchAtCells(
  737. logic_id,
  738. rung_id,
  739. {{wire_ids.at(0), 0}, {wire_ids.at(1), 0}, {wire_ids.at(2), 0}});
  740. require(branch.succeeded,
  741. "visually continuous adjacent wire cells must create one bypass");
  742. const LadderRung *rung = service.findRung(logic_id, rung_id);
  743. require(rung != nullptr && rung->condition.has_value()
  744. && rung->condition->kind == ConditionExpressionKind::Parallel
  745. && rung->condition->children.size() == 2U
  746. && rung->condition->children.front().kind
  747. == ConditionExpressionKind::Wire
  748. && rung->condition->children.front().wire->columnSpan == 3
  749. && rung->condition->children.back().kind
  750. == ConditionExpressionKind::Wire
  751. && rung->condition->children.back().wire->columnSpan == 3,
  752. "three adjacent one-column wires must become a three-column parallel range");
  753. }
  754. void testBatchDeleteAllNodesInParallelBranch()
  755. {
  756. TestProjectStorage storage;
  757. ProjectService project_service(storage);
  758. LogicEditorService service(project_service);
  759. const std::string logic_id = service.ensureDefaultLogic().id;
  760. const std::string rung_id = makeEmptyRung(service, logic_id);
  761. const LogicEditorResult first = service.appendCondition(
  762. logic_id, rung_id, contact(0));
  763. const LogicEditorResult second = service.appendCondition(
  764. logic_id, rung_id, contact(1));
  765. const LogicEditorResult third = service.appendCondition(
  766. logic_id, rung_id, contact(2));
  767. const LogicEditorResult fourth = service.appendCondition(
  768. logic_id, rung_id, contact(3));
  769. require(first.succeeded && second.succeeded && third.succeeded
  770. && fourth.succeeded,
  771. "parallel batch deletion setup contacts must be created");
  772. const LogicEditorResult branch = service.addParallelBranch(
  773. logic_id, rung_id,
  774. {second.id, third.id, fourth.id},
  775. contact(10));
  776. require(branch.succeeded,
  777. "parallel batch deletion setup branch must be created");
  778. require(service.removeNodes(
  779. logic_id, {second.id, third.id, fourth.id})
  780. .succeeded,
  781. "deleting every node in a parallel branch as one batch must succeed");
  782. const LadderRung *rung = service.findRung(logic_id, rung_id);
  783. require(rung != nullptr && rung->condition.has_value()
  784. && rung->validate(),
  785. "batch deletion must leave a valid normalized ladder expression");
  786. require(rung->condition->kind == ConditionExpressionKind::Series
  787. && rung->condition->children.size() == 2U
  788. && rung->condition->children.at(0).kind
  789. == ConditionExpressionKind::Node
  790. && rung->condition->children.at(1).kind
  791. == ConditionExpressionKind::Parallel
  792. && conditionNodes(*rung->condition) == 2
  793. && gapColumns(*rung->condition) == 3
  794. && conditionColumns(*rung->condition) == 4,
  795. "deleted parallel conditions must remain as gaps without collapsing the branch");
  796. require(service.findNode(logic_id, second.id) == nullptr
  797. && service.findNode(logic_id, third.id) == nullptr
  798. && service.findNode(logic_id, fourth.id) == nullptr,
  799. "all selected parallel branch nodes must be removed");
  800. require(service.undo().succeeded,
  801. "parallel batch deletion must be undoable");
  802. require(service.findNode(logic_id, second.id) != nullptr
  803. && service.findNode(logic_id, third.id) != nullptr
  804. && service.findNode(logic_id, fourth.id) != nullptr,
  805. "undo must restore every deleted parallel branch node");
  806. }
  807. void testConditionColumnLimit()
  808. {
  809. TestProjectStorage storage;
  810. ProjectService project_service(storage);
  811. LogicEditorService service(project_service);
  812. const std::string logic_id = service.ensureDefaultLogic().id;
  813. const std::string rung_id = makeEmptyRung(service, logic_id);
  814. for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
  815. {
  816. require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded,
  817. "the first ten condition columns must be editable");
  818. }
  819. require(project_service.saveAs("logic-editor-condition-limit.json").succeeded,
  820. "the ten-column network must be saveable before testing overflow");
  821. require(!project_service.isModified(),
  822. "saving the ten-column network must clear the modified state");
  823. const LogicEditorResult overflow = service.appendCondition(
  824. logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns));
  825. require(!overflow.succeeded
  826. && overflow.error == LogicEditorError::InvalidOperation,
  827. "the eleventh condition column must be rejected by the editor service");
  828. require(!project_service.isModified(),
  829. "a failed eleventh-column edit must preserve the saved state");
  830. const LadderRung *rung = service.findRung(logic_id, rung_id);
  831. require(rung != nullptr && rung->condition.has_value()
  832. && rung->condition->kind == ConditionExpressionKind::Series
  833. && rung->condition->children.size()
  834. == static_cast<std::size_t>(
  835. ProjectLimits::kMaximumConditionColumns),
  836. "a rejected eleventh column must leave the ten-column network unchanged");
  837. require(service.setOutput(
  838. logic_id,
  839. rung_id,
  840. CoilNodeConfig{
  841. RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  842. true)
  843. .succeeded
  844. && project_service.isModified(),
  845. "a successful edit must make the project modified again");
  846. require(!service.appendCondition(
  847. logic_id, rung_id,
  848. contact(ProjectLimits::kMaximumConditionColumns))
  849. .succeeded
  850. && project_service.isModified(),
  851. "a failed edit must preserve an existing modified state");
  852. }
  853. void testUnconditionalOutputEditing()
  854. {
  855. TestProjectStorage storage;
  856. ProjectService project_service(storage);
  857. LogicEditorService service(project_service);
  858. const std::string logic_id = service.ensureDefaultLogic().id;
  859. const std::string rung_id = makeEmptyRung(service, logic_id);
  860. require(service.setOutput(
  861. logic_id,
  862. rung_id,
  863. CoilNodeConfig{
  864. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
  865. true)
  866. .succeeded,
  867. "the editor must allow a coil before any condition is added");
  868. const LadderRung *rung = service.findRung(logic_id, rung_id);
  869. require(rung != nullptr && rung->condition.has_value()
  870. && rung->condition->kind == ConditionExpressionKind::Wire
  871. && rung->condition->wire->columnSpan
  872. == ProjectLimits::kMaximumConditionColumns
  873. && rung->output.has_value() && rung->validateForRunning(),
  874. "an editor-created unconditional network must use a full-width wire");
  875. const std::string contact_rung_id = makeEmptyRung(service, logic_id);
  876. const LogicEditorResult contact_result = service.appendCondition(
  877. logic_id, contact_rung_id, contact(0));
  878. require(contact_result.succeeded
  879. && service.updateNodeConfig(
  880. logic_id, contact_result.id, contact(0)).succeeded
  881. && service.setOutput(
  882. logic_id,
  883. contact_rung_id,
  884. CoilNodeConfig{
  885. RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal},
  886. true).succeeded,
  887. "a contact followed by an output must be editable");
  888. const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id);
  889. require(contact_rung != nullptr && contact_rung->condition.has_value()
  890. && conditionNodes(*contact_rung->condition) == 1
  891. && wireColumns(*contact_rung->condition) == 9
  892. && conditionColumns(*contact_rung->condition) == 10
  893. && contact_rung->validateForRunning(),
  894. "a contact followed by an output must persist the remaining nine wire cells");
  895. }
  896. void testExplicitGapEditing()
  897. {
  898. TestProjectStorage storage;
  899. ProjectService project_service(storage);
  900. LogicEditorService service(project_service);
  901. const std::string logic_id = service.ensureDefaultLogic().id;
  902. const std::string rung_id = makeEmptyRung(service, logic_id);
  903. require(service.setOutput(
  904. logic_id,
  905. rung_id,
  906. CoilNodeConfig{
  907. RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal},
  908. true).succeeded,
  909. "gap editing setup must create an explicit full-width wire");
  910. const LadderRung *rung = service.findRung(logic_id, rung_id);
  911. const std::string full_wire_id = rung->condition->id;
  912. require(service.disconnectWireCells(
  913. logic_id, rung_id, {{full_wire_id, 4}}).succeeded,
  914. "deleting one wire cell must create a gap");
  915. rung = service.findRung(logic_id, rung_id);
  916. require(rung->validate() && !rung->validateForRunning()
  917. && conditionColumns(*rung->condition) == 10
  918. && wireColumns(*rung->condition) == 9
  919. && gapColumns(*rung->condition) == 1,
  920. "a disconnected wire cell must preserve width and block runtime");
  921. const ConditionExpression *gap = firstExpressionOfKind(
  922. *rung->condition, ConditionExpressionKind::Gap);
  923. require(gap != nullptr, "the disconnected cell must expose a gap expression");
  924. const std::string first_gap_id = gap->id;
  925. require(service.replaceGapColumnWithWire(
  926. logic_id, rung_id, first_gap_id, 0).succeeded,
  927. "a gap cell must be repairable with a real wire");
  928. rung = service.findRung(logic_id, rung_id);
  929. require(gapColumns(*rung->condition) == 0
  930. && wireColumns(*rung->condition) == 10
  931. && rung->validateForRunning(),
  932. "repairing the gap with a wire must restore a runnable path");
  933. const ConditionExpression *wire = firstExpressionOfKind(
  934. *rung->condition, ConditionExpressionKind::Wire);
  935. require(wire != nullptr, "the repaired path must expose a wire expression");
  936. const std::string repaired_wire_id = wire->id;
  937. require(service.disconnectWireCells(
  938. logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded,
  939. "a repaired wire cell must be disconnectable again");
  940. rung = service.findRung(logic_id, rung_id);
  941. gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap);
  942. require(gap != nullptr, "the second disconnection must expose a gap expression");
  943. const std::string second_gap_id = gap->id;
  944. const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition(
  945. logic_id, rung_id, second_gap_id, 0, contact(31));
  946. require(inserted_result.succeeded
  947. && service.updateNodeConfig(
  948. logic_id, inserted_result.id, contact(31)).succeeded,
  949. "a gap cell must be replaceable with a contact");
  950. rung = service.findRung(logic_id, rung_id);
  951. const ConditionExpression *inserted = firstExpressionOfKind(
  952. *rung->condition, ConditionExpressionKind::Node);
  953. require(inserted != nullptr && gapColumns(*rung->condition) == 0
  954. && conditionNodes(*rung->condition) == 1
  955. && conditionColumns(*rung->condition) == 10
  956. && rung->validateForRunning(),
  957. "a contact inserted into a gap must preserve the ten-column path");
  958. const std::string inserted_node_id = inserted->node->id;
  959. require(service.removeNode(logic_id, inserted_node_id).succeeded,
  960. "the inserted contact must be removable");
  961. rung = service.findRung(logic_id, rung_id);
  962. require(conditionNodes(*rung->condition) == 0
  963. && gapColumns(*rung->condition) == 1
  964. && conditionColumns(*rung->condition) == 10
  965. && !rung->validateForRunning(),
  966. "deleting a contact must restore a gap instead of reconnecting the path");
  967. const std::string multi_rung_id = makeEmptyRung(service, logic_id);
  968. require(service.setOutput(
  969. logic_id,
  970. multi_rung_id,
  971. CoilNodeConfig{
  972. RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal},
  973. true).succeeded,
  974. "multi-gap setup must create a full-width wire");
  975. const std::string multi_wire_id = service.findRung(
  976. logic_id, multi_rung_id)->condition->id;
  977. require(service.disconnectWireCells(
  978. logic_id,
  979. multi_rung_id,
  980. {{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded,
  981. "multiple cells in one wire must disconnect atomically");
  982. const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id);
  983. require(conditionColumns(*multi_rung->condition) == 10
  984. && wireColumns(*multi_rung->condition) == 8
  985. && gapColumns(*multi_rung->condition) == 2
  986. && !multi_rung->validateForRunning(),
  987. "multiple disconnected cells must retain both explicit gaps");
  988. }
  989. void testColumnTargetedConditionInsertion()
  990. {
  991. TestProjectStorage storage;
  992. ProjectService project_service(storage);
  993. LogicEditorService service(project_service);
  994. const std::string logic_id = service.ensureDefaultLogic().id;
  995. const std::string first_rung_id = makeEmptyRung(service, logic_id);
  996. require(service.insertConditionAtColumn(
  997. logic_id, first_rung_id, 4, contact(4)).succeeded,
  998. "an empty network must accept a condition at the selected fifth column");
  999. const LadderRung *rung = service.findRung(logic_id, first_rung_id);
  1000. require(rung != nullptr && rung->condition.has_value()
  1001. && rung->condition->kind == ConditionExpressionKind::Series
  1002. && rung->condition->children.size() == 2U
  1003. && rung->condition->children.front().kind
  1004. == ConditionExpressionKind::Wire
  1005. && rung->condition->children.front().wire->columnSpan == 4
  1006. && rung->condition->children.back().kind
  1007. == ConditionExpressionKind::Node
  1008. && rung->validate()
  1009. && conditionColumns(*rung->condition) == 5,
  1010. "column insertion must preserve the requested horizontal position");
  1011. require(service.insertConditionAtColumn(
  1012. logic_id, first_rung_id, 2, contact(2)).error
  1013. == LogicEditorError::InvalidOperation,
  1014. "inserting into an already occupied column must be rejected atomically");
  1015. require(service.insertConditionAtColumn(
  1016. logic_id, first_rung_id, 10, contact(10)).error
  1017. == LogicEditorError::InvalidOperation,
  1018. "the eleventh condition column must be rejected");
  1019. require(service.insertConditionAtColumn(
  1020. logic_id, first_rung_id, -1, contact(10)).error
  1021. == LogicEditorError::InvalidOperation,
  1022. "a negative grid column must be rejected");
  1023. require(service.insertConditionAtColumn(
  1024. logic_id,
  1025. first_rung_id,
  1026. 5,
  1027. CoilNodeConfig{
  1028. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
  1029. .error == LogicEditorError::InvalidNode,
  1030. "a condition grid slot must reject output instructions");
  1031. const LogicEditorResult second_rung = service.addRung(logic_id);
  1032. require(second_rung.succeeded,
  1033. "column insertion test must create a second empty network");
  1034. require(service.setOutput(
  1035. logic_id,
  1036. second_rung.id,
  1037. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  1038. true)
  1039. .succeeded,
  1040. "an output-only network must be configurable before adding a condition");
  1041. const std::string output_wire_id = service.findRung(
  1042. logic_id, second_rung.id)->condition->id;
  1043. require(service.replaceWireColumnWithCondition(
  1044. logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded,
  1045. "the first wire cell must accept a contact on an unconditional network");
  1046. const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
  1047. require(output_rung != nullptr && output_rung->condition.has_value()
  1048. && output_rung->condition->kind == ConditionExpressionKind::Series
  1049. && conditionNodes(*output_rung->condition) == 1
  1050. && wireColumns(*output_rung->condition) == 9
  1051. && conditionColumns(*output_rung->condition) == 10
  1052. && output_rung->output.has_value()
  1053. && output_rung->validate(),
  1054. "condition insertion must keep the independent output slot intact");
  1055. const LogicEditorResult last_column_rung = service.addRung(logic_id);
  1056. require(last_column_rung.succeeded
  1057. && service.insertConditionAtColumn(
  1058. logic_id, last_column_rung.id, 9, contact(9)).succeeded,
  1059. "the tenth condition column must remain a valid insertion target");
  1060. const LadderRung *full_width = service.findRung(
  1061. logic_id, last_column_rung.id);
  1062. require(full_width != nullptr && full_width->condition.has_value()
  1063. && full_width->condition->kind == ConditionExpressionKind::Series
  1064. && full_width->condition->children.front().wire->columnSpan == 9
  1065. && conditionColumns(*full_width->condition) == 10,
  1066. "last-column insertion must fill exactly the ten-column condition area");
  1067. require(!service.appendCondition(
  1068. logic_id, last_column_rung.id, contact(10)).succeeded
  1069. && conditionColumns(*service.findRung(
  1070. logic_id, last_column_rung.id)->condition) == 10,
  1071. "a full-width grid must reject another condition without partial changes");
  1072. }
  1073. void testWireColumnReplacement()
  1074. {
  1075. TestProjectStorage storage;
  1076. ProjectService project_service(storage);
  1077. LogicEditorService service(project_service);
  1078. const std::string logic_id = service.ensureDefaultLogic().id;
  1079. const std::string rung_id = makeEmptyRung(service, logic_id);
  1080. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
  1081. require(wire.succeeded,
  1082. "wire-column replacement test must create a four-column wire");
  1083. require(service.replaceWireColumnWithCondition(
  1084. logic_id, rung_id, wire.id, 1,
  1085. ContactNodeConfig{
  1086. RegisterAddress{RegisterArea::M, 1},
  1087. ContactMode::NormallyClosed})
  1088. .succeeded,
  1089. "a selected wire cell must be replaceable without removing adjacent cells");
  1090. const LadderRung *rung = service.findRung(logic_id, rung_id);
  1091. require(rung != nullptr && rung->condition.has_value()
  1092. && rung->condition->kind == ConditionExpressionKind::Series
  1093. && rung->condition->children.size() == 3U
  1094. && rung->condition->children.front().wire->columnSpan == 1
  1095. && rung->condition->children.at(1).kind
  1096. == ConditionExpressionKind::Node
  1097. && std::get<ContactNodeConfig>(
  1098. rung->condition->children.at(1).node->config).mode
  1099. == ContactMode::NormallyClosed
  1100. && rung->condition->children.back().wire->columnSpan == 2
  1101. && conditionColumns(*rung->condition) == 4,
  1102. "wire-cell replacement must split the wire around the new contact");
  1103. const std::vector<ControlLogic> before_invalid =
  1104. project_service.project().controlLogics;
  1105. require(service.replaceWireColumnWithCondition(
  1106. logic_id,
  1107. rung_id,
  1108. rung->condition->children.front().id,
  1109. 1,
  1110. contact(2))
  1111. .error == LogicEditorError::InvalidOperation,
  1112. "an out-of-range wire-cell offset must be rejected");
  1113. require(project_service.project().controlLogics.size() == before_invalid.size()
  1114. && conditionColumns(*service.findRung(
  1115. logic_id, rung_id)->condition) == 4,
  1116. "a rejected wire-cell replacement must leave the network width unchanged");
  1117. const LogicEditorResult first_cell_rung = service.addRung(logic_id);
  1118. const LogicEditorResult first_cell_wire = service.appendWire(
  1119. logic_id, first_cell_rung.id, 4);
  1120. require(first_cell_rung.succeeded && first_cell_wire.succeeded
  1121. && service.replaceWireColumnWithCondition(
  1122. logic_id,
  1123. first_cell_rung.id,
  1124. first_cell_wire.id,
  1125. 0,
  1126. contact(10))
  1127. .succeeded,
  1128. "the first cell of a multi-column wire must be replaceable");
  1129. const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
  1130. require(first_cell != nullptr && first_cell->condition.has_value()
  1131. && first_cell->condition->kind == ConditionExpressionKind::Series
  1132. && first_cell->condition->children.size() == 2U
  1133. && first_cell->condition->children.front().kind
  1134. == ConditionExpressionKind::Node
  1135. && first_cell->condition->children.back().kind
  1136. == ConditionExpressionKind::Wire
  1137. && first_cell->condition->children.back().wire->columnSpan == 3,
  1138. "first-cell replacement must preserve the trailing wire cells");
  1139. require(service.undo().succeeded,
  1140. "wire-cell replacement must be one undoable edit");
  1141. const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
  1142. require(undone != nullptr && undone->condition.has_value()
  1143. && undone->condition->kind == ConditionExpressionKind::Wire
  1144. && undone->condition->wire->columnSpan == 4,
  1145. "undo must restore the original unsplit wire");
  1146. require(service.redo().succeeded,
  1147. "wire-cell replacement must be redoable");
  1148. const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
  1149. require(redone != nullptr && redone->condition.has_value()
  1150. && redone->condition->kind == ConditionExpressionKind::Series
  1151. && conditionColumns(*redone->condition) == 4,
  1152. "redo must restore the split wire without changing its width");
  1153. const std::string redone_trailing_wire_id =
  1154. redone->condition->children.back().id;
  1155. const LogicEditorResult last_cell_rung = service.addRung(logic_id);
  1156. const LogicEditorResult last_cell_wire = service.appendWire(
  1157. logic_id, last_cell_rung.id, 4);
  1158. require(last_cell_rung.succeeded && last_cell_wire.succeeded
  1159. && service.replaceWireColumnWithCondition(
  1160. logic_id,
  1161. last_cell_rung.id,
  1162. last_cell_wire.id,
  1163. 3,
  1164. contact(11))
  1165. .succeeded,
  1166. "the last cell of a multi-column wire must be replaceable");
  1167. const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
  1168. require(last_cell != nullptr && last_cell->condition.has_value()
  1169. && last_cell->condition->kind == ConditionExpressionKind::Series
  1170. && last_cell->condition->children.size() == 2U
  1171. && last_cell->condition->children.front().kind
  1172. == ConditionExpressionKind::Wire
  1173. && last_cell->condition->children.front().wire->columnSpan == 3
  1174. && last_cell->condition->children.back().kind
  1175. == ConditionExpressionKind::Node,
  1176. "last-cell replacement must preserve the leading wire cells");
  1177. const LogicEditorResult single_cell_rung = service.addRung(logic_id);
  1178. const LogicEditorResult single_cell_wire = service.appendWire(
  1179. logic_id, single_cell_rung.id, 1);
  1180. require(single_cell_rung.succeeded && single_cell_wire.succeeded
  1181. && service.replaceWireColumnWithCondition(
  1182. logic_id,
  1183. single_cell_rung.id,
  1184. single_cell_wire.id,
  1185. 0,
  1186. contact(12))
  1187. .succeeded,
  1188. "a one-column wire must use the same grid-cell replacement API");
  1189. const LadderRung *single_cell = service.findRung(
  1190. logic_id, single_cell_rung.id);
  1191. require(single_cell != nullptr && single_cell->condition.has_value()
  1192. && single_cell->condition->kind == ConditionExpressionKind::Node,
  1193. "one-column wire replacement must normalize directly to a condition node");
  1194. require(service.replaceWireColumnWithCondition(
  1195. logic_id,
  1196. first_cell_rung.id,
  1197. redone_trailing_wire_id,
  1198. 0,
  1199. CoilNodeConfig{
  1200. RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
  1201. .error == LogicEditorError::InvalidNode,
  1202. "wire grid cells must reject output instructions");
  1203. require(service.replaceWireColumnWithCondition(
  1204. logic_id,
  1205. first_cell_rung.id,
  1206. "missing-wire",
  1207. 0,
  1208. contact(13))
  1209. .error == LogicEditorError::ExpressionNotFound,
  1210. "wire grid replacement must reject an unknown wire without mutation");
  1211. }
  1212. void testSequentialConditionInsertionConsumesFollowingWire()
  1213. {
  1214. TestProjectStorage storage;
  1215. ProjectService project_service(storage);
  1216. LogicEditorService service(project_service);
  1217. const std::string logic_id = service.ensureDefaultLogic().id;
  1218. const std::string rung_id = makeEmptyRung(service, logic_id);
  1219. const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
  1220. require(wire.succeeded,
  1221. "sequential wire replacement must start with a full-width wire");
  1222. LogicEditorResult inserted = service.replaceWireColumnWithCondition(
  1223. logic_id, rung_id, wire.id, 0, contact(0));
  1224. require(inserted.succeeded,
  1225. "the first condition must replace the first full-wire cell");
  1226. std::string selected_node_id = inserted.id;
  1227. for (int address = 1; address < 10; ++address)
  1228. {
  1229. inserted = service.insertConditionAfter(
  1230. logic_id, rung_id, selected_node_id, contact(address));
  1231. require(inserted.succeeded,
  1232. "continuous condition insertion must consume the following wire cell");
  1233. selected_node_id = inserted.id;
  1234. const LadderRung *rung = service.findRung(logic_id, rung_id);
  1235. std::vector<const LogicNode *> nodes;
  1236. collectConditionNodes(*rung->condition, &nodes);
  1237. require(rung != nullptr && rung->condition.has_value()
  1238. && conditionColumns(*rung->condition) == 10
  1239. && nodes.size() == static_cast<std::size_t>(address + 1)
  1240. && wireColumns(*rung->condition) == 9 - address,
  1241. "each continuous insertion must preserve width while consuming one wire cell");
  1242. }
  1243. const LadderRung *full = service.findRung(logic_id, rung_id);
  1244. require(full != nullptr && full->condition.has_value()
  1245. && conditionColumns(*full->condition) == 10
  1246. && wireColumns(*full->condition) == 0,
  1247. "ten continuous insertions must replace the entire wire without expanding it");
  1248. require(service.insertConditionAfter(
  1249. logic_id, rung_id, selected_node_id, contact(10))
  1250. .error == LogicEditorError::InvalidOperation,
  1251. "the eleventh condition must still be rejected after all wire cells are consumed");
  1252. require(conditionColumns(*service.findRung(
  1253. logic_id, rung_id)->condition) == 10,
  1254. "a rejected eleventh insertion must leave the full network unchanged");
  1255. require(service.undo().succeeded,
  1256. "a failed eleventh insertion must not displace the last successful undo step");
  1257. const LadderRung *undone = service.findRung(logic_id, rung_id);
  1258. std::vector<const LogicNode *> undone_nodes;
  1259. collectConditionNodes(*undone->condition, &undone_nodes);
  1260. require(undone_nodes.size() == 9U
  1261. && wireColumns(*undone->condition) == 1
  1262. && conditionColumns(*undone->condition) == 10,
  1263. "undo must restore nine contacts followed by one wire cell");
  1264. require(service.redo().succeeded,
  1265. "the final wire-consuming insertion must be redoable");
  1266. const LadderRung *redone = service.findRung(logic_id, rung_id);
  1267. std::vector<const LogicNode *> redone_nodes;
  1268. collectConditionNodes(*redone->condition, &redone_nodes);
  1269. require(redone_nodes.size() == 10U
  1270. && wireColumns(*redone->condition) == 0
  1271. && conditionColumns(*redone->condition) == 10,
  1272. "redo must restore all ten contacts without wire cells");
  1273. }
  1274. void testLogicLifecycleAndOrdering()
  1275. {
  1276. TestProjectStorage storage;
  1277. ProjectService project_service(storage);
  1278. LogicEditorService service(project_service);
  1279. const std::string first_id = service.ensureDefaultLogic().id;
  1280. const LogicEditorResult second = service.addLogic("Safety logic");
  1281. const LogicEditorResult third = service.addLogic("Alarm logic");
  1282. require(second.succeeded && third.succeeded,
  1283. "multiple control logic modules must be creatable");
  1284. require(service.renameLogic(second.id, "Interlock logic").succeeded,
  1285. "control logic modules must be renamable by stable id");
  1286. require(service.renameLogic(third.id, "Interlock logic").error
  1287. == LogicEditorError::DuplicateName,
  1288. "control logic names must remain unique");
  1289. require(service.moveLogic(third.id, -1).succeeded
  1290. && project_service.project().controlLogics.at(1).id == third.id,
  1291. "logic scan order must follow editable vector order");
  1292. require(service.setLogicEnabled(second.id, false).succeeded
  1293. && !service.findLogic(second.id)->enabled,
  1294. "a control logic module must support explicit disable and enable");
  1295. require(service.setLogicEnabled(second.id, true).succeeded
  1296. && service.findLogic(second.id)->enabled,
  1297. "a disabled control logic module must be re-enableable");
  1298. require(service.removeLogic(third.id).succeeded,
  1299. "a non-final control logic module must be deletable");
  1300. require(service.removeLogic(second.id).succeeded,
  1301. "logic deletion must preserve the remaining module");
  1302. require(service.removeLogic(first_id).error
  1303. == LogicEditorError::LastLogicRequired,
  1304. "the project must retain at least one control logic module");
  1305. }
  1306. void testEdgeNodesAndRungComments()
  1307. {
  1308. TestProjectStorage storage;
  1309. ProjectService project_service(storage);
  1310. LogicEditorService service(project_service);
  1311. const std::string logic_id = service.ensureDefaultLogic().id;
  1312. const std::string rung_id = makeEmptyRung(service, logic_id);
  1313. const LogicEditorResult rising = service.appendCondition(
  1314. logic_id,
  1315. rung_id,
  1316. EdgeContactNodeConfig{
  1317. RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising});
  1318. require(rising.succeeded && rising.id == "edge-1",
  1319. "the editor must create rising edge nodes with a stable prefix");
  1320. require(service.updateNodeConfig(
  1321. logic_id,
  1322. rising.id,
  1323. EdgeContactNodeConfig{
  1324. RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling})
  1325. .succeeded,
  1326. "the editor must apply edge mode and M address properties");
  1327. require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded,
  1328. "the editor must update a network comment by stable rung id");
  1329. require(!service.updateRungComment(
  1330. logic_id, rung_id, "第一行\n第二行").succeeded,
  1331. "the editor must reject multiline network comments");
  1332. require(!service.updateRungComment(
  1333. logic_id,
  1334. rung_id,
  1335. std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a'))
  1336. .succeeded,
  1337. "the editor must reject oversized network comments");
  1338. const LadderRung *rung = service.findRung(logic_id, rung_id);
  1339. const LogicNode *edge = service.findNode(logic_id, rising.id);
  1340. require(rung != nullptr && rung->comment == "延时启动网络"
  1341. && edge != nullptr
  1342. && std::get<EdgeContactNodeConfig>(edge->config).mode
  1343. == EdgeMode::Falling,
  1344. "edge and rung comment updates must remain in the model");
  1345. }
  1346. void testHistoryAndAtomicBatchDelete()
  1347. {
  1348. TestProjectStorage storage;
  1349. ProjectService project_service(storage);
  1350. LogicEditorService service(project_service);
  1351. const std::string logic_id = service.ensureDefaultLogic().id;
  1352. const std::string first_rung_id = makeEmptyRung(service, logic_id);
  1353. const LogicEditorResult first = service.appendCondition(
  1354. logic_id, first_rung_id, contact(0));
  1355. const LogicEditorResult second = service.appendCondition(
  1356. logic_id, first_rung_id, contact(1));
  1357. const LogicEditorResult second_rung = service.addRung(logic_id);
  1358. const LogicEditorResult third = service.appendCondition(
  1359. logic_id, second_rung.id, contact(2));
  1360. require(first.succeeded && second.succeeded && second_rung.succeeded
  1361. && third.succeeded,
  1362. "nodes for history testing must be created");
  1363. service.clearHistory();
  1364. require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
  1365. "batch node deletion must validate every id before changing the logic");
  1366. require(service.findNode(logic_id, first.id) != nullptr
  1367. && service.findNode(logic_id, third.id) != nullptr
  1368. && !service.canUndo(),
  1369. "failed batch node deletion must be atomic and leave history unchanged");
  1370. require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
  1371. "valid nodes across multiple rungs must be deleted together");
  1372. require(service.findNode(logic_id, first.id) == nullptr
  1373. && service.findNode(logic_id, third.id) == nullptr,
  1374. "all selected nodes must be removed by one batch operation");
  1375. require(service.undo().succeeded
  1376. && service.findNode(logic_id, first.id) != nullptr
  1377. && service.findNode(logic_id, third.id) != nullptr,
  1378. "logic undo must restore a cross-rung batch deletion");
  1379. require(service.redo().succeeded
  1380. && service.findNode(logic_id, first.id) == nullptr
  1381. && service.findNode(logic_id, third.id) == nullptr,
  1382. "logic redo must reapply a cross-rung batch deletion");
  1383. service.clearHistory();
  1384. const ControlLogic *logic = service.findLogic(logic_id);
  1385. require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
  1386. && !service.canUndo(),
  1387. "setting an unchanged logic state must not consume history");
  1388. service.clearHistory();
  1389. for (int index = 1; index <= 101; ++index)
  1390. {
  1391. require(service.updateRungComment(
  1392. logic_id, first_rung_id, "comment-" + std::to_string(index))
  1393. .succeeded,
  1394. "repeated valid rung edits must succeed");
  1395. }
  1396. int undo_count = 0;
  1397. while (service.undo().succeeded)
  1398. {
  1399. ++undo_count;
  1400. }
  1401. require(undo_count == static_cast<int>(EditorHistory<int>::kMaximumEntries),
  1402. "logic history must retain exactly the configured most recent steps");
  1403. require(service.redo().succeeded,
  1404. "logic redo must be available after an undo");
  1405. require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
  1406. "a new logic edit must succeed after undo");
  1407. require(!service.canRedo(),
  1408. "a new logic edit must clear the redo history");
  1409. (void)second;
  1410. }
  1411. void testBatchPasteNodesAndRung()
  1412. {
  1413. TestProjectStorage storage;
  1414. ProjectService project_service(storage);
  1415. LogicEditorService service(project_service);
  1416. const std::string logic_id = service.ensureDefaultLogic().id;
  1417. const std::string rung_id = makeEmptyRung(service, logic_id);
  1418. const LogicEditorResult first = service.appendCondition(
  1419. logic_id, rung_id, contact(10));
  1420. const LogicEditorResult second = service.appendCondition(
  1421. logic_id, rung_id, contact(11));
  1422. require(first.succeeded && second.succeeded,
  1423. "nodes for paste testing must be created");
  1424. const LogicNode first_copy = *service.findNode(logic_id, first.id);
  1425. const LogicNode second_copy = *service.findNode(logic_id, second.id);
  1426. service.clearHistory();
  1427. const LogicEditorResult pasted = service.pasteConditionNodes(
  1428. logic_id, rung_id, {first_copy, second_copy});
  1429. require(pasted.succeeded
  1430. && service.findNode(logic_id, pasted.id) != nullptr
  1431. && service.findRung(logic_id, rung_id)->condition.has_value()
  1432. && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U,
  1433. "batch condition paste must append fresh nodes");
  1434. require(pasted.id != first.id && pasted.id != second.id
  1435. && service.undo().succeeded
  1436. && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U,
  1437. "batch condition paste must use fresh ids and one undo step");
  1438. require(service.areConditionNodesContiguous(
  1439. logic_id, rung_id, {first.id, second.id}),
  1440. "adjacent nodes in one series must be copyable as a range");
  1441. const std::string wire_rung_id = makeEmptyRung(service, logic_id);
  1442. const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3);
  1443. service.clearHistory();
  1444. LogicConditionPasteTarget wire_target;
  1445. wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
  1446. wire_target.expressionId = wire.id;
  1447. wire_target.column = 1;
  1448. const LogicEditorResult pasted_on_wire = service.pasteConditionNodes(
  1449. logic_id, wire_rung_id, {first_copy, second_copy}, wire_target);
  1450. const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id);
  1451. require(pasted_on_wire.succeeded && wire_rung != nullptr
  1452. && wire_rung->condition.has_value()
  1453. && conditionNodes(*wire_rung->condition) == 2
  1454. && conditionColumns(*wire_rung->condition) == 3,
  1455. "condition paste must replace the selected wire cell and consume following wire cells");
  1456. require(service.undo().succeeded
  1457. && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3,
  1458. "wire-targeted paste must be undone as one edit");
  1459. const std::string full_rung_id = makeEmptyRung(service, logic_id);
  1460. for (int address = 0; address < 9; ++address)
  1461. {
  1462. require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded,
  1463. "nine conditions must fit before an atomic paste failure test");
  1464. }
  1465. service.clearHistory();
  1466. require(!service.pasteConditionNodes(
  1467. logic_id, full_rung_id, {first_copy, second_copy}).succeeded
  1468. && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9
  1469. && !service.canUndo(),
  1470. "a multi-node paste that exceeds ten columns must roll back completely");
  1471. const LogicEditorResult third = service.appendCondition(
  1472. logic_id, rung_id, contact(12));
  1473. require(third.succeeded
  1474. && !service.areConditionNodesContiguous(
  1475. logic_id, rung_id, {first.id, third.id}),
  1476. "non-adjacent nodes must not be copied as one condition range");
  1477. const LogicEditorResult output = service.setOutput(
  1478. logic_id,
  1479. rung_id,
  1480. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set},
  1481. true);
  1482. require(output.succeeded, "the source rung must accept an output before copying");
  1483. const LadderRung source = *service.findRung(logic_id, rung_id);
  1484. service.clearHistory();
  1485. const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size();
  1486. const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source);
  1487. require(pasted_rung.succeeded
  1488. && service.findLogic(logic_id)->rungs.size()
  1489. == rung_count_before_paste + 1U
  1490. && pasted_rung.id != source.id,
  1491. "whole rung paste must append a new network");
  1492. const LadderRung *copy = service.findRung(logic_id, pasted_rung.id);
  1493. require(copy != nullptr && copy->condition.has_value()
  1494. && copy->condition->id != source.condition->id
  1495. && copy->output.has_value() && source.output.has_value()
  1496. && copy->output->id != source.output->id
  1497. && std::get<CoilNodeConfig>(copy->output->config).address.index() == 20
  1498. && std::get<CoilNodeConfig>(copy->output->config).mode == CoilMode::Set,
  1499. "whole rung paste must regenerate ids and preserve output configuration");
  1500. }
  1501. void testConditionPasteTargetsAndValidation()
  1502. {
  1503. TestProjectStorage storage;
  1504. ProjectService project_service(storage);
  1505. LogicEditorService service(project_service);
  1506. const std::string logic_id = service.ensureDefaultLogic().id;
  1507. LogicNode source;
  1508. source.id = "copied-condition";
  1509. source.config = contact(90);
  1510. source.configured = true;
  1511. const std::string empty_column_rung = makeEmptyRung(service, logic_id);
  1512. LogicConditionPasteTarget empty_column;
  1513. empty_column.kind = LogicConditionPasteTargetKind::EmptyColumn;
  1514. empty_column.column = 2;
  1515. require(service.pasteConditionNodes(
  1516. logic_id, empty_column_rung, {source}, empty_column).succeeded,
  1517. "condition paste must support an empty grid column");
  1518. require(conditionColumns(*service.findRung(
  1519. logic_id, empty_column_rung)->condition) == 3,
  1520. "empty-column paste must preserve the requested column offset");
  1521. const std::string branch_rung = makeEmptyRung(service, logic_id);
  1522. const LogicEditorResult branch_source = service.appendCondition(
  1523. logic_id, branch_rung, contact(1));
  1524. const LogicEditorResult branch_source_two = service.appendCondition(
  1525. logic_id, branch_rung, contact(2));
  1526. const LogicEditorResult branch_source_three = service.appendCondition(
  1527. logic_id, branch_rung, contact(3));
  1528. require(branch_source.succeeded && branch_source_two.succeeded
  1529. && branch_source_three.succeeded,
  1530. "branch paste setup must add a source range");
  1531. const LogicEditorResult branch = service.addParallelBranch(
  1532. logic_id, branch_rung,
  1533. {branch_source.id, branch_source_two.id, branch_source_three.id},
  1534. contact(4));
  1535. require(branch.succeeded, "branch paste setup must create a parallel branch");
  1536. const ConditionExpression &paste_lower = service.findRung(
  1537. logic_id, branch_rung)->condition->children.at(1);
  1538. require(paste_lower.kind == ConditionExpressionKind::Series
  1539. && paste_lower.children.back().kind == ConditionExpressionKind::Wire,
  1540. "branch paste setup must persist the short branch padding wire");
  1541. LogicConditionPasteTarget branch_target;
  1542. branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
  1543. branch_target.expressionId = paste_lower.children.back().id;
  1544. branch_target.column = 1;
  1545. require(service.pasteConditionNodes(
  1546. logic_id, branch_rung, {source}, branch_target).succeeded,
  1547. "condition paste must support a persisted wire cell in a branch");
  1548. const std::string after_node_rung = makeEmptyRung(service, logic_id);
  1549. const LogicEditorResult after_source = service.appendCondition(
  1550. logic_id, after_node_rung, contact(3));
  1551. require(after_source.succeeded, "after-node paste setup must add a source node");
  1552. LogicConditionPasteTarget after_target;
  1553. after_target.kind = LogicConditionPasteTargetKind::AfterNode;
  1554. after_target.expressionId = after_source.id;
  1555. require(service.pasteConditionNodes(
  1556. logic_id, after_node_rung, {source}, after_target).succeeded,
  1557. "condition paste must support insertion after a selected node");
  1558. require(conditionNodes(*service.findRung(
  1559. logic_id, after_node_rung)->condition) == 2,
  1560. "after-node paste must add exactly one condition");
  1561. const std::string replace_wire_rung = makeEmptyRung(service, logic_id);
  1562. const LogicEditorResult wire = service.appendWire(
  1563. logic_id, replace_wire_rung, 2);
  1564. require(wire.succeeded, "replace-wire paste setup must add a wire");
  1565. LogicConditionPasteTarget replace_wire;
  1566. replace_wire.kind = LogicConditionPasteTargetKind::ReplaceWire;
  1567. replace_wire.expressionId = wire.id;
  1568. require(service.pasteConditionNodes(
  1569. logic_id, replace_wire_rung, {source}, replace_wire).succeeded,
  1570. "condition paste must replace an entire wire expression");
  1571. require(conditionNodes(*service.findRung(
  1572. logic_id, replace_wire_rung)->condition) == 1,
  1573. "whole-wire paste must create one condition node");
  1574. const std::string replace_cell_rung = makeEmptyRung(service, logic_id);
  1575. const LogicEditorResult cell_wire = service.appendWire(
  1576. logic_id, replace_cell_rung, 3);
  1577. require(cell_wire.succeeded, "replace-cell paste setup must add a wire");
  1578. LogicConditionPasteTarget replace_cell;
  1579. replace_cell.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
  1580. replace_cell.expressionId = cell_wire.id;
  1581. replace_cell.column = 1;
  1582. require(service.pasteConditionNodes(
  1583. logic_id, replace_cell_rung, {source}, replace_cell).succeeded,
  1584. "condition paste must replace one selected wire cell");
  1585. require(conditionColumns(*service.findRung(
  1586. logic_id, replace_cell_rung)->condition) == 3,
  1587. "wire-cell paste must preserve the original network width");
  1588. require(!service.pasteConditionNodes(logic_id, after_node_rung, {}).succeeded,
  1589. "an empty condition clipboard must be rejected");
  1590. LogicNode output = source;
  1591. output.config = CoilNodeConfig{RegisterAddress{RegisterArea::M, 91}, CoilMode::Normal};
  1592. require(!service.pasteConditionNodes(
  1593. logic_id, after_node_rung, {output}).succeeded,
  1594. "an output instruction must not be pasted into the condition area");
  1595. require(!service.pasteConditionNodes(
  1596. "missing-logic", after_node_rung, {source}).succeeded,
  1597. "condition paste must reject an unknown logic");
  1598. require(!service.pasteConditionNodes(
  1599. logic_id, "missing-rung", {source}).succeeded,
  1600. "condition paste must reject an unknown rung");
  1601. const LadderRung *before_invalid = service.findRung(
  1602. logic_id, after_node_rung);
  1603. require(before_invalid != nullptr && before_invalid->condition.has_value(),
  1604. "invalid paste setup must retain its target network");
  1605. const int node_count_before_invalid = conditionNodes(*before_invalid->condition);
  1606. LogicConditionPasteTarget invalid_target;
  1607. invalid_target.kind = LogicConditionPasteTargetKind::AfterNode;
  1608. invalid_target.expressionId = "missing-expression";
  1609. service.clearHistory();
  1610. require(!service.pasteConditionNodes(
  1611. logic_id, after_node_rung, {source}, invalid_target).succeeded
  1612. && conditionNodes(*service.findRung(
  1613. logic_id, after_node_rung)->condition) == node_count_before_invalid
  1614. && !service.canUndo(),
  1615. "an invalid paste target must roll back without recording history");
  1616. }
  1617. void testConfiguredLogicLimits()
  1618. {
  1619. TestProjectStorage storage;
  1620. ProjectLimitSettings limits;
  1621. limits.maximumControlLogics = 1U;
  1622. limits.maximumRungsPerLogic = 0U;
  1623. ProjectService project_service(storage, limits);
  1624. LogicEditorService service(project_service);
  1625. const LogicEditorResult initial = service.ensureDefaultLogic();
  1626. require(initial.succeeded,
  1627. "the configured-limit fixture must create its one default logic");
  1628. require(!service.addLogic("Second logic").succeeded,
  1629. "the logic editor must use the configured logic-group limit");
  1630. require(!service.addRung(initial.id).succeeded,
  1631. "the logic editor must use the configured per-logic rung limit");
  1632. }
  1633. } // namespace
  1634. int main()
  1635. {
  1636. try
  1637. {
  1638. testLogicCommandParsing();
  1639. testContinuousLogicCommands();
  1640. testExistingNodeCommandReplacement();
  1641. testPositionedLogicCommandsAndAtomicFailures();
  1642. testEmptyLogicCreatesNetworksOnFirstEdit();
  1643. testBatchRungDeleteIsAtomic();
  1644. testAppendingWireMovesToNextNetworkAfterTenColumns();
  1645. testAppendingWireReusesDeletedGapCells();
  1646. testStructuredEditingAndNormalization();
  1647. testRangeParallelInsertion();
  1648. testParallelBranchGridInsertion();
  1649. testStructuredWireEditing();
  1650. testWireCellParallelSelectionUsesExactColumns();
  1651. testWireCellParallelSelectionAcrossAdjacentWires();
  1652. testBatchDeleteAllNodesInParallelBranch();
  1653. testConditionColumnLimit();
  1654. testUnconditionalOutputEditing();
  1655. testExplicitGapEditing();
  1656. testColumnTargetedConditionInsertion();
  1657. testWireColumnReplacement();
  1658. testSequentialConditionInsertionConsumesFollowingWire();
  1659. testLogicLifecycleAndOrdering();
  1660. testEdgeNodesAndRungComments();
  1661. testHistoryAndAtomicBatchDelete();
  1662. testBatchPasteNodesAndRung();
  1663. testConditionPasteTargetsAndValidation();
  1664. testConfiguredLogicLimits();
  1665. }
  1666. catch (const std::exception &error)
  1667. {
  1668. std::cerr << "logic editor service tests failed: " << error.what() << '\n';
  1669. return 1;
  1670. }
  1671. std::cout << "logic editor service tests passed\n";
  1672. return 0;
  1673. }