综合平台编程器项目的远程存储
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.
 
 
 
 

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