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

935 lignes
38 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 of 128 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.front().controls.clear();
  327. for (std::size_t index = 0U;
  328. index < ProjectLimits::kMaximumHmiControlsPerPage;
  329. ++index)
  330. {
  331. HmiControl label;
  332. label.id = "label-" + std::to_string(index);
  333. label.type = HmiControlType::Label;
  334. label.bounds = {0, 0, 1, 1};
  335. label.text = "label";
  336. project.hmiPages.front().controls.push_back(std::move(label));
  337. }
  338. require(project.validate(), "a page control count of 512 must be accepted");
  339. HmiControl extra_label;
  340. extra_label.id = "label-over";
  341. extra_label.type = HmiControlType::Label;
  342. extra_label.bounds = {0, 0, 1, 1};
  343. extra_label.text = "label";
  344. project.hmiPages.front().controls.push_back(std::move(extra_label));
  345. require(!project.validate(), "a page control count of 513 must be rejected");
  346. project = makeValidProject();
  347. project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth;
  348. project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight;
  349. require(project.validate(), "an HMI page size of 1600 by 800 must be accepted");
  350. project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1;
  351. require(!project.validate(), "an HMI page width of 1601 must be rejected");
  352. project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1;
  353. require(!project.validate(), "an HMI page width of 319 must be rejected");
  354. project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth;
  355. project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1;
  356. require(!project.validate(), "an HMI page height of 199 must be rejected");
  357. ConditionExpression leaf = ConditionExpression::fromNode({
  358. "depth-node-0",
  359. ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}},
  360. true});
  361. std::function<ConditionExpression(int, int *)> makeNested =
  362. [&makeNested](int depth, int *next_address)
  363. {
  364. if (depth == 1)
  365. {
  366. const int address = (*next_address)++;
  367. return ConditionExpression::fromNode({
  368. "depth-node-" + std::to_string(address),
  369. ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
  370. true});
  371. }
  372. const int address = (*next_address)++;
  373. ConditionExpression expression;
  374. expression.id = "depth-expression-" + std::to_string(address);
  375. expression.kind = depth % 2 == 0
  376. ? ConditionExpressionKind::Parallel
  377. : ConditionExpressionKind::Series;
  378. expression.children = {
  379. makeNested(depth - 1, next_address),
  380. ConditionExpression::fromNode({
  381. "depth-node-" + std::to_string(address),
  382. ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
  383. true})};
  384. return expression;
  385. };
  386. int next_address = 1;
  387. ConditionExpression maximum_depth = makeNested(
  388. static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address);
  389. require(maximum_depth.validate(),
  390. "an expression depth of 20 must be accepted");
  391. ConditionExpression excessive_depth = makeNested(
  392. static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1,
  393. &next_address);
  394. require(!excessive_depth.validate(),
  395. "an expression depth of 21 must be rejected");
  396. CounterNodeConfig counter_config{
  397. CounterAddress{0},
  398. CounterMode::Up,
  399. RegisterAddress{RegisterArea::D, 0},
  400. WordOperand{WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, -1},
  401. RegisterAddress{RegisterArea::M, 0}};
  402. LogicNode counter{"counter-negative-preset", counter_config, true};
  403. require(!counter.validate(), "a negative constant counter preset must be rejected");
  404. (void)leaf;
  405. }
  406. void testLogicNodeConfigurationBoundaries()
  407. {
  408. // 触点只能绑定 M 区,数值比较只能绑定 D 区
  409. LogicNode contact;
  410. contact.id = "contact";
  411. contact.config = ContactNodeConfig{
  412. RegisterAddress{RegisterArea::M, 0},
  413. ContactMode::NormallyOpen};
  414. require(contact.validate(), "contact node bound to M address must be valid");
  415. contact.config = ContactNodeConfig{
  416. RegisterAddress{RegisterArea::D, 0},
  417. ContactMode::NormallyOpen};
  418. require(!contact.validate(), "contact node bound to D address must be rejected");
  419. LogicNode comparison;
  420. comparison.id = "comparison";
  421. comparison.config = CompareNodeConfig{
  422. RegisterAddress{RegisterArea::D, 0},
  423. ComparisonOperator::GreaterThan,
  424. static_cast<std::int16_t>(100)};
  425. require(comparison.validate(), "comparison node bound to D address must be valid");
  426. }
  427. void testTimerAndCommentBoundaries()
  428. {
  429. LogicNode edge;
  430. edge.id = "edge";
  431. edge.config = EdgeContactNodeConfig{
  432. RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising};
  433. require(edge.validate(), "a valid rising edge contact must pass validation");
  434. LogicNode timer_contact;
  435. timer_contact.id = "timer-contact";
  436. timer_contact.config = TimerContactNodeConfig{
  437. TimerAddress{4000}, ContactMode::NormallyOpen};
  438. require(timer_contact.validate(), "T4000 must be a valid timer contact");
  439. timer_contact.config = TimerContactNodeConfig{
  440. TimerAddress{-1}, ContactMode::NormallyOpen};
  441. require(!timer_contact.validate(), "a negative T address must be rejected");
  442. LogicNode ton;
  443. ton.id = "ton";
  444. ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs};
  445. require(ton.validate(), "the minimum TON preset must be valid");
  446. ton.config = TonNodeConfig{TimerAddress{0}, 0};
  447. require(!ton.validate(), "a zero TON preset must be rejected");
  448. ton.config = TonNodeConfig{
  449. TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1};
  450. require(!ton.validate(), "an oversized TON preset must be rejected");
  451. RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"};
  452. require(comment.validate(), "a nonblank register comment must be valid");
  453. comment.text.assign(ProjectLimits::kMaximumRegisterCommentBytes, 'a');
  454. require(comment.validate(), "a register comment at the byte limit must be valid");
  455. comment.text.push_back('a');
  456. require(!comment.validate(), "a register comment above the byte limit must fail");
  457. comment.text = "启动\n按钮";
  458. require(!comment.validate(), "a multiline register comment must be rejected");
  459. comment.text = "启动\r按钮";
  460. require(!comment.validate(), "a register comment containing CR must be rejected");
  461. comment.text = " \t";
  462. require(!comment.validate(), "a blank register comment must be rejected");
  463. LadderRung comment_rung;
  464. comment_rung.id = "comment-rung";
  465. comment_rung.name = "Comment rung";
  466. comment_rung.comment.assign(ProjectLimits::kMaximumRungCommentBytes, 'a');
  467. require(comment_rung.validate(), "a rung comment at the byte limit must be valid");
  468. comment_rung.comment.push_back('a');
  469. require(!comment_rung.validate(), "a rung comment above the byte limit must fail");
  470. comment_rung.comment = "第一行\n第二行";
  471. require(!comment_rung.validate(), "a multiline rung comment must be rejected");
  472. comment_rung.comment = "第一行\r第二行";
  473. require(!comment_rung.validate(), "a rung comment containing CR must be rejected");
  474. Project project = makeValidProject();
  475. project.registerComments = {
  476. {RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
  477. {RegisterAddress{RegisterArea::M, 0}, "重复地址"}};
  478. require(!project.validate(), "duplicate register comments must be rejected");
  479. }
  480. LadderRung makeTimerRung(
  481. const std::string &rung_id,
  482. const std::string &condition_id,
  483. const std::string &output_id,
  484. int timer_index,
  485. int preset_ms)
  486. {
  487. LogicNode condition;
  488. condition.id = condition_id;
  489. condition.config = ContactNodeConfig{
  490. RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen};
  491. LogicNode output;
  492. output.id = output_id;
  493. output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms};
  494. LadderRung rung;
  495. rung.id = rung_id;
  496. rung.name = rung_id;
  497. rung.condition = ConditionExpression::fromNode(condition);
  498. rung.output = output;
  499. return rung;
  500. }
  501. void testTimerReferencesForRunning()
  502. {
  503. ControlLogic valid;
  504. valid.id = "timer-valid";
  505. valid.name = "Timer valid";
  506. valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100));
  507. require(validateTimerReferencesForRunning({valid}),
  508. "a timer contact-free TON network must pass timer reference validation");
  509. ControlLogic duplicate = valid;
  510. duplicate.id = "timer-duplicate";
  511. duplicate.name = "Timer duplicate";
  512. duplicate.rungs.front().output->id = "ton-duplicate";
  513. require(!validateTimerReferencesForRunning({valid, duplicate}),
  514. "the same T must not have two enabled TON drivers");
  515. ControlLogic missing;
  516. missing.id = "timer-missing";
  517. missing.name = "Timer missing";
  518. LogicNode contact;
  519. contact.id = "missing-contact";
  520. contact.config = TimerContactNodeConfig{
  521. TimerAddress{7}, ContactMode::NormallyOpen};
  522. LogicNode output;
  523. output.id = "missing-coil";
  524. output.config = CoilNodeConfig{
  525. RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
  526. LadderRung missing_rung;
  527. missing_rung.id = "missing-rung";
  528. missing_rung.name = "Missing rung";
  529. missing_rung.condition = ConditionExpression::fromNode(contact);
  530. missing_rung.output = output;
  531. missing.rungs.push_back(missing_rung);
  532. require(!validateTimerReferencesForRunning({missing}),
  533. "a T contact without an enabled TON driver must be rejected");
  534. missing.enabled = false;
  535. require(validateTimerReferencesForRunning({missing}),
  536. "disabled timer drafts must not block runtime timer validation");
  537. }
  538. LadderRung makeCounterRung(
  539. const std::string &rung_id,
  540. const std::string &output_id,
  541. int counter_index)
  542. {
  543. LogicNode condition;
  544. condition.id = rung_id + "-input";
  545. condition.config = ContactNodeConfig{
  546. RegisterAddress{RegisterArea::M, counter_index},
  547. ContactMode::NormallyOpen};
  548. LogicNode output;
  549. output.id = output_id;
  550. output.config = CounterNodeConfig{
  551. CounterAddress{counter_index},
  552. CounterMode::Up,
  553. RegisterAddress{RegisterArea::D, counter_index},
  554. WordOperand{
  555. WordOperandKind::Constant,
  556. RegisterAddress{RegisterArea::D, 0},
  557. 10},
  558. RegisterAddress{RegisterArea::M, counter_index + 1}};
  559. LadderRung rung;
  560. rung.id = rung_id;
  561. rung.name = rung_id;
  562. rung.condition = ConditionExpression::fromNode(condition);
  563. rung.output = output;
  564. return rung;
  565. }
  566. void testCounterAndDataInstructionBoundaries()
  567. {
  568. require(CounterAddress{0}.isValid() && CounterAddress{4000}.isValid(),
  569. "C0 and C4000 must be valid counter resources");
  570. require(!CounterAddress{-1}.isValid() && !CounterAddress{4001}.isValid(),
  571. "counter resources outside 0 through 4000 must be rejected");
  572. LogicNode counter;
  573. counter.id = "counter";
  574. counter.config = CounterNodeConfig{
  575. CounterAddress{0},
  576. CounterMode::Up,
  577. RegisterAddress{RegisterArea::D, 10},
  578. WordOperand{
  579. WordOperandKind::Register,
  580. RegisterAddress{RegisterArea::D, 11},
  581. 0},
  582. RegisterAddress{RegisterArea::M, 12}};
  583. require(counter.validate(),
  584. "a counter with C identity and external M/D addresses must be valid");
  585. CounterNodeConfig invalid_counter = std::get<CounterNodeConfig>(counter.config);
  586. invalid_counter.currentValueAddress = RegisterAddress{RegisterArea::M, 10};
  587. counter.config = invalid_counter;
  588. require(!counter.validate(), "counter CV must reject M addresses");
  589. LogicNode move;
  590. move.id = "move";
  591. move.config = MoveNodeConfig{
  592. WordOperand{
  593. WordOperandKind::Constant,
  594. RegisterAddress{RegisterArea::D, 0},
  595. -100},
  596. RegisterAddress{RegisterArea::D, 20}};
  597. require(move.validate() && move.isOutput(),
  598. "MOVE with a constant source and D destination must be a valid output");
  599. LogicNode add;
  600. add.id = "add";
  601. add.config = ArithmeticNodeConfig{
  602. ArithmeticOperation::Add,
  603. WordOperand{
  604. WordOperandKind::Register,
  605. RegisterAddress{RegisterArea::D, 20},
  606. 0},
  607. WordOperand{
  608. WordOperandKind::Constant,
  609. RegisterAddress{RegisterArea::D, 0},
  610. 1},
  611. RegisterAddress{RegisterArea::D, 20}};
  612. require(add.validate() && add.isOutput(),
  613. "ADD must allow the same D register as source and destination");
  614. ControlLogic valid;
  615. valid.id = "counter-valid";
  616. valid.name = "Counter valid";
  617. valid.rungs.push_back(makeCounterRung("counter-rung", "ctu-0", 0));
  618. require(validateCounterReferencesForRunning({valid}),
  619. "a counter output without contacts must pass reference validation");
  620. ControlLogic duplicate = valid;
  621. duplicate.id = "counter-duplicate";
  622. duplicate.name = "Counter duplicate";
  623. duplicate.rungs.front().output->id = "ctu-duplicate";
  624. require(!validateCounterReferencesForRunning({valid, duplicate}),
  625. "the same C resource must not have multiple enabled drivers");
  626. ControlLogic missing;
  627. missing.id = "counter-missing";
  628. missing.name = "Counter missing";
  629. LogicNode missing_contact;
  630. missing_contact.id = "missing-counter-contact";
  631. missing_contact.config = CounterContactNodeConfig{
  632. CounterAddress{7}, ContactMode::NormallyOpen};
  633. LogicNode output;
  634. output.id = "missing-counter-coil";
  635. output.config = CoilNodeConfig{
  636. RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
  637. LadderRung missing_rung;
  638. missing_rung.id = "missing-counter-rung";
  639. missing_rung.name = "Missing counter rung";
  640. missing_rung.condition = ConditionExpression::fromNode(missing_contact);
  641. missing_rung.output = output;
  642. missing.rungs.push_back(missing_rung);
  643. require(!validateCounterReferencesForRunning({missing}),
  644. "a C contact without an enabled counter driver must be rejected");
  645. }
  646. void testLadderLogicBoundaries()
  647. {
  648. LogicNode stop;
  649. stop.id = "stop";
  650. stop.config = ContactNodeConfig{
  651. RegisterAddress{RegisterArea::M, 1},
  652. ContactMode::NormallyClosed};
  653. LogicNode start;
  654. start.id = "start";
  655. start.config = ContactNodeConfig{
  656. RegisterAddress{RegisterArea::M, 0},
  657. ContactMode::NormallyOpen};
  658. LogicNode run_contact;
  659. run_contact.id = "run-contact";
  660. run_contact.config = ContactNodeConfig{
  661. RegisterAddress{RegisterArea::M, 1},
  662. ContactMode::NormallyOpen};
  663. LogicNode coil;
  664. coil.id = "run-coil";
  665. coil.config = CoilNodeConfig{
  666. RegisterAddress{RegisterArea::M, 1},
  667. CoilMode::Normal};
  668. ControlLogic logic;
  669. logic.id = "hold-logic";
  670. logic.name = "Hold logic";
  671. ConditionExpression start_parallel;
  672. start_parallel.id = "parallel-start";
  673. start_parallel.kind = ConditionExpressionKind::Parallel;
  674. start_parallel.children = {
  675. ConditionExpression::fromNode(start),
  676. ConditionExpression::fromNode(run_contact)};
  677. ConditionExpression root;
  678. root.id = "series-root";
  679. root.kind = ConditionExpressionKind::Series;
  680. root.children = {
  681. ConditionExpression::fromNode(stop),
  682. start_parallel};
  683. LadderRung rung;
  684. rung.id = "rung-1";
  685. rung.name = "Self hold";
  686. rung.condition = root;
  687. rung.output = coil;
  688. logic.rungs.push_back(rung);
  689. require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid");
  690. ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2);
  691. require(wire.validate() && wire.validateForRunning()
  692. && wire.wire->columnSpan == 2,
  693. "a configured horizontal wire must be a valid runnable expression leaf");
  694. ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0);
  695. require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected");
  696. invalid_wire = ConditionExpression::fromWire(
  697. "wire-too-wide", WireSegment::kMaximumColumnSpan + 1);
  698. require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected");
  699. ConditionExpression maximum_columns;
  700. maximum_columns.id = "maximum-columns";
  701. maximum_columns.kind = ConditionExpressionKind::Series;
  702. for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
  703. {
  704. LogicNode node;
  705. node.id = "column-" + std::to_string(column + 1);
  706. node.config = ContactNodeConfig{
  707. RegisterAddress{RegisterArea::M, column},
  708. ContactMode::NormallyOpen};
  709. maximum_columns.children.push_back(
  710. ConditionExpression::fromNode(std::move(node)));
  711. }
  712. require(maximum_columns.validate(),
  713. "ten condition columns must be accepted");
  714. LogicNode extra_column;
  715. extra_column.id = "column-11";
  716. extra_column.config = ContactNodeConfig{
  717. RegisterAddress{RegisterArea::M, 10},
  718. ContactMode::NormallyOpen};
  719. maximum_columns.children.push_back(
  720. ConditionExpression::fromNode(std::move(extra_column)));
  721. require(!maximum_columns.validate(),
  722. "an eleventh condition column must be rejected");
  723. ConditionExpression wired_series;
  724. wired_series.id = "wired-series";
  725. wired_series.kind = ConditionExpressionKind::Series;
  726. wired_series.children = {
  727. ConditionExpression::fromNode(stop),
  728. ConditionExpression::fromWire("wire-series"),
  729. start_parallel};
  730. require(wired_series.validateForRunning(),
  731. "a wire must preserve a valid structured series expression");
  732. logic.rungs.front().condition->children.front() =
  733. ConditionExpression::fromNode(coil);
  734. require(!logic.validate(), "a ladder condition expression must reject coils");
  735. logic.rungs.front().condition = root;
  736. logic.rungs.front().output = start;
  737. require(!logic.validate(), "a ladder output must be a coil");
  738. logic.rungs.front().output.reset();
  739. require(logic.validate(), "incomplete ladder may remain in an editable draft");
  740. require(!logic.validateForRunning(),
  741. "conditions without an output must block runtime validation");
  742. LadderRung empty_rung{
  743. "rung-empty", "Empty network", {}, std::nullopt, std::nullopt};
  744. require(empty_rung.validate(), "an empty editing network must be valid");
  745. empty_rung.output = coil;
  746. require(empty_rung.validate(), "output-only network may remain in an editable draft");
  747. require(empty_rung.validateForRunning(),
  748. "an output-only network must be valid as an unconditional rung");
  749. logic.rungs.front().output = coil;
  750. logic.rungs.front().condition = root;
  751. logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id;
  752. require(!logic.validate(), "logic node ids must be unique");
  753. ConditionExpression nested_parallel;
  754. nested_parallel.id = "parallel-nested";
  755. nested_parallel.kind = ConditionExpressionKind::Parallel;
  756. nested_parallel.children = {
  757. ConditionExpression::fromNode(start),
  758. root};
  759. require(nested_parallel.validate(),
  760. "nested series and parallel expressions must be valid");
  761. }
  762. void testModelsValidateBindingsAndIdentifiers()
  763. {
  764. // 聚合验证必须拒绝错误绑定、重复标识和越界控件
  765. Project project = makeValidProject();
  766. require(project.validate(), "valid project model must pass validation");
  767. project.hmiPages.front().controls.front().binding =
  768. RegisterAddress{RegisterArea::D, 0};
  769. require(!project.validate(), "button bound to D area must be rejected");
  770. project = makeValidProject();
  771. project.hmiPages.push_back(project.hmiPages.front());
  772. require(!project.validate(), "duplicate HMI page id must be rejected");
  773. project = makeValidProject();
  774. project.hmiPages.front().controls.front().bounds.x = -1;
  775. require(!project.validate(), "controls outside the page must be rejected");
  776. project = makeValidProject();
  777. project.hmiPages.front().controls.front().bounds.width = 801;
  778. require(!project.validate(), "controls wider than the page must be rejected");
  779. project = makeValidProject();
  780. project.hmiPages.front().controls.front().properties.emplace("", "value");
  781. require(!project.validate(), "empty HMI property names must be rejected");
  782. project = makeValidProject();
  783. project.hmiPages.front().controls.front().binding.reset();
  784. require(project.validate(), "unbound HMI control must be accepted in a draft");
  785. require(!project.validateForRunning(),
  786. "unbound HMI control must block runtime validation");
  787. project = makeValidProject();
  788. project.controlLogics.front().rungs.front().output->configured = false;
  789. require(project.validate(), "unconfigured ladder node must be accepted in a draft");
  790. require(!project.validateForRunning(),
  791. "unconfigured ladder node must block runtime validation");
  792. }
  793. void testRuntimeStateBoundaries()
  794. {
  795. // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件
  796. RuntimeState state;
  797. require(state.policy().allowsProjectEditing, "editing mode must allow project editing");
  798. require(state.enterOfflineRunning().succeeded, "editing may enter offline running");
  799. require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers");
  800. require(state.policy().runsLogicExecutor, "offline mode must run logic executor");
  801. require(state.enterOnlineRunning(true).error
  802. == ModeTransitionError::MustReturnToEditing,
  803. "offline mode must not directly enter online mode");
  804. require(state.enterEditing().succeeded, "offline mode may return to editing");
  805. require(state.enterOnlineRunning(false).error
  806. == ModeTransitionError::InitialPlcReadRequired,
  807. "online mode must require an initial PLC read");
  808. require(state.enterOnlineRunning(true).succeeded,
  809. "editing may enter online mode after initial PLC read");
  810. require(!state.policy().runsLogicExecutor,
  811. "online mode must keep the software logic executor stopped");
  812. require(state.policy().usesPlcRegisters, "online mode must use PLC registers");
  813. }
  814. } // namespace
  815. int main()
  816. {
  817. try
  818. {
  819. // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程
  820. testRegisterAddressBoundaries();
  821. testRegisterAddressParsing();
  822. testRegisterRepositorySeparatesAreas();
  823. testHmiControlRegistryCompleteness();
  824. testProgressBarConfigurationBoundaries();
  825. testHmiAppearancePropertyBoundaries();
  826. testLogicNodeConfigurationBoundaries();
  827. testTimerAndCommentBoundaries();
  828. testTimerReferencesForRunning();
  829. testCounterAndDataInstructionBoundaries();
  830. testLadderLogicBoundaries();
  831. testModelsValidateBindingsAndIdentifiers();
  832. testMultiPageAndLogicDomainRules();
  833. testQuantityBoundaries();
  834. testRuntimeStateBoundaries();
  835. }
  836. catch (const std::exception &error)
  837. {
  838. std::cerr << "domain tests failed: " << error.what() << '\n';
  839. return 1;
  840. }
  841. std::cout << "domain tests passed\n";
  842. return 0;
  843. }