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

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