综合平台编程器项目的远程存储
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

734 linhas
29 KiB

  1. #include "domain/control_logic_model.h"
  2. #include "domain/hmi_control_registry.h"
  3. #include "domain/hmi_model.h"
  4. #include "domain/project_model.h"
  5. #include "domain/register_address.h"
  6. #include "domain/register_repository.h"
  7. #include "domain/runtime_state.h"
  8. #include <cstdint>
  9. #include <exception>
  10. #include <iostream>
  11. #include <set>
  12. #include <stdexcept>
  13. #include <string>
  14. namespace {
  15. void require(bool condition, const std::string &message)
  16. {
  17. if (!condition)
  18. {
  19. throw std::runtime_error(message);
  20. }
  21. }
  22. void testRegisterAddressBoundaries()
  23. {
  24. // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径
  25. require(RegisterAddress{RegisterArea::M, 0}.isValid(),
  26. "M0 must be valid");
  27. require(RegisterAddress{RegisterArea::D, 4000}.isValid(),
  28. "D4000 must be valid");
  29. require(!(RegisterAddress{RegisterArea::M, -1}.isValid()),
  30. "negative register index must be rejected");
  31. require(!(RegisterAddress{RegisterArea::D, 4001}.isValid()),
  32. "register index above 4000 must be rejected");
  33. require(!(RegisterAddress{static_cast<RegisterArea>(99), 0}.isValid()),
  34. "unknown register area must be rejected");
  35. }
  36. void testRegisterAddressParsing()
  37. {
  38. const RegisterAddressParseResult m0 = parseRegisterAddress(" m0 ");
  39. require(m0.succeeded && m0.address == RegisterAddress{RegisterArea::M, 0},
  40. "register parser must trim and normalize lowercase M addresses");
  41. const RegisterAddressParseResult d4000 = parseRegisterAddress("D4000");
  42. require(d4000.succeeded
  43. && d4000.address == RegisterAddress{RegisterArea::D, 4000},
  44. "register parser must accept the maximum D address");
  45. require(parseRegisterAddress("").error == RegisterAddressParseError::Empty,
  46. "register parser must distinguish empty input");
  47. require(parseRegisterAddress("X0").error
  48. == RegisterAddressParseError::UnsupportedArea,
  49. "register parser must reject unsupported areas");
  50. require(parseRegisterAddress("M1.0").error
  51. == RegisterAddressParseError::InvalidFormat,
  52. "register parser must reject non-decimal indices");
  53. require(parseRegisterAddress("D4001").error
  54. == RegisterAddressParseError::OutOfRange,
  55. "register parser must reject addresses above the project range");
  56. }
  57. void testRegisterRepositorySeparatesAreas()
  58. {
  59. // 验证离线仓库不会把 M 位和 D 字交叉解释
  60. VirtualRegisterRepository repository;
  61. const RegisterAddress m0{RegisterArea::M, 0};
  62. const RegisterAddress d0{RegisterArea::D, 0};
  63. require(repository.writeBit(m0, true).succeeded, "M bit write must succeed");
  64. require(repository.readBit(m0).value, "M bit read must return written value");
  65. require(repository.writeWord(d0, static_cast<std::int16_t>(-123)).succeeded,
  66. "D word write must succeed");
  67. require(repository.readWord(d0).value == -123, "D word read must return written value");
  68. require(repository.readBit(d0).error == RegisterError::AreaMismatch,
  69. "D address must not be read as a bit");
  70. require(repository.readWord(m0).error == RegisterError::AreaMismatch,
  71. "M address must not be read as a word");
  72. }
  73. void testHmiControlRegistryCompleteness()
  74. {
  75. std::set<const HmiControlDescriptor *> descriptors;
  76. std::set<std::string> storage_names;
  77. std::set<std::string> id_prefixes;
  78. const std::size_t control_type_count =
  79. static_cast<std::size_t>(HmiControlType::Count);
  80. for (std::size_t index = 0; index < control_type_count; ++index)
  81. {
  82. const HmiControlType type = static_cast<HmiControlType>(index);
  83. const HmiControlDescriptor *descriptor =
  84. findHmiControlDescriptor(type);
  85. require(descriptor != nullptr,
  86. "every HMI control type must have one descriptor");
  87. require(descriptor->type == type,
  88. "HMI type lookup must return the requested descriptor");
  89. require(descriptors.emplace(descriptor).second,
  90. "each HMI control type must resolve to a different descriptor");
  91. require(descriptor->storageName != nullptr
  92. && descriptor->storageName[0] != '\0',
  93. "HMI storage names must not be empty");
  94. require(descriptor->displayName != nullptr
  95. && descriptor->displayName[0] != '\0',
  96. "HMI display names must not be empty");
  97. require(descriptor->idPrefix != nullptr
  98. && descriptor->idPrefix[0] != '\0',
  99. "HMI id prefixes must not be empty");
  100. require(descriptor->defaultText != nullptr,
  101. "HMI default text must not be null");
  102. require(descriptor->defaultBounds.width > 0
  103. && descriptor->defaultBounds.height > 0,
  104. "HMI default bounds must have a positive size");
  105. require(findHmiControlDescriptor(descriptor->storageName) == descriptor,
  106. "HMI storage name lookup must return its registered descriptor");
  107. require(storage_names.emplace(descriptor->storageName).second,
  108. "HMI storage names must be unique");
  109. require(id_prefixes.emplace(descriptor->idPrefix).second,
  110. "HMI id prefixes must be unique");
  111. const std::optional<RegisterArea> binding_area =
  112. hmiBindingArea(descriptor->bindingKind);
  113. switch (descriptor->runtimeValueKind)
  114. {
  115. case HmiRuntimeValueKind::Bit:
  116. {
  117. require(descriptor->bindingKind == HmiBindingKind::Bit
  118. && binding_area == RegisterArea::M,
  119. "bit runtime controls must bind to the M area");
  120. break;
  121. }
  122. case HmiRuntimeValueKind::Word:
  123. {
  124. require(descriptor->bindingKind == HmiBindingKind::Word
  125. && binding_area == RegisterArea::D,
  126. "word runtime controls must bind to the D area");
  127. break;
  128. }
  129. case HmiRuntimeValueKind::None:
  130. {
  131. require(descriptor->bindingKind == HmiBindingKind::None
  132. && !binding_area.has_value(),
  133. "static controls must not declare a register binding");
  134. break;
  135. }
  136. }
  137. require(descriptor->requiresBindingForRunning
  138. == (descriptor->runtimeValueKind
  139. != HmiRuntimeValueKind::None),
  140. "runtime value controls must require a configured binding");
  141. }
  142. require(findHmiControlDescriptor(HmiControlType::Count) == nullptr,
  143. "the HMI control count marker must not be registered");
  144. require(findHmiControlDescriptor(static_cast<HmiControlType>(99)) == nullptr,
  145. "unknown HMI control types must not resolve");
  146. require(findHmiControlDescriptor("unknown") == nullptr,
  147. "unknown HMI storage names must not resolve");
  148. require(hmiBindingArea(HmiBindingKind::Bit) == RegisterArea::M,
  149. "bit bindings must use the M area");
  150. require(hmiBindingArea(HmiBindingKind::Word) == RegisterArea::D,
  151. "word bindings must use the D area");
  152. require(!hmiBindingArea(HmiBindingKind::None).has_value(),
  153. "controls without bindings must not resolve a register area");
  154. }
  155. void testProgressBarConfigurationBoundaries()
  156. {
  157. const HmiProgressBarConfig config{-20, 80, true};
  158. require(config.isValid(), "a progress bar range must accept increasing bounds");
  159. require(config.percentageForValue(-20) == 0,
  160. "progress bar minimum must map to zero percent");
  161. require(config.percentageForValue(30) == 50,
  162. "progress bar midpoint must map to fifty percent");
  163. require(config.percentageForValue(80) == 100,
  164. "progress bar maximum must map to one hundred percent");
  165. require(config.percentageForValue(-100) == 0
  166. && config.percentageForValue(100) == 100,
  167. "progress bar percentages must clamp values outside the range");
  168. HmiProgressBarConfig invalid_range{10, 10, true};
  169. require(!invalid_range.isValid()
  170. && invalid_range.percentageForValue(10) == 0,
  171. "progress bar must reject an empty range defensively");
  172. HmiControl progress;
  173. progress.id = "progress";
  174. progress.type = HmiControlType::ProgressBar;
  175. progress.progressBar = config;
  176. require(progress.validate(),
  177. "an unbound progress bar with valid configuration must remain a valid draft");
  178. require(!progress.isConfigured(),
  179. "an unbound progress bar must not be ready for running");
  180. progress.binding = RegisterAddress{RegisterArea::D, 0};
  181. require(progress.validate() && progress.isConfigured(),
  182. "a progress bar with a D binding must be ready for running");
  183. progress.progressBar->maximumValue = progress.progressBar->minimumValue;
  184. require(!progress.validate(),
  185. "a progress bar with an invalid range must be rejected");
  186. progress.progressBar = config;
  187. progress.binding = RegisterAddress{RegisterArea::M, 0};
  188. require(!progress.validate(),
  189. "a progress bar must reject an M binding");
  190. }
  191. Project makeValidProject()
  192. {
  193. // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线
  194. HmiControl start_button;
  195. start_button.id = "start-button";
  196. start_button.type = HmiControlType::Button;
  197. start_button.text = "Start";
  198. start_button.binding = {RegisterArea::M, 0};
  199. HmiPage page;
  200. page.id = "main-page";
  201. page.name = "Main";
  202. page.controls.push_back(start_button);
  203. LogicNode contact;
  204. contact.id = "start-contact";
  205. contact.config = ContactNodeConfig{
  206. RegisterAddress{RegisterArea::M, 0},
  207. ContactMode::NormallyOpen};
  208. LogicNode coil;
  209. coil.id = "run-coil";
  210. coil.config = CoilNodeConfig{
  211. RegisterAddress{RegisterArea::M, 1},
  212. CoilMode::Normal};
  213. ControlLogic logic;
  214. logic.id = "start-logic";
  215. logic.name = "Start logic";
  216. LadderRung rung;
  217. rung.id = "rung-1";
  218. rung.name = "Network 1";
  219. rung.condition = ConditionExpression::fromNode(contact);
  220. rung.output = coil;
  221. logic.rungs.push_back(rung);
  222. Project project;
  223. project.metadata = {"sample-project", "Sample project", "1.0"};
  224. project.hmiPages.push_back(page);
  225. project.initialHmiPageId = page.id;
  226. project.controlLogics.push_back(logic);
  227. return project;
  228. }
  229. void testMultiPageAndLogicDomainRules()
  230. {
  231. Project project = makeValidProject();
  232. HmiPage settings;
  233. settings.id = "settings-page";
  234. settings.name = "Settings";
  235. project.hmiPages.push_back(settings);
  236. HmiControl label;
  237. label.id = "title";
  238. label.type = HmiControlType::Label;
  239. label.text = "Machine";
  240. project.hmiPages.front().controls.push_back(label);
  241. require(project.validate(), "an unbound label must be a valid static control");
  242. project.hmiPages.front().controls.back().binding =
  243. RegisterAddress{RegisterArea::M, 10};
  244. require(!project.validate(), "labels must reject register bindings");
  245. project = makeValidProject();
  246. project.hmiPages.push_back(settings);
  247. HmiControl jump;
  248. jump.id = "settings-jump";
  249. jump.type = HmiControlType::PageJump;
  250. jump.text = "Settings";
  251. jump.pageJump = HmiPageJumpConfig{settings.id};
  252. project.hmiPages.front().controls.push_back(jump);
  253. require(project.validate() && project.validateForRunning(),
  254. "a page jump must resolve its target by stable page id");
  255. project.hmiPages.front().controls.back().pageJump->targetPageId = "missing";
  256. require(!project.validate(), "a page jump must reject a missing target page");
  257. project = makeValidProject();
  258. project.initialHmiPageId = "missing";
  259. require(!project.validate(), "the initial HMI page id must resolve to a page");
  260. project = makeValidProject();
  261. HmiPage duplicate_name = settings;
  262. duplicate_name.name = project.hmiPages.front().name;
  263. project.hmiPages.push_back(duplicate_name);
  264. require(!project.validate(), "HMI page names must be unique");
  265. project = makeValidProject();
  266. ControlLogic disabled_draft;
  267. disabled_draft.id = "draft-logic";
  268. disabled_draft.name = "Draft logic";
  269. disabled_draft.enabled = false;
  270. disabled_draft.rungs.push_back(
  271. {"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
  272. project.controlLogics.push_back(disabled_draft);
  273. require(project.validateForRunning(),
  274. "a disabled draft logic must not block offline running");
  275. project.controlLogics.back().name = project.controlLogics.front().name;
  276. require(!project.validate(), "control logic names must be unique");
  277. project = makeValidProject();
  278. project.hmiPages.front().controls.front().type =
  279. static_cast<HmiControlType>(99);
  280. project.hmiPages.front().controls.front().binding.reset();
  281. require(!project.validate(), "unknown HMI control types must be rejected");
  282. }
  283. void testLogicNodeConfigurationBoundaries()
  284. {
  285. // 触点只能绑定 M 区,数值比较只能绑定 D 区
  286. LogicNode contact;
  287. contact.id = "contact";
  288. contact.config = ContactNodeConfig{
  289. RegisterAddress{RegisterArea::M, 0},
  290. ContactMode::NormallyOpen};
  291. require(contact.validate(), "contact node bound to M address must be valid");
  292. contact.config = ContactNodeConfig{
  293. RegisterAddress{RegisterArea::D, 0},
  294. ContactMode::NormallyOpen};
  295. require(!contact.validate(), "contact node bound to D address must be rejected");
  296. LogicNode comparison;
  297. comparison.id = "comparison";
  298. comparison.config = CompareNodeConfig{
  299. RegisterAddress{RegisterArea::D, 0},
  300. ComparisonOperator::GreaterThan,
  301. static_cast<std::int16_t>(100)};
  302. require(comparison.validate(), "comparison node bound to D address must be valid");
  303. }
  304. void testTimerAndCommentBoundaries()
  305. {
  306. LogicNode edge;
  307. edge.id = "edge";
  308. edge.config = EdgeContactNodeConfig{
  309. RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising};
  310. require(edge.validate(), "a valid rising edge contact must pass validation");
  311. LogicNode timer_contact;
  312. timer_contact.id = "timer-contact";
  313. timer_contact.config = TimerContactNodeConfig{
  314. TimerAddress{4000}, ContactMode::NormallyOpen};
  315. require(timer_contact.validate(), "T4000 must be a valid timer contact");
  316. timer_contact.config = TimerContactNodeConfig{
  317. TimerAddress{-1}, ContactMode::NormallyOpen};
  318. require(!timer_contact.validate(), "a negative T address must be rejected");
  319. LogicNode ton;
  320. ton.id = "ton";
  321. ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs};
  322. require(ton.validate(), "the minimum TON preset must be valid");
  323. ton.config = TonNodeConfig{TimerAddress{0}, 0};
  324. require(!ton.validate(), "a zero TON preset must be rejected");
  325. ton.config = TonNodeConfig{
  326. TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1};
  327. require(!ton.validate(), "an oversized TON preset must be rejected");
  328. RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"};
  329. require(comment.validate(), "a nonblank register comment must be valid");
  330. comment.text = " \t";
  331. require(!comment.validate(), "a blank register comment must be rejected");
  332. Project project = makeValidProject();
  333. project.registerComments = {
  334. {RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
  335. {RegisterAddress{RegisterArea::M, 0}, "重复地址"}};
  336. require(!project.validate(), "duplicate register comments must be rejected");
  337. }
  338. LadderRung makeTimerRung(
  339. const std::string &rung_id,
  340. const std::string &condition_id,
  341. const std::string &output_id,
  342. int timer_index,
  343. int preset_ms)
  344. {
  345. LogicNode condition;
  346. condition.id = condition_id;
  347. condition.config = ContactNodeConfig{
  348. RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen};
  349. LogicNode output;
  350. output.id = output_id;
  351. output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms};
  352. LadderRung rung;
  353. rung.id = rung_id;
  354. rung.name = rung_id;
  355. rung.condition = ConditionExpression::fromNode(condition);
  356. rung.output = output;
  357. return rung;
  358. }
  359. void testTimerReferencesForRunning()
  360. {
  361. ControlLogic valid;
  362. valid.id = "timer-valid";
  363. valid.name = "Timer valid";
  364. valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100));
  365. require(validateTimerReferencesForRunning({valid}),
  366. "a timer contact-free TON network must pass timer reference validation");
  367. ControlLogic duplicate = valid;
  368. duplicate.id = "timer-duplicate";
  369. duplicate.name = "Timer duplicate";
  370. duplicate.rungs.front().output->id = "ton-duplicate";
  371. require(!validateTimerReferencesForRunning({valid, duplicate}),
  372. "the same T must not have two enabled TON drivers");
  373. ControlLogic missing;
  374. missing.id = "timer-missing";
  375. missing.name = "Timer missing";
  376. LogicNode contact;
  377. contact.id = "missing-contact";
  378. contact.config = TimerContactNodeConfig{
  379. TimerAddress{7}, ContactMode::NormallyOpen};
  380. LogicNode output;
  381. output.id = "missing-coil";
  382. output.config = CoilNodeConfig{
  383. RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
  384. LadderRung missing_rung;
  385. missing_rung.id = "missing-rung";
  386. missing_rung.name = "Missing rung";
  387. missing_rung.condition = ConditionExpression::fromNode(contact);
  388. missing_rung.output = output;
  389. missing.rungs.push_back(missing_rung);
  390. require(!validateTimerReferencesForRunning({missing}),
  391. "a T contact without an enabled TON driver must be rejected");
  392. missing.enabled = false;
  393. require(validateTimerReferencesForRunning({missing}),
  394. "disabled timer drafts must not block runtime timer validation");
  395. }
  396. LadderRung makeCounterRung(
  397. const std::string &rung_id,
  398. const std::string &output_id,
  399. int counter_index)
  400. {
  401. LogicNode condition;
  402. condition.id = rung_id + "-input";
  403. condition.config = ContactNodeConfig{
  404. RegisterAddress{RegisterArea::M, counter_index},
  405. ContactMode::NormallyOpen};
  406. LogicNode output;
  407. output.id = output_id;
  408. output.config = CounterNodeConfig{
  409. CounterAddress{counter_index},
  410. CounterMode::Up,
  411. RegisterAddress{RegisterArea::D, counter_index},
  412. WordOperand{
  413. WordOperandKind::Constant,
  414. RegisterAddress{RegisterArea::D, 0},
  415. 10},
  416. RegisterAddress{RegisterArea::M, counter_index + 1}};
  417. LadderRung rung;
  418. rung.id = rung_id;
  419. rung.name = rung_id;
  420. rung.condition = ConditionExpression::fromNode(condition);
  421. rung.output = output;
  422. return rung;
  423. }
  424. void testCounterAndDataInstructionBoundaries()
  425. {
  426. require(CounterAddress{0}.isValid() && CounterAddress{4000}.isValid(),
  427. "C0 and C4000 must be valid counter resources");
  428. require(!CounterAddress{-1}.isValid() && !CounterAddress{4001}.isValid(),
  429. "counter resources outside 0 through 4000 must be rejected");
  430. LogicNode counter;
  431. counter.id = "counter";
  432. counter.config = CounterNodeConfig{
  433. CounterAddress{0},
  434. CounterMode::Up,
  435. RegisterAddress{RegisterArea::D, 10},
  436. WordOperand{
  437. WordOperandKind::Register,
  438. RegisterAddress{RegisterArea::D, 11},
  439. 0},
  440. RegisterAddress{RegisterArea::M, 12}};
  441. require(counter.validate(),
  442. "a counter with C identity and external M/D addresses must be valid");
  443. CounterNodeConfig invalid_counter = std::get<CounterNodeConfig>(counter.config);
  444. invalid_counter.currentValueAddress = RegisterAddress{RegisterArea::M, 10};
  445. counter.config = invalid_counter;
  446. require(!counter.validate(), "counter CV must reject M addresses");
  447. LogicNode move;
  448. move.id = "move";
  449. move.config = MoveNodeConfig{
  450. WordOperand{
  451. WordOperandKind::Constant,
  452. RegisterAddress{RegisterArea::D, 0},
  453. -100},
  454. RegisterAddress{RegisterArea::D, 20}};
  455. require(move.validate() && move.isOutput(),
  456. "MOVE with a constant source and D destination must be a valid output");
  457. LogicNode add;
  458. add.id = "add";
  459. add.config = ArithmeticNodeConfig{
  460. ArithmeticOperation::Add,
  461. WordOperand{
  462. WordOperandKind::Register,
  463. RegisterAddress{RegisterArea::D, 20},
  464. 0},
  465. WordOperand{
  466. WordOperandKind::Constant,
  467. RegisterAddress{RegisterArea::D, 0},
  468. 1},
  469. RegisterAddress{RegisterArea::D, 20}};
  470. require(add.validate() && add.isOutput(),
  471. "ADD must allow the same D register as source and destination");
  472. ControlLogic valid;
  473. valid.id = "counter-valid";
  474. valid.name = "Counter valid";
  475. valid.rungs.push_back(makeCounterRung("counter-rung", "ctu-0", 0));
  476. require(validateCounterReferencesForRunning({valid}),
  477. "a counter output without contacts must pass reference validation");
  478. ControlLogic duplicate = valid;
  479. duplicate.id = "counter-duplicate";
  480. duplicate.name = "Counter duplicate";
  481. duplicate.rungs.front().output->id = "ctu-duplicate";
  482. require(!validateCounterReferencesForRunning({valid, duplicate}),
  483. "the same C resource must not have multiple enabled drivers");
  484. ControlLogic missing;
  485. missing.id = "counter-missing";
  486. missing.name = "Counter missing";
  487. LogicNode missing_contact;
  488. missing_contact.id = "missing-counter-contact";
  489. missing_contact.config = CounterContactNodeConfig{
  490. CounterAddress{7}, ContactMode::NormallyOpen};
  491. LogicNode output;
  492. output.id = "missing-counter-coil";
  493. output.config = CoilNodeConfig{
  494. RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
  495. LadderRung missing_rung;
  496. missing_rung.id = "missing-counter-rung";
  497. missing_rung.name = "Missing counter rung";
  498. missing_rung.condition = ConditionExpression::fromNode(missing_contact);
  499. missing_rung.output = output;
  500. missing.rungs.push_back(missing_rung);
  501. require(!validateCounterReferencesForRunning({missing}),
  502. "a C contact without an enabled counter driver must be rejected");
  503. }
  504. void testLadderLogicBoundaries()
  505. {
  506. LogicNode stop;
  507. stop.id = "stop";
  508. stop.config = ContactNodeConfig{
  509. RegisterAddress{RegisterArea::M, 1},
  510. ContactMode::NormallyClosed};
  511. LogicNode start;
  512. start.id = "start";
  513. start.config = ContactNodeConfig{
  514. RegisterAddress{RegisterArea::M, 0},
  515. ContactMode::NormallyOpen};
  516. LogicNode run_contact;
  517. run_contact.id = "run-contact";
  518. run_contact.config = ContactNodeConfig{
  519. RegisterAddress{RegisterArea::M, 1},
  520. ContactMode::NormallyOpen};
  521. LogicNode coil;
  522. coil.id = "run-coil";
  523. coil.config = CoilNodeConfig{
  524. RegisterAddress{RegisterArea::M, 1},
  525. CoilMode::Normal};
  526. ControlLogic logic;
  527. logic.id = "hold-logic";
  528. logic.name = "Hold logic";
  529. ConditionExpression start_parallel;
  530. start_parallel.id = "parallel-start";
  531. start_parallel.kind = ConditionExpressionKind::Parallel;
  532. start_parallel.children = {
  533. ConditionExpression::fromNode(start),
  534. ConditionExpression::fromNode(run_contact)};
  535. ConditionExpression root;
  536. root.id = "series-root";
  537. root.kind = ConditionExpressionKind::Series;
  538. root.children = {
  539. ConditionExpression::fromNode(stop),
  540. start_parallel};
  541. LadderRung rung;
  542. rung.id = "rung-1";
  543. rung.name = "Self hold";
  544. rung.condition = root;
  545. rung.output = coil;
  546. logic.rungs.push_back(rung);
  547. require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid");
  548. logic.rungs.front().condition->children.front() =
  549. ConditionExpression::fromNode(coil);
  550. require(!logic.validate(), "a ladder condition expression must reject coils");
  551. logic.rungs.front().condition = root;
  552. logic.rungs.front().output = start;
  553. require(!logic.validate(), "a ladder output must be a coil");
  554. logic.rungs.front().output.reset();
  555. require(logic.validate(), "incomplete ladder may remain in an editable draft");
  556. require(!logic.validateForRunning(),
  557. "conditions without an output must block runtime validation");
  558. LadderRung empty_rung{
  559. "rung-empty", "Empty network", {}, std::nullopt, std::nullopt};
  560. require(empty_rung.validate(), "an empty editing network must be valid");
  561. empty_rung.output = coil;
  562. require(empty_rung.validate(), "output-only network may remain in an editable draft");
  563. require(!empty_rung.validateForRunning(),
  564. "an output without conditions must block runtime validation");
  565. logic.rungs.front().output = coil;
  566. logic.rungs.front().condition = root;
  567. logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id;
  568. require(!logic.validate(), "logic node ids must be unique");
  569. ConditionExpression nested_parallel;
  570. nested_parallel.id = "parallel-nested";
  571. nested_parallel.kind = ConditionExpressionKind::Parallel;
  572. nested_parallel.children = {
  573. ConditionExpression::fromNode(start),
  574. root};
  575. require(nested_parallel.validate(),
  576. "nested series and parallel expressions must be valid");
  577. }
  578. void testModelsValidateBindingsAndIdentifiers()
  579. {
  580. // 聚合验证必须拒绝错误绑定、重复标识和越界控件
  581. Project project = makeValidProject();
  582. require(project.validate(), "valid project model must pass validation");
  583. project.hmiPages.front().controls.front().binding =
  584. RegisterAddress{RegisterArea::D, 0};
  585. require(!project.validate(), "button bound to D area must be rejected");
  586. project = makeValidProject();
  587. project.hmiPages.push_back(project.hmiPages.front());
  588. require(!project.validate(), "duplicate HMI page id must be rejected");
  589. project = makeValidProject();
  590. project.hmiPages.front().controls.front().bounds.x = -1;
  591. require(!project.validate(), "controls outside the page must be rejected");
  592. project = makeValidProject();
  593. project.hmiPages.front().controls.front().bounds.width = 801;
  594. require(!project.validate(), "controls wider than the page must be rejected");
  595. project = makeValidProject();
  596. project.hmiPages.front().controls.front().properties.emplace("", "value");
  597. require(!project.validate(), "empty HMI property names must be rejected");
  598. project = makeValidProject();
  599. project.hmiPages.front().controls.front().binding.reset();
  600. require(project.validate(), "unbound HMI control must be accepted in a draft");
  601. require(!project.validateForRunning(),
  602. "unbound HMI control must block runtime validation");
  603. project = makeValidProject();
  604. project.controlLogics.front().rungs.front().output->configured = false;
  605. require(project.validate(), "unconfigured ladder node must be accepted in a draft");
  606. require(!project.validateForRunning(),
  607. "unconfigured ladder node must block runtime validation");
  608. }
  609. void testRuntimeStateBoundaries()
  610. {
  611. // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件
  612. RuntimeState state;
  613. require(state.policy().allowsProjectEditing, "editing mode must allow project editing");
  614. require(state.enterOfflineRunning().succeeded, "editing may enter offline running");
  615. require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers");
  616. require(state.policy().runsLogicExecutor, "offline mode must run logic executor");
  617. require(state.enterOnlineRunning(true).error
  618. == ModeTransitionError::MustReturnToEditing,
  619. "offline mode must not directly enter online mode");
  620. require(state.enterEditing().succeeded, "offline mode may return to editing");
  621. require(state.enterOnlineRunning(false).error
  622. == ModeTransitionError::InitialPlcReadRequired,
  623. "online mode must require an initial PLC read");
  624. require(state.enterOnlineRunning(true).succeeded,
  625. "editing may enter online mode after initial PLC read");
  626. require(!state.policy().runsLogicExecutor,
  627. "online mode must keep the software logic executor stopped");
  628. require(state.policy().usesPlcRegisters, "online mode must use PLC registers");
  629. }
  630. } // namespace
  631. int main()
  632. {
  633. try
  634. {
  635. // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程
  636. testRegisterAddressBoundaries();
  637. testRegisterAddressParsing();
  638. testRegisterRepositorySeparatesAreas();
  639. testHmiControlRegistryCompleteness();
  640. testProgressBarConfigurationBoundaries();
  641. testLogicNodeConfigurationBoundaries();
  642. testTimerAndCommentBoundaries();
  643. testTimerReferencesForRunning();
  644. testCounterAndDataInstructionBoundaries();
  645. testLadderLogicBoundaries();
  646. testModelsValidateBindingsAndIdentifiers();
  647. testMultiPageAndLogicDomainRules();
  648. testRuntimeStateBoundaries();
  649. }
  650. catch (const std::exception &error)
  651. {
  652. std::cerr << "domain tests failed: " << error.what() << '\n';
  653. return 1;
  654. }
  655. std::cout << "domain tests passed\n";
  656. return 0;
  657. }