综合平台编程器项目的远程存储
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

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