综合平台编程器项目的远程存储
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 

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