综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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