综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

721 lines
28 KiB

  1. #include "domain/hmi_model.h"
  2. #include "domain/project_limits.h"
  3. #include "domain/register_repository.h"
  4. #include "domain/virtual_register_repository.h"
  5. #include "services/hmi_runtime_service.h"
  6. #include "services/offline_simulation_service.h"
  7. #include "services/software_logic_executor.h"
  8. #include "support/test_support.h"
  9. #include <QCoreApplication>
  10. #include <array>
  11. #include <iostream>
  12. #include <stdexcept>
  13. #include <string>
  14. #include <vector>
  15. namespace {
  16. using TestSupport::require;
  17. LogicNode contact(const std::string &id, int address,
  18. ContactMode mode = ContactMode::NormallyOpen)
  19. {
  20. return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
  21. }
  22. LogicNode edgeContact(const std::string &id, int address, EdgeMode mode)
  23. {
  24. return {id,
  25. EdgeContactNodeConfig{
  26. RegisterAddress{RegisterArea::M, address}, mode},
  27. true};
  28. }
  29. WordOperand constantOperand(std::int16_t value)
  30. {
  31. return {
  32. WordOperandKind::Constant,
  33. RegisterAddress{RegisterArea::D, 0},
  34. value};
  35. }
  36. WordOperand registerOperand(int address)
  37. {
  38. return {
  39. WordOperandKind::Register,
  40. RegisterAddress{RegisterArea::D, address},
  41. 0};
  42. }
  43. LogicNode move(
  44. const std::string &id,
  45. WordOperand source,
  46. int destination)
  47. {
  48. return {
  49. id,
  50. MoveNodeConfig{
  51. source,
  52. RegisterAddress{RegisterArea::D, destination}},
  53. true};
  54. }
  55. LogicNode arithmetic(
  56. const std::string &id,
  57. ArithmeticOperation operation,
  58. WordOperand left,
  59. WordOperand right,
  60. int destination)
  61. {
  62. return {
  63. id,
  64. ArithmeticNodeConfig{
  65. operation,
  66. left,
  67. right,
  68. RegisterAddress{RegisterArea::D, destination}},
  69. true};
  70. }
  71. LogicNode comparison(const std::string &id, int address,
  72. ComparisonOperator operation, std::int16_t value)
  73. {
  74. return {id,
  75. CompareNodeConfig{RegisterAddress{RegisterArea::D, address}, operation, value},
  76. true};
  77. }
  78. LogicNode coil(const std::string &id, int address,
  79. CoilMode mode = CoilMode::Normal)
  80. {
  81. return {id, CoilNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
  82. }
  83. LadderRung rung(const std::string &id,
  84. const std::vector<std::vector<LogicNode>> &stages,
  85. const LogicNode &output)
  86. {
  87. LadderRung result;
  88. result.id = id;
  89. result.name = id;
  90. std::vector<ConditionExpression> series_children;
  91. for (std::size_t index = 0; index < stages.size(); ++index)
  92. {
  93. std::vector<ConditionExpression> parallel_children;
  94. for (const LogicNode &node : stages[index])
  95. {
  96. parallel_children.push_back(ConditionExpression::fromNode(node));
  97. }
  98. if (parallel_children.size() == 1U)
  99. {
  100. series_children.push_back(std::move(parallel_children.front()));
  101. }
  102. else
  103. {
  104. ConditionExpression parallel;
  105. parallel.id = id + "-parallel-" + std::to_string(index);
  106. parallel.kind = ConditionExpressionKind::Parallel;
  107. parallel.children = std::move(parallel_children);
  108. series_children.push_back(std::move(parallel));
  109. }
  110. }
  111. if (series_children.size() == 1U)
  112. {
  113. result.condition = std::move(series_children.front());
  114. }
  115. else
  116. {
  117. ConditionExpression series;
  118. series.id = id + "-series";
  119. series.kind = ConditionExpressionKind::Series;
  120. series.children = std::move(series_children);
  121. result.condition = std::move(series);
  122. }
  123. const int occupied_columns = static_cast<int>(stages.size());
  124. if (occupied_columns < ProjectLimits::kMaximumConditionColumns)
  125. {
  126. ConditionExpression wire = ConditionExpression::fromWire(
  127. id + "-output-wire",
  128. ProjectLimits::kMaximumConditionColumns - occupied_columns);
  129. if (result.condition->kind == ConditionExpressionKind::Series)
  130. {
  131. result.condition->children.push_back(std::move(wire));
  132. }
  133. else
  134. {
  135. ConditionExpression series;
  136. series.id = id + "-explicit-series";
  137. series.kind = ConditionExpressionKind::Series;
  138. series.children.push_back(std::move(*result.condition));
  139. series.children.push_back(std::move(wire));
  140. result.condition = std::move(series);
  141. }
  142. }
  143. result.output = output;
  144. return result;
  145. }
  146. ControlLogic logic(const std::vector<LadderRung> &rungs)
  147. {
  148. return {"logic-1", "logic-1", rungs, true};
  149. }
  150. bool readBit(RegisterRepository &repository, int address)
  151. {
  152. const BitReadResult result = repository.readBit(
  153. RegisterAddress{RegisterArea::M, address});
  154. require(result.succeeded, "test register read must succeed");
  155. return result.value;
  156. }
  157. void writeBit(RegisterRepository &repository, int address, bool value)
  158. {
  159. require(repository.writeBit(RegisterAddress{RegisterArea::M, address}, value).succeeded,
  160. "test bit write must succeed");
  161. }
  162. void writeWord(RegisterRepository &repository, int address, std::int16_t value)
  163. {
  164. require(repository.writeWord(RegisterAddress{RegisterArea::D, address}, value).succeeded,
  165. "test word write must succeed");
  166. }
  167. std::int16_t readWord(RegisterRepository &repository, int address)
  168. {
  169. const WordReadResult result = repository.readWord(
  170. RegisterAddress{RegisterArea::D, address});
  171. require(result.succeeded, "test word read must succeed");
  172. return result.value;
  173. }
  174. void testNestedSeriesParallelExpression()
  175. {
  176. VirtualRegisterRepository repository;
  177. SoftwareLogicExecutor executor;
  178. ConditionExpression nested_series;
  179. nested_series.id = "nested-series";
  180. nested_series.kind = ConditionExpressionKind::Series;
  181. nested_series.children = {
  182. ConditionExpression::fromNode(contact("b", 1)),
  183. ConditionExpression::fromNode(contact("c", 2))};
  184. ConditionExpression padded_a;
  185. padded_a.id = "padded-a";
  186. padded_a.kind = ConditionExpressionKind::Series;
  187. padded_a.children = {
  188. ConditionExpression::fromNode(contact("a", 0)),
  189. ConditionExpression::fromWire("a-branch-wire", 1)};
  190. ConditionExpression parallel;
  191. parallel.id = "root-parallel";
  192. parallel.kind = ConditionExpressionKind::Parallel;
  193. parallel.children = {std::move(padded_a), nested_series};
  194. ConditionExpression root;
  195. root.id = "nested-output-series";
  196. root.kind = ConditionExpressionKind::Series;
  197. root.children = {
  198. std::move(parallel),
  199. ConditionExpression::fromWire("nested-output-wire", 8)};
  200. LadderRung nested_rung;
  201. nested_rung.id = "nested-rung";
  202. nested_rung.name = "nested-rung";
  203. nested_rung.condition = root;
  204. nested_rung.output = coil("nested-output", 10);
  205. const ControlLogic program = logic({nested_rung});
  206. writeBit(repository, 1, true);
  207. writeBit(repository, 2, true);
  208. LogicTraceSnapshot trace;
  209. require(executor.executeScan({program}, repository, &trace).succeeded,
  210. "nested expression scan must succeed");
  211. require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)");
  212. require(trace.expressionValues.at("nested-series")
  213. && trace.expressionValues.at("root-parallel")
  214. && trace.rungValues.at("nested-rung"),
  215. "scan trace must expose active nested expression and rung values");
  216. writeBit(repository, 2, false);
  217. require(executor.executeScan({program}, repository, &trace).succeeded,
  218. "nested false scan must succeed");
  219. require(!readBit(repository, 10), "incomplete B AND C branch must be false");
  220. writeBit(repository, 0, true);
  221. require(executor.executeScan({program}, repository, &trace).succeeded,
  222. "alternate branch scan must succeed");
  223. require(readBit(repository, 10), "A branch must independently energize output");
  224. }
  225. void testUnconditionalCoil()
  226. {
  227. VirtualRegisterRepository repository;
  228. SoftwareLogicExecutor executor;
  229. LadderRung unconditional;
  230. unconditional.id = "unconditional-rung";
  231. unconditional.name = "unconditional-rung";
  232. unconditional.condition = ConditionExpression::fromWire(
  233. "unconditional-wire", ProjectLimits::kMaximumConditionColumns);
  234. unconditional.output = coil("unconditional-coil", 10);
  235. const ControlLogic program = logic({unconditional});
  236. LogicTraceSnapshot trace;
  237. require(executor.validate({program}).succeeded,
  238. "a full-width wire network must pass runtime validation");
  239. require(executor.executeScan({program}, repository, &trace).succeeded,
  240. "a full-width wire network scan must succeed");
  241. require(readBit(repository, 10),
  242. "a full-width wire network must energize its coil as a constant-true rung");
  243. require(trace.rungValues.at("unconditional-rung")
  244. && trace.nodePowerValues.at("unconditional-coil"),
  245. "an unconditional rung must report energized power flow");
  246. }
  247. void testWirePassThroughAndPowerTrace()
  248. {
  249. VirtualRegisterRepository repository;
  250. SoftwareLogicExecutor executor;
  251. ConditionExpression root;
  252. root.id = "wire-series";
  253. root.kind = ConditionExpressionKind::Series;
  254. root.children = {
  255. ConditionExpression::fromNode(contact("wire-input", 0)),
  256. ConditionExpression::fromWire("wire-segment", 2),
  257. ConditionExpression::fromNode(contact("wire-output", 1)),
  258. ConditionExpression::fromWire("wire-output-padding", 6)};
  259. LadderRung wired_rung;
  260. wired_rung.id = "wired-rung";
  261. wired_rung.name = "wired-rung";
  262. wired_rung.condition = root;
  263. wired_rung.output = coil("wired-coil", 10);
  264. const ControlLogic program = logic({wired_rung});
  265. writeBit(repository, 1, true);
  266. LogicTraceSnapshot trace;
  267. require(executor.executeScan({program}, repository, &trace).succeeded,
  268. "wire expression scan must succeed");
  269. require(!readBit(repository, 10),
  270. "a horizontal wire must not bypass a false upstream series contact");
  271. require(trace.expressionValues.at("wire-segment")
  272. && !trace.expressionInputValues.at("wire-segment")
  273. && !trace.expressionPowerValues.at("wire-segment"),
  274. "a wire must remain logically true without showing false upstream power");
  275. writeBit(repository, 0, true);
  276. require(executor.executeScan({program}, repository, &trace).succeeded,
  277. "powered wire expression scan must succeed");
  278. require(readBit(repository, 10)
  279. && trace.expressionInputValues.at("wire-segment")
  280. && trace.expressionPowerValues.at("wire-segment"),
  281. "a powered horizontal wire must pass current to the downstream contact");
  282. }
  283. void testSeriesParallelContactsAndSequentialVisibility()
  284. {
  285. VirtualRegisterRepository repository;
  286. SoftwareLogicExecutor executor;
  287. const ControlLogic program = logic({
  288. rung("rung-1",
  289. {{contact("start", 0), contact("alternate", 1)},
  290. {contact("stop", 2, ContactMode::NormallyClosed)}},
  291. coil("run", 3)),
  292. rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))});
  293. writeBit(repository, 1, true);
  294. require(executor.executeScan({program}, repository).succeeded,
  295. "parallel and series scan must succeed");
  296. require(readBit(repository, 3), "parallel OR and series AND must energize output");
  297. require(readBit(repository, 4),
  298. "a later rung must see an earlier rung write in the same scan");
  299. writeBit(repository, 2, true);
  300. require(executor.executeScan({program}, repository).succeeded,
  301. "normally closed scan must succeed");
  302. require(!readBit(repository, 3), "normally closed stop contact must open the rung");
  303. require(!readBit(repository, 4), "downstream normal coil must follow the new value");
  304. }
  305. void testAllComparisons()
  306. {
  307. const std::array<ComparisonOperator, 6> operations{
  308. ComparisonOperator::Equal, ComparisonOperator::NotEqual,
  309. ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual,
  310. ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual};
  311. const std::array<std::int16_t, 6> actual_values{10, 9, 9, 10, 11, 10};
  312. for (std::size_t index = 0; index < operations.size(); ++index)
  313. {
  314. VirtualRegisterRepository repository;
  315. SoftwareLogicExecutor executor;
  316. writeWord(repository, 0, actual_values[index]);
  317. const ControlLogic program = logic({
  318. rung("compare-rung", {{comparison("compare", 0, operations[index], 10)},
  319. }, coil("result", 10))});
  320. require(executor.executeScan({program}, repository).succeeded,
  321. "comparison scan must succeed");
  322. require(readBit(repository, 10), "comparison operator must evaluate true");
  323. }
  324. }
  325. void testSetResetAndDisabledLogic()
  326. {
  327. VirtualRegisterRepository repository;
  328. SoftwareLogicExecutor executor;
  329. ControlLogic program = logic({
  330. rung("set-rung", {{contact("set-input", 0)}}, coil("set-output", 5, CoilMode::Set)),
  331. rung("reset-rung", {{contact("reset-input", 1)}},
  332. coil("reset-output", 6, CoilMode::Reset))});
  333. writeBit(repository, 0, true);
  334. require(executor.executeScan({program}, repository).succeeded, "set scan must succeed");
  335. require(readBit(repository, 5), "set coil must latch true");
  336. writeBit(repository, 0, false);
  337. require(executor.executeScan({program}, repository).succeeded,
  338. "inactive set scan must succeed");
  339. require(readBit(repository, 5), "inactive set coil must retain its value");
  340. writeBit(repository, 1, true);
  341. writeBit(repository, 6, true);
  342. require(executor.executeScan({program}, repository).succeeded,
  343. "reset scan must succeed");
  344. require(!readBit(repository, 6), "reset coil must write false");
  345. program.enabled = false;
  346. writeBit(repository, 5, true);
  347. require(executor.executeScan({program}, repository).succeeded,
  348. "disabled logic scan must be ignored successfully");
  349. require(readBit(repository, 5), "disabled logic must not change outputs");
  350. }
  351. void testMultipleLogicScanOrderAndTraceIsolation()
  352. {
  353. VirtualRegisterRepository repository;
  354. SoftwareLogicExecutor executor;
  355. ControlLogic first = logic({
  356. rung("rung-1", {{contact("input", 0)}}, coil("output", 1))});
  357. first.id = "logic-first";
  358. first.name = "First";
  359. ControlLogic second = logic({
  360. rung("rung-1", {{contact("input", 1)}}, coil("output", 2))});
  361. second.id = "logic-second";
  362. second.name = "Second";
  363. writeBit(repository, 0, true);
  364. LogicTraceSnapshot trace;
  365. require(executor.executeScan({first, second}, repository, &trace).succeeded,
  366. "all enabled logic modules must execute in project order");
  367. require(readBit(repository, 1) && readBit(repository, 2),
  368. "a later logic module must observe an earlier module write in one scan");
  369. require(trace.logicValues.size() == 2U
  370. && trace.forLogic(first.id).rungValues.at("rung-1")
  371. && trace.forLogic(second.id).rungValues.at("rung-1"),
  372. "runtime traces must be partitioned by logic id when node ids repeat");
  373. ControlLogic disabled_draft;
  374. disabled_draft.id = "logic-draft";
  375. disabled_draft.name = "Draft";
  376. disabled_draft.enabled = false;
  377. disabled_draft.rungs.push_back(
  378. {"rung-1", "Draft", {}, std::nullopt, std::nullopt});
  379. require(executor.validate({first, disabled_draft}).succeeded,
  380. "a disabled incomplete logic module must not block offline execution");
  381. }
  382. void testEdgeContactsAreOneScanPulsesAndAreLogicScoped()
  383. {
  384. VirtualRegisterRepository repository;
  385. SoftwareLogicExecutor executor;
  386. const ControlLogic program = logic({
  387. rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}},
  388. coil("rising-output", 10)),
  389. rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}},
  390. coil("falling-output", 11))});
  391. writeBit(repository, 0, true);
  392. require(executor.executeScan({program}, repository).succeeded,
  393. "the first high scan must evaluate rising edge input");
  394. require(readBit(repository, 10), "rising edge must pulse on OFF to ON");
  395. require(!readBit(repository, 11), "falling edge must stay off without ON to OFF");
  396. require(executor.executeScan({program}, repository).succeeded,
  397. "a repeated high scan must succeed");
  398. require(!readBit(repository, 10), "rising edge must clear after one scan");
  399. writeBit(repository, 1, true);
  400. require(executor.executeScan({program}, repository).succeeded,
  401. "the falling edge input must establish its previous high state");
  402. writeBit(repository, 1, false);
  403. require(executor.executeScan({program}, repository).succeeded,
  404. "the first low scan after high must succeed");
  405. require(readBit(repository, 11), "falling edge must pulse on ON to OFF");
  406. require(executor.executeScan({program}, repository).succeeded,
  407. "a repeated low scan must succeed");
  408. require(!readBit(repository, 11), "falling edge must clear after one scan");
  409. ControlLogic first = logic({
  410. rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}},
  411. coil("shared-output-first", 20))});
  412. first.id = "logic-first";
  413. first.name = "First";
  414. ControlLogic second = logic({
  415. rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}},
  416. coil("shared-output-second", 21))});
  417. second.id = "logic-second";
  418. second.name = "Second";
  419. writeBit(repository, 2, true);
  420. require(executor.executeScan({first, second}, repository).succeeded,
  421. "logic-scoped edge history must support duplicate node ids");
  422. writeBit(repository, 3, true);
  423. require(executor.executeScan({first, second}, repository).succeeded,
  424. "each duplicate edge node must update its own history");
  425. require(!readBit(repository, 20) && readBit(repository, 21),
  426. "duplicate node ids in different logic modules must not share edge history");
  427. executor.resetRuntime();
  428. require(executor.executeScan({first, second}, repository).succeeded,
  429. "resetting runtime must clear edge history");
  430. require(readBit(repository, 20) && readBit(repository, 21),
  431. "a restarted runtime must treat current ON inputs as fresh rising edges");
  432. }
  433. void testMoveAndSaturatingArithmetic()
  434. {
  435. VirtualRegisterRepository repository;
  436. SoftwareLogicExecutor executor;
  437. const ControlLogic program = logic({
  438. rung(
  439. "move-rung",
  440. {{contact("execute", 0)}},
  441. move("move", constantOperand(7), 10)),
  442. rung(
  443. "add-rung",
  444. {{contact("execute-add", 0)}},
  445. arithmetic(
  446. "add",
  447. ArithmeticOperation::Add,
  448. registerOperand(10),
  449. constantOperand(32767),
  450. 11)),
  451. rung(
  452. "sub-rung",
  453. {{contact("execute-sub", 0)}},
  454. arithmetic(
  455. "sub",
  456. ArithmeticOperation::Subtract,
  457. constantOperand(-32768),
  458. constantOperand(1),
  459. 12))});
  460. LogicTraceSnapshot trace;
  461. require(executor.executeScan({program}, repository, &trace).succeeded,
  462. "inactive data instructions must scan successfully");
  463. require(readWord(repository, 10) == 0,
  464. "MOVE must not write while its rung is false");
  465. writeBit(repository, 0, true);
  466. require(executor.executeScan({program}, repository, &trace).succeeded,
  467. "active data instructions must scan successfully");
  468. require(readWord(repository, 10) == 7,
  469. "MOVE must copy a constant into the destination D register");
  470. require(readWord(repository, 11) == 32767
  471. && trace.wordValues.at("add").overflow,
  472. "ADD must saturate positive overflow and expose the overflow trace");
  473. require(readWord(repository, 12) == -32768
  474. && trace.wordValues.at("sub").overflow,
  475. "SUB must saturate negative overflow and expose the overflow trace");
  476. const ControlLogic repeated_add = logic({
  477. rung(
  478. "repeated-add-rung",
  479. {{contact("repeated-add-input", 1)}},
  480. arithmetic(
  481. "repeated-add",
  482. ArithmeticOperation::Add,
  483. registerOperand(20),
  484. constantOperand(1),
  485. 20))});
  486. writeBit(repository, 1, true);
  487. require(executor.executeScan({repeated_add}, repository).succeeded
  488. && executor.executeScan({repeated_add}, repository).succeeded,
  489. "ADD must execute on every scan while its rung remains true");
  490. require(readWord(repository, 20) == 2,
  491. "ADD with the same source and destination must accumulate by scan");
  492. }
  493. void testSetResetPairOnSameAddress()
  494. {
  495. VirtualRegisterRepository repository;
  496. SoftwareLogicExecutor executor;
  497. const ControlLogic program = logic({
  498. rung("low-level-rung",
  499. {{comparison("low-level", 0,
  500. ComparisonOperator::LessThanOrEqual, 30)}},
  501. coil("pump-set", 20, CoilMode::Set)),
  502. rung("high-level-rung",
  503. {{comparison("high-level", 0,
  504. ComparisonOperator::GreaterThanOrEqual, 80)}},
  505. coil("pump-reset", 20, CoilMode::Reset))});
  506. writeWord(repository, 0, 20);
  507. require(executor.executeScan({program}, repository).succeeded,
  508. "set/reset pair scan must succeed at the low limit");
  509. require(readBit(repository, 20), "low level must latch the pump on");
  510. writeWord(repository, 0, 50);
  511. require(executor.executeScan({program}, repository).succeeded,
  512. "set/reset pair scan must succeed inside the deadband");
  513. require(readBit(repository, 20), "deadband must retain the latched on state");
  514. writeWord(repository, 0, 90);
  515. require(executor.executeScan({program}, repository).succeeded,
  516. "set/reset pair scan must succeed at the high limit");
  517. require(!readBit(repository, 20), "high level must reset the pump");
  518. writeWord(repository, 0, 50);
  519. require(executor.executeScan({program}, repository).succeeded,
  520. "set/reset pair rescan must succeed inside the deadband");
  521. require(!readBit(repository, 20), "deadband must retain the reset state");
  522. }
  523. void testConflictingCoilsAreRejected()
  524. {
  525. SoftwareLogicExecutor executor;
  526. const ControlLogic normal_and_set = logic({
  527. rung("normal-rung", {{contact("normal-input", 0)}}, coil("normal", 8)),
  528. rung("set-rung", {{contact("set-input", 1)}}, coil("set", 8, CoilMode::Set))});
  529. const LogicScanResult set_result = executor.validate({normal_and_set});
  530. require(!set_result.succeeded
  531. && set_result.error == LogicScanError::ConflictingOutput,
  532. "normal and set coils for one address must be rejected");
  533. require(set_result.rungId == "set-rung" && set_result.nodeId == "set",
  534. "conflict error must identify the offending rung and node");
  535. const ControlLogic reset_and_normal = logic({
  536. rung("reset-rung", {{contact("reset-input", 0)}},
  537. coil("reset", 9, CoilMode::Reset)),
  538. rung("normal-rung", {{contact("normal-input", 1)}}, coil("normal", 9))});
  539. const LogicScanResult reset_result = executor.validate({reset_and_normal});
  540. require(!reset_result.succeeded
  541. && reset_result.error == LogicScanError::ConflictingOutput,
  542. "reset and normal coils for one address must be rejected");
  543. }
  544. void testHmiSimulationClosedLoop()
  545. {
  546. VirtualRegisterRepository repository;
  547. HmiRuntimeService hmi(repository);
  548. SoftwareLogicExecutor executor;
  549. HmiControl start;
  550. start.id = "start-button";
  551. start.type = HmiControlType::Button;
  552. start.bounds = {0, 0, 80, 30};
  553. start.text = "start";
  554. start.binding = RegisterAddress{RegisterArea::M, 0};
  555. start.buttonOperation = HmiButtonOperation::MomentaryOn;
  556. HmiControl indicator;
  557. indicator.id = "run-indicator";
  558. indicator.type = HmiControlType::Indicator;
  559. indicator.bounds = {0, 40, 80, 30};
  560. indicator.text = "run";
  561. indicator.binding = RegisterAddress{RegisterArea::M, 2};
  562. const ControlLogic program = logic({
  563. rung("hold-rung",
  564. {{contact("stop", 1, ContactMode::NormallyClosed)},
  565. {contact("start", 0), contact("feedback", 2)}},
  566. coil("run", 2))});
  567. require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded,
  568. "HMI start button press must write virtual M");
  569. require(executor.executeScan({program}, repository).succeeded,
  570. "closed-loop scan must succeed");
  571. require(hmi.readControl(indicator).bit_value,
  572. "HMI indicator must observe the logic output");
  573. require(hmi.operateButton(start, HmiButtonEvent::Released).succeeded,
  574. "HMI start button release must write virtual M");
  575. require(executor.executeScan({program}, repository).succeeded, "holding scan must succeed");
  576. require(hmi.readControl(indicator).bit_value,
  577. "feedback contact must hold the output after start turns off");
  578. writeBit(repository, 1, true);
  579. require(executor.executeScan({program}, repository).succeeded, "stop scan must succeed");
  580. require(!hmi.readControl(indicator).bit_value,
  581. "stop contact must clear the HMI run indication");
  582. }
  583. void testSimulationLifecycleSnapshotAndFault()
  584. {
  585. VirtualRegisterRepository repository;
  586. OfflineSimulationService simulation(repository);
  587. ControlLogic program = logic({
  588. rung("snapshot-rung", {{contact("input", 0)}}, coil("output", 1))});
  589. writeBit(repository, 0, true);
  590. require(simulation.start({program}).succeeded, "simulation must start");
  591. require(simulation.state() == SimulationState::Running, "simulation must report running");
  592. require(!readBit(repository, 0), "starting a session must clear virtual registers");
  593. require(simulation.scanIntervalMs() == 50, "default scan interval must be 50 ms");
  594. writeBit(repository, 0, true);
  595. program.rungs.front().output = coil("changed-output", 9);
  596. require(simulation.executeOnce().succeeded, "manual lifecycle scan must succeed");
  597. require(readBit(repository, 1), "simulation must use its start-time snapshot");
  598. require(simulation.successfulScanCount() == 1, "successful scans must be counted");
  599. simulation.stop();
  600. require(simulation.state() == SimulationState::Stopped, "simulation must stop");
  601. require(simulation.start({program}).succeeded, "stopped simulation must support restart");
  602. require(!readBit(repository, 1), "new session must clear prior output values");
  603. simulation.stop();
  604. }
  605. class FailingVirtualRegisterRepository final : public VirtualRegisterRepository
  606. {
  607. public:
  608. BitReadResult readBit(const RegisterAddress &) const override
  609. {
  610. return {false, false, RegisterError::Unavailable};
  611. }
  612. };
  613. void testRepositoryFailureEntersFaultState()
  614. {
  615. FailingVirtualRegisterRepository repository;
  616. OfflineSimulationService simulation(repository);
  617. const ControlLogic program = logic({
  618. rung("fault-rung", {{contact("fault-input", 0)}}, coil("fault-output", 1))});
  619. require(simulation.start({program}).succeeded,
  620. "valid logic must pass simulation startup");
  621. const LogicScanResult result = simulation.executeOnce();
  622. require(!result.succeeded && result.error == LogicScanError::RegisterReadFailed,
  623. "repository read failure must fail the scan");
  624. require(simulation.state() == SimulationState::Faulted,
  625. "repository failure must enter the fault state");
  626. require(simulation.lastError().logicId == "logic-1"
  627. && simulation.lastError().rungId == "fault-rung"
  628. && simulation.lastError().nodeId == "fault-input",
  629. "fault feedback must retain logic, rung and node context");
  630. }
  631. } // namespace
  632. int main(int argc, char *argv[])
  633. {
  634. QCoreApplication application(argc, argv);
  635. try
  636. {
  637. testSeriesParallelContactsAndSequentialVisibility();
  638. testNestedSeriesParallelExpression();
  639. testUnconditionalCoil();
  640. testWirePassThroughAndPowerTrace();
  641. testAllComparisons();
  642. testSetResetAndDisabledLogic();
  643. testMultipleLogicScanOrderAndTraceIsolation();
  644. testEdgeContactsAreOneScanPulsesAndAreLogicScoped();
  645. testMoveAndSaturatingArithmetic();
  646. testSetResetPairOnSameAddress();
  647. testConflictingCoilsAreRejected();
  648. testHmiSimulationClosedLoop();
  649. testSimulationLifecycleSnapshotAndFault();
  650. testRepositoryFailureEntersFaultState();
  651. }
  652. catch (const std::exception &error)
  653. {
  654. std::cerr << "offline simulation service tests failed: " << error.what() << '\n';
  655. return 1;
  656. }
  657. std::cout << "offline simulation service tests passed\n";
  658. return 0;
  659. }