综合平台编程器项目的远程存储
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

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