综合平台编程器项目的远程存储
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

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