综合平台编程器项目的远程存储
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

884 行
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. Project makeValidProject();
  178. void testHmiAppearancePropertyBoundaries()
  179. {
  180. // 外观属性必须在领域层拒绝格式错误,但不能影响未知扩展属性
  181. Project project = makeValidProject();
  182. HmiControl &button = project.hmiPages.front().controls.front();
  183. button.properties[HmiAppearanceProperty::kTextColor] = "#E53935";
  184. button.properties[HmiAppearanceProperty::kFontSize] = "18";
  185. button.properties[HmiAppearanceProperty::kFontBold] = "true";
  186. button.properties[HmiAppearanceProperty::kFontItalic] = "false";
  187. require(project.validate(), "valid HMI appearance properties must pass validation");
  188. button.properties[HmiAppearanceProperty::kTextColor] = "red";
  189. require(!project.validate(), "text colors must use the #RRGGBB format");
  190. project = makeValidProject();
  191. HmiControl &font_control = project.hmiPages.front().controls.front();
  192. font_control.properties[HmiAppearanceProperty::kFontSize] = "5";
  193. require(!project.validate(), "font sizes below the minimum must be rejected");
  194. font_control.properties[HmiAppearanceProperty::kFontSize] = "73";
  195. require(!project.validate(), "font sizes above the maximum must be rejected");
  196. font_control.properties[HmiAppearanceProperty::kFontSize] = "large";
  197. require(!project.validate(), "non-numeric font sizes must be rejected");
  198. project = makeValidProject();
  199. HmiControl &style_control = project.hmiPages.front().controls.front();
  200. style_control.properties[HmiAppearanceProperty::kFontBold] = "yes";
  201. require(!project.validate(), "font style flags must be true or false");
  202. project = makeValidProject();
  203. project.hmiPages.front().controls.front().properties["legacyColor"] = "green";
  204. require(project.validate(), "unknown HMI extension properties must remain supported");
  205. }
  206. Project makeValidProject()
  207. {
  208. // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线
  209. HmiControl start_button;
  210. start_button.id = "start-button";
  211. start_button.type = HmiControlType::Button;
  212. start_button.text = "Start";
  213. start_button.binding = {RegisterArea::M, 0};
  214. HmiPage page;
  215. page.id = "main-page";
  216. page.name = "Main";
  217. page.controls.push_back(start_button);
  218. LogicNode contact;
  219. contact.id = "start-contact";
  220. contact.config = ContactNodeConfig{
  221. RegisterAddress{RegisterArea::M, 0},
  222. ContactMode::NormallyOpen};
  223. LogicNode coil;
  224. coil.id = "run-coil";
  225. coil.config = CoilNodeConfig{
  226. RegisterAddress{RegisterArea::M, 1},
  227. CoilMode::Normal};
  228. ControlLogic logic;
  229. logic.id = "start-logic";
  230. logic.name = "Start logic";
  231. LadderRung rung;
  232. rung.id = "rung-1";
  233. rung.name = "Network 1";
  234. ConditionExpression condition;
  235. condition.id = "start-series";
  236. condition.kind = ConditionExpressionKind::Series;
  237. condition.children = {
  238. ConditionExpression::fromNode(contact),
  239. ConditionExpression::fromWire("start-wire", 9)};
  240. rung.condition = std::move(condition);
  241. rung.output = coil;
  242. logic.rungs.push_back(rung);
  243. Project project;
  244. project.metadata = {"sample-project", "Sample project", "1.0"};
  245. project.hmiPages.push_back(page);
  246. project.initialHmiPageId = page.id;
  247. project.controlLogics.push_back(logic);
  248. return project;
  249. }
  250. void testMultiPageAndLogicDomainRules()
  251. {
  252. Project project = makeValidProject();
  253. HmiPage settings;
  254. settings.id = "settings-page";
  255. settings.name = "Settings";
  256. project.hmiPages.push_back(settings);
  257. HmiControl label;
  258. label.id = "title";
  259. label.type = HmiControlType::Label;
  260. label.text = "Machine";
  261. project.hmiPages.front().controls.push_back(label);
  262. require(project.validate(), "an unbound label must be a valid static control");
  263. project.hmiPages.front().controls.back().binding =
  264. RegisterAddress{RegisterArea::M, 10};
  265. require(!project.validate(), "labels must reject register bindings");
  266. project = makeValidProject();
  267. project.hmiPages.push_back(settings);
  268. HmiControl jump;
  269. jump.id = "settings-jump";
  270. jump.type = HmiControlType::PageJump;
  271. jump.text = "Settings";
  272. jump.pageJump = HmiPageJumpConfig{settings.id};
  273. project.hmiPages.front().controls.push_back(jump);
  274. require(project.validate() && project.validateForRunning(),
  275. "a page jump must resolve its target by stable page id");
  276. project.hmiPages.front().controls.back().pageJump->targetPageId = "missing";
  277. require(!project.validate(), "a page jump must reject a missing target page");
  278. project = makeValidProject();
  279. project.initialHmiPageId = "missing";
  280. require(!project.validate(), "the initial HMI page id must resolve to a page");
  281. project = makeValidProject();
  282. HmiPage duplicate_name = settings;
  283. duplicate_name.name = project.hmiPages.front().name;
  284. project.hmiPages.push_back(duplicate_name);
  285. require(!project.validate(), "HMI page names must be unique");
  286. project = makeValidProject();
  287. ControlLogic disabled_draft;
  288. disabled_draft.id = "draft-logic";
  289. disabled_draft.name = "Draft logic";
  290. disabled_draft.enabled = false;
  291. disabled_draft.rungs.push_back(
  292. {"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
  293. project.controlLogics.push_back(disabled_draft);
  294. require(project.validateForRunning(),
  295. "a disabled draft logic must not block offline running");
  296. project.controlLogics.back().name = project.controlLogics.front().name;
  297. require(!project.validate(), "control logic names must be unique");
  298. project = makeValidProject();
  299. project.hmiPages.front().controls.front().type =
  300. static_cast<HmiControlType>(99);
  301. project.hmiPages.front().controls.front().binding.reset();
  302. require(!project.validate(), "unknown HMI control types must be rejected");
  303. }
  304. void testQuantityBoundaries()
  305. {
  306. Project project = makeValidProject();
  307. for (std::size_t index = 1U; index < ProjectLimits::kMaximumHmiPages; ++index)
  308. {
  309. project.hmiPages.push_back({
  310. "page-" + std::to_string(index),
  311. "Page " + std::to_string(index),
  312. 800,
  313. 400,
  314. {}});
  315. }
  316. require(project.validate(), "an HMI page count at the configured limit must be accepted");
  317. project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}});
  318. require(!project.validate(), "an HMI page count of 129 must be rejected");
  319. project = makeValidProject();
  320. project.hmiPages.clear();
  321. project.initialHmiPageId.clear();
  322. for (std::size_t page_index = 0U; page_index < 17U; ++page_index)
  323. {
  324. HmiPage page{
  325. "bulk-page-" + std::to_string(page_index),
  326. "Bulk page " + std::to_string(page_index),
  327. 800,
  328. 400,
  329. {}};
  330. for (std::size_t control_index = 0U;
  331. control_index < ProjectLimits::kMaximumHmiControlsPerPage;
  332. ++control_index)
  333. {
  334. HmiControl label;
  335. label.id = "label-" + std::to_string(control_index);
  336. label.type = HmiControlType::Label;
  337. label.bounds = {0, 0, 1, 1};
  338. label.text = "label";
  339. page.controls.push_back(std::move(label));
  340. }
  341. project.hmiPages.push_back(std::move(page));
  342. }
  343. project.initialHmiPageId = project.hmiPages.front().id;
  344. require(!project.validate(),
  345. "an HMI control count over the project limit must be rejected");
  346. project = makeValidProject();
  347. project.hmiPages.front().controls.clear();
  348. for (std::size_t index = 0U;
  349. index < ProjectLimits::kMaximumHmiControlsPerPage;
  350. ++index)
  351. {
  352. HmiControl label;
  353. label.id = "label-" + std::to_string(index);
  354. label.type = HmiControlType::Label;
  355. label.bounds = {0, 0, 1, 1};
  356. label.text = "label";
  357. project.hmiPages.front().controls.push_back(std::move(label));
  358. }
  359. require(project.validate(), "a page control count at the configured limit must be accepted");
  360. HmiControl extra_label;
  361. extra_label.id = "label-over";
  362. extra_label.type = HmiControlType::Label;
  363. extra_label.bounds = {0, 0, 1, 1};
  364. extra_label.text = "label";
  365. project.hmiPages.front().controls.push_back(std::move(extra_label));
  366. require(!project.validate(), "a page control count of 513 must be rejected");
  367. project = makeValidProject();
  368. project.controlLogics.clear();
  369. for (std::size_t logic_index = 0U; logic_index < 9U; ++logic_index)
  370. {
  371. ControlLogic logic{
  372. "bulk-logic-" + std::to_string(logic_index),
  373. "Bulk logic " + std::to_string(logic_index),
  374. {},
  375. true};
  376. for (std::size_t rung_index = 0U;
  377. rung_index < ProjectLimits::kMaximumRungsPerLogic;
  378. ++rung_index)
  379. {
  380. logic.rungs.push_back({
  381. "rung-" + std::to_string(rung_index),
  382. "Rung " + std::to_string(rung_index),
  383. {},
  384. std::nullopt,
  385. {}});
  386. }
  387. project.controlLogics.push_back(std::move(logic));
  388. }
  389. require(!project.validate(),
  390. "a ladder rung count over the project limit must be rejected");
  391. project = makeValidProject();
  392. project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth;
  393. project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight;
  394. require(project.validate(), "an HMI page size of 1600 by 800 must be accepted");
  395. project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1;
  396. require(!project.validate(), "an HMI page width of 1601 must be rejected");
  397. project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1;
  398. require(!project.validate(), "an HMI page width of 319 must be rejected");
  399. project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth;
  400. project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1;
  401. require(!project.validate(), "an HMI page height of 199 must be rejected");
  402. ConditionExpression leaf = ConditionExpression::fromNode({
  403. "depth-node-0",
  404. ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}},
  405. true});
  406. std::function<ConditionExpression(int, int *)> makeNested =
  407. [&makeNested](int depth, int *next_address)
  408. {
  409. if (depth == 1)
  410. {
  411. const int address = (*next_address)++;
  412. return ConditionExpression::fromNode({
  413. "depth-node-" + std::to_string(address),
  414. ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
  415. true});
  416. }
  417. const int address = (*next_address)++;
  418. ConditionExpression nested = makeNested(depth - 1, next_address);
  419. ConditionExpression sibling = ConditionExpression::fromNode({
  420. "depth-node-" + std::to_string(address),
  421. ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
  422. true});
  423. ConditionExpression expression;
  424. expression.id = "depth-expression-" + std::to_string(address);
  425. expression.kind = depth % 2 == 0
  426. ? ConditionExpressionKind::Parallel
  427. : ConditionExpressionKind::Series;
  428. if (expression.kind == ConditionExpressionKind::Parallel
  429. && expressionColumns(nested) > 1)
  430. {
  431. ConditionExpression padded_sibling;
  432. padded_sibling.id = "depth-padding-" + std::to_string(address);
  433. padded_sibling.kind = ConditionExpressionKind::Series;
  434. padded_sibling.children = {
  435. std::move(sibling),
  436. ConditionExpression::fromWire(
  437. "depth-wire-" + std::to_string(address),
  438. expressionColumns(nested) - 1)};
  439. sibling = std::move(padded_sibling);
  440. }
  441. expression.children = {std::move(nested), std::move(sibling)};
  442. return expression;
  443. };
  444. int next_address = 1;
  445. ConditionExpression maximum_depth = makeNested(
  446. static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address);
  447. require(maximum_depth.validate(),
  448. "an expression depth at the configured limit must be accepted");
  449. ConditionExpression excessive_depth = makeNested(
  450. static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1,
  451. &next_address);
  452. require(!excessive_depth.validate(),
  453. "an expression depth above the configured limit must be rejected");
  454. (void)leaf;
  455. }
  456. void testLogicNodeConfigurationBoundaries()
  457. {
  458. // 触点只能绑定 M 区,数值比较只能绑定 D 区
  459. LogicNode contact;
  460. contact.id = "contact";
  461. contact.config = ContactNodeConfig{
  462. RegisterAddress{RegisterArea::M, 0},
  463. ContactMode::NormallyOpen};
  464. require(contact.validate(), "contact node bound to M address must be valid");
  465. contact.config = ContactNodeConfig{
  466. RegisterAddress{RegisterArea::D, 0},
  467. ContactMode::NormallyOpen};
  468. require(!contact.validate(), "contact node bound to D address must be rejected");
  469. LogicNode comparison;
  470. comparison.id = "comparison";
  471. comparison.config = CompareNodeConfig{
  472. RegisterAddress{RegisterArea::D, 0},
  473. ComparisonOperator::GreaterThan,
  474. static_cast<std::int16_t>(100)};
  475. require(comparison.validate(), "comparison node bound to D address must be valid");
  476. }
  477. void testEdgeAndCommentBoundaries()
  478. {
  479. LogicNode edge;
  480. edge.id = "edge";
  481. edge.config = EdgeContactNodeConfig{
  482. RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising};
  483. require(edge.validate(), "a valid rising edge contact must pass validation");
  484. RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"};
  485. require(comment.validate(), "a nonblank register comment must be valid");
  486. comment.text.assign(ProjectLimits::kMaximumRegisterCommentBytes, 'a');
  487. require(comment.validate(), "a register comment at the byte limit must be valid");
  488. comment.text.push_back('a');
  489. require(!comment.validate(), "a register comment above the byte limit must fail");
  490. comment.text = "启动\n按钮";
  491. require(!comment.validate(), "a multiline register comment must be rejected");
  492. comment.text = "启动\r按钮";
  493. require(!comment.validate(), "a register comment containing CR must be rejected");
  494. comment.text = " \t";
  495. require(!comment.validate(), "a blank register comment must be rejected");
  496. LadderRung comment_rung;
  497. comment_rung.id = "comment-rung";
  498. comment_rung.name = "Comment rung";
  499. comment_rung.comment.assign(ProjectLimits::kMaximumRungCommentBytes, 'a');
  500. require(comment_rung.validate(), "a rung comment at the byte limit must be valid");
  501. comment_rung.comment.push_back('a');
  502. require(!comment_rung.validate(), "a rung comment above the byte limit must fail");
  503. comment_rung.comment = "第一行\n第二行";
  504. require(!comment_rung.validate(), "a multiline rung comment must be rejected");
  505. comment_rung.comment = "第一行\r第二行";
  506. require(!comment_rung.validate(), "a rung comment containing CR must be rejected");
  507. Project project = makeValidProject();
  508. project.registerComments = {
  509. {RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
  510. {RegisterAddress{RegisterArea::M, 0}, "重复地址"}};
  511. require(!project.validate(), "duplicate register comments must be rejected");
  512. }
  513. void testDataInstructionBoundaries()
  514. {
  515. LogicNode move;
  516. move.id = "move";
  517. move.config = MoveNodeConfig{
  518. WordOperand{
  519. WordOperandKind::Constant,
  520. RegisterAddress{RegisterArea::D, 0},
  521. -100},
  522. RegisterAddress{RegisterArea::D, 20}};
  523. require(move.validate() && move.isOutput(),
  524. "MOVE with a constant source and D destination must be a valid output");
  525. LogicNode add;
  526. add.id = "add";
  527. add.config = ArithmeticNodeConfig{
  528. ArithmeticOperation::Add,
  529. WordOperand{
  530. WordOperandKind::Register,
  531. RegisterAddress{RegisterArea::D, 20},
  532. 0},
  533. WordOperand{
  534. WordOperandKind::Constant,
  535. RegisterAddress{RegisterArea::D, 0},
  536. 1},
  537. RegisterAddress{RegisterArea::D, 20}};
  538. require(add.validate() && add.isOutput(),
  539. "ADD must allow the same D register as source and destination");
  540. }
  541. void testLadderLogicBoundaries()
  542. {
  543. LogicNode stop;
  544. stop.id = "stop";
  545. stop.config = ContactNodeConfig{
  546. RegisterAddress{RegisterArea::M, 1},
  547. ContactMode::NormallyClosed};
  548. LogicNode start;
  549. start.id = "start";
  550. start.config = ContactNodeConfig{
  551. RegisterAddress{RegisterArea::M, 0},
  552. ContactMode::NormallyOpen};
  553. LogicNode run_contact;
  554. run_contact.id = "run-contact";
  555. run_contact.config = ContactNodeConfig{
  556. RegisterAddress{RegisterArea::M, 1},
  557. ContactMode::NormallyOpen};
  558. LogicNode coil;
  559. coil.id = "run-coil";
  560. coil.config = CoilNodeConfig{
  561. RegisterAddress{RegisterArea::M, 1},
  562. CoilMode::Normal};
  563. ControlLogic logic;
  564. logic.id = "hold-logic";
  565. logic.name = "Hold logic";
  566. ConditionExpression start_parallel;
  567. start_parallel.id = "parallel-start";
  568. start_parallel.kind = ConditionExpressionKind::Parallel;
  569. start_parallel.children = {
  570. ConditionExpression::fromNode(start),
  571. ConditionExpression::fromNode(run_contact)};
  572. ConditionExpression root;
  573. root.id = "series-root";
  574. root.kind = ConditionExpressionKind::Series;
  575. root.children = {
  576. ConditionExpression::fromNode(stop),
  577. start_parallel,
  578. ConditionExpression::fromWire("hold-output-wire", 8)};
  579. LadderRung rung;
  580. rung.id = "rung-1";
  581. rung.name = "Self hold";
  582. rung.condition = root;
  583. rung.output = coil;
  584. logic.rungs.push_back(rung);
  585. require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid");
  586. ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2);
  587. require(wire.validate() && wire.validateForRunning()
  588. && wire.wire->columnSpan == 2,
  589. "a configured horizontal wire must be a valid runnable expression leaf");
  590. ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0);
  591. require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected");
  592. invalid_wire = ConditionExpression::fromWire(
  593. "wire-too-wide", WireSegment::kMaximumColumnSpan + 1);
  594. require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected");
  595. ConditionExpression gap = ConditionExpression::fromGap("gap-1", 1);
  596. require(gap.validate() && !gap.validateForRunning(),
  597. "a gap must be a valid editing draft but must block runtime validation");
  598. ConditionExpression maximum_columns;
  599. maximum_columns.id = "maximum-columns";
  600. maximum_columns.kind = ConditionExpressionKind::Series;
  601. for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
  602. {
  603. LogicNode node;
  604. node.id = "column-" + std::to_string(column + 1);
  605. node.config = ContactNodeConfig{
  606. RegisterAddress{RegisterArea::M, column},
  607. ContactMode::NormallyOpen};
  608. maximum_columns.children.push_back(
  609. ConditionExpression::fromNode(std::move(node)));
  610. }
  611. require(maximum_columns.validate(),
  612. "ten condition columns must be accepted");
  613. LogicNode extra_column;
  614. extra_column.id = "column-11";
  615. extra_column.config = ContactNodeConfig{
  616. RegisterAddress{RegisterArea::M, 10},
  617. ContactMode::NormallyOpen};
  618. maximum_columns.children.push_back(
  619. ConditionExpression::fromNode(std::move(extra_column)));
  620. require(!maximum_columns.validate(),
  621. "an eleventh condition column must be rejected");
  622. ConditionExpression wired_series;
  623. wired_series.id = "wired-series";
  624. wired_series.kind = ConditionExpressionKind::Series;
  625. wired_series.children = {
  626. ConditionExpression::fromNode(stop),
  627. ConditionExpression::fromWire("wire-series"),
  628. start_parallel};
  629. require(wired_series.validateForRunning(),
  630. "a wire must preserve a valid structured series expression");
  631. logic.rungs.front().condition->children.front() =
  632. ConditionExpression::fromNode(coil);
  633. require(!logic.validate(), "a ladder condition expression must reject coils");
  634. logic.rungs.front().condition = root;
  635. logic.rungs.front().output = start;
  636. require(!logic.validate(), "a ladder output must be a coil");
  637. logic.rungs.front().output.reset();
  638. require(logic.validate(), "incomplete ladder may remain in an editable draft");
  639. require(!logic.validateForRunning(),
  640. "conditions without an output must block runtime validation");
  641. LadderRung empty_rung{
  642. "rung-empty", "Empty network", {}, std::nullopt, std::nullopt};
  643. require(empty_rung.validate(), "an empty editing network must be valid");
  644. empty_rung.output = coil;
  645. require(!empty_rung.validate(),
  646. "an output without an explicit ten-column condition path must be rejected");
  647. empty_rung.condition = ConditionExpression::fromWire("unconditional-wire", 10);
  648. require(empty_rung.validate() && empty_rung.validateForRunning(),
  649. "a ten-column wire path must represent a runnable unconditional rung");
  650. empty_rung.condition = ConditionExpression::fromGap("unconditional-gap", 10);
  651. require(empty_rung.validate() && !empty_rung.validateForRunning(),
  652. "a full-width gap draft must remain saved but disconnected");
  653. logic.rungs.front().output = coil;
  654. logic.rungs.front().condition = root;
  655. logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id;
  656. require(!logic.validate(), "logic node ids must be unique");
  657. ConditionExpression nested_parallel;
  658. nested_parallel.id = "parallel-nested";
  659. nested_parallel.kind = ConditionExpressionKind::Parallel;
  660. nested_parallel.children = {
  661. ConditionExpression::fromNode(start),
  662. root};
  663. require(!nested_parallel.validate(),
  664. "parallel branches with unequal explicit widths must be rejected");
  665. ConditionExpression padded_start;
  666. padded_start.id = "padded-start";
  667. padded_start.kind = ConditionExpressionKind::Series;
  668. padded_start.children = {
  669. ConditionExpression::fromNode(start),
  670. ConditionExpression::fromWire("nested-parallel-wire", 9)};
  671. nested_parallel.children.front() = std::move(padded_start);
  672. require(nested_parallel.validate(),
  673. "parallel branches padded with explicit wires must be valid");
  674. }
  675. void testModelsValidateBindingsAndIdentifiers()
  676. {
  677. // 聚合验证必须拒绝错误绑定、重复标识和越界控件
  678. Project project = makeValidProject();
  679. require(project.validate(), "valid project model must pass validation");
  680. project.hmiPages.front().controls.front().binding =
  681. RegisterAddress{RegisterArea::D, 0};
  682. require(!project.validate(), "button bound to D area must be rejected");
  683. project = makeValidProject();
  684. project.hmiPages.push_back(project.hmiPages.front());
  685. require(!project.validate(), "duplicate HMI page id must be rejected");
  686. project = makeValidProject();
  687. project.hmiPages.front().controls.front().bounds.x = -1;
  688. require(!project.validate(), "controls outside the page must be rejected");
  689. project = makeValidProject();
  690. project.hmiPages.front().controls.front().bounds.width = 801;
  691. require(!project.validate(), "controls wider than the page must be rejected");
  692. project = makeValidProject();
  693. project.hmiPages.front().controls.front().properties.emplace("", "value");
  694. require(!project.validate(), "empty HMI property names must be rejected");
  695. project = makeValidProject();
  696. project.hmiPages.front().controls.front().binding.reset();
  697. require(project.validate(), "unbound HMI control must be accepted in a draft");
  698. require(!project.validateForRunning(),
  699. "unbound HMI control must block runtime validation");
  700. project = makeValidProject();
  701. project.controlLogics.front().rungs.front().output->configured = false;
  702. require(project.validate(), "unconfigured ladder node must be accepted in a draft");
  703. require(!project.validateForRunning(),
  704. "unconfigured ladder node must block runtime validation");
  705. }
  706. void testRuntimeStateBoundaries()
  707. {
  708. // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件
  709. RuntimeState state;
  710. require(state.policy().allowsProjectEditing, "editing mode must allow project editing");
  711. require(state.enterOfflineRunning().succeeded, "editing may enter offline running");
  712. require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers");
  713. require(state.policy().runsLogicExecutor, "offline mode must run logic executor");
  714. require(state.enterOnlineRunning(true).error
  715. == ModeTransitionError::MustReturnToEditing,
  716. "offline mode must not directly enter online mode");
  717. require(state.enterEditing().succeeded, "offline mode may return to editing");
  718. require(state.enterOnlineRunning(false).error
  719. == ModeTransitionError::InitialPlcReadRequired,
  720. "online mode must require an initial PLC read");
  721. require(state.enterOnlineRunning(true).succeeded,
  722. "editing may enter online mode after initial PLC read");
  723. require(!state.policy().runsLogicExecutor,
  724. "online mode must keep the software logic executor stopped");
  725. require(state.policy().usesPlcRegisters, "online mode must use PLC registers");
  726. }
  727. void testRuntimeConfiguredProjectLimits()
  728. {
  729. ProjectLimitSettings limits;
  730. std::string error;
  731. Project project = makeValidProject();
  732. HmiPage second_page = project.hmiPages.front();
  733. second_page.id = "second-page";
  734. second_page.name = "Second page";
  735. project.hmiPages.push_back(second_page);
  736. limits.maximumHmiPages = 1U;
  737. require(!project.validate(limits, &error)
  738. && error.find("当前配置上限为 1") != std::string::npos,
  739. "runtime page limits must be enforced by aggregate validation");
  740. project = makeValidProject();
  741. HmiControl second_control = project.hmiPages.front().controls.front();
  742. second_control.id = "second-control";
  743. project.hmiPages.front().controls.push_back(second_control);
  744. limits = {};
  745. limits.maximumHmiControlsPerPage = 1U;
  746. require(!project.validate(limits, &error),
  747. "runtime per-page control limits must be enforced");
  748. project = makeValidProject();
  749. project.alarmDefinitions.push_back({});
  750. limits = {};
  751. limits.maximumAlarmDefinitions = 0U;
  752. require(!project.validate(limits, &error),
  753. "runtime alarm limits must be enforced before child validation");
  754. project = makeValidProject();
  755. ControlLogic second_logic = project.controlLogics.front();
  756. second_logic.id = "second-logic";
  757. second_logic.name = "Second logic";
  758. second_logic.rungs.clear();
  759. project.controlLogics.push_back(second_logic);
  760. limits = {};
  761. limits.maximumControlLogics = 1U;
  762. require(!project.validate(limits, &error),
  763. "runtime control-logic limits must be enforced");
  764. project = makeValidProject();
  765. limits = {};
  766. limits.maximumRungsPerLogic = 0U;
  767. require(!project.validate(limits, &error),
  768. "runtime per-logic rung limits must be enforced");
  769. }
  770. } // namespace
  771. int main()
  772. {
  773. try
  774. {
  775. // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程
  776. testRegisterAddressBoundaries();
  777. testRegisterAddressParsing();
  778. testRegisterRepositorySeparatesAreas();
  779. testHmiControlRegistryCompleteness();
  780. testHmiAppearancePropertyBoundaries();
  781. testLogicNodeConfigurationBoundaries();
  782. testEdgeAndCommentBoundaries();
  783. testDataInstructionBoundaries();
  784. testLadderLogicBoundaries();
  785. testModelsValidateBindingsAndIdentifiers();
  786. testMultiPageAndLogicDomainRules();
  787. testQuantityBoundaries();
  788. testRuntimeConfiguredProjectLimits();
  789. testRuntimeStateBoundaries();
  790. }
  791. catch (const std::exception &error)
  792. {
  793. std::cerr << "domain tests failed: " << error.what() << '\n';
  794. return 1;
  795. }
  796. std::cout << "domain tests passed\n";
  797. return 0;
  798. }