综合平台编程器项目的远程存储
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

735 строки
36 KiB

  1. #include "domain/project_storage.h"
  2. #include "domain/register_repository.h"
  3. #include "domain/virtual_register_repository.h"
  4. #include "services/hmi_editor_service.h"
  5. #include "services/editor_history.h"
  6. #include "services/hmi_runtime_service.h"
  7. #include "services/hmi_navigation_service.h"
  8. #include "services/project_service.h"
  9. #include "support/test_support.h"
  10. #include <exception>
  11. #include <iostream>
  12. #include <limits>
  13. #include <stdexcept>
  14. #include <string>
  15. #include <variant>
  16. namespace {
  17. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  18. using TestSupport::require;
  19. void testControlEditing()
  20. {
  21. // 覆盖控件创建、移动、绑定校验、重命名冲突和删除流程
  22. TestProjectStorage storage;
  23. ProjectService project_service(storage, defaultProjectLimitSettings());
  24. HmiEditorService service(project_service, HmiDefaultSettings{});
  25. const HmiEditorResult page_result = service.ensureDefaultPage();
  26. require(page_result.succeeded, "default HMI page creation must succeed");
  27. const std::string page_id = page_result.id;
  28. const HmiEditorResult button = service.addControl(page_id, HmiControlType::Button);
  29. const HmiEditorResult indicator = service.addControl(page_id, HmiControlType::Indicator);
  30. const HmiEditorResult display = service.addControl(
  31. page_id, HmiControlType::NumericDisplay);
  32. const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput);
  33. require(button.succeeded && indicator.succeeded && display.succeeded
  34. && input.succeeded,
  35. "basic HMI controls must be added");
  36. const HmiPage *page = service.findPage(page_id);
  37. require(page != nullptr && page->controls.size() == 4,
  38. "all added controls must be kept in the page model");
  39. require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded,
  40. "a valid control move must succeed");
  41. require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded,
  42. "a move outside the page must be rejected");
  43. HmiControl updated = *service.findControl(page_id, button.id);
  44. updated.binding = RegisterAddress{RegisterArea::D, 0};
  45. require(!service.updateControl(page_id, button.id, updated).succeeded,
  46. "buttons must reject D address bindings during editing");
  47. updated.binding = RegisterAddress{RegisterArea::M, 7};
  48. updated.text = "启动";
  49. require(service.updateControl(page_id, button.id, updated).succeeded,
  50. "a button M binding and edited text must be accepted");
  51. HmiControl duplicate = *service.findControl(page_id, indicator.id);
  52. duplicate.id = button.id;
  53. require(service.updateControl(page_id, indicator.id, duplicate).error
  54. == HmiEditorError::DuplicateId,
  55. "duplicate control ids must be rejected");
  56. require(service.removeControl(page_id, indicator.id).succeeded,
  57. "deleting a selected control must succeed");
  58. require(service.findControl(page_id, indicator.id) == nullptr,
  59. "deleted controls must not remain in the model");
  60. }
  61. void testBatchControlAlignment()
  62. {
  63. TestProjectStorage storage;
  64. ProjectService project_service(storage, defaultProjectLimitSettings());
  65. HmiEditorService service(project_service, HmiDefaultSettings{});
  66. const std::string page_id = service.ensureDefaultPage().id;
  67. const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label);
  68. const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label);
  69. const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label);
  70. require(first.succeeded && second.succeeded && third.succeeded,
  71. "controls for alignment testing must be created");
  72. const std::vector<std::string> ids{first.id, second.id, third.id};
  73. const std::vector<HmiRect> original_bounds{
  74. {100, 80, 80, 30},
  75. {240, 120, 100, 40},
  76. {380, 160, 60, 20}};
  77. for (std::size_t index = 0U; index < ids.size(); ++index)
  78. {
  79. HmiControl control = *service.findControl(page_id, ids[index]);
  80. control.bounds = original_bounds[index];
  81. require(service.updateControl(page_id, ids[index], control).succeeded,
  82. "alignment fixtures must be positioned successfully");
  83. }
  84. service.clearHistory();
  85. require(service.alignControls(page_id, ids, HmiAlignment::Left).succeeded,
  86. "left alignment must succeed");
  87. require(service.findControl(page_id, first.id)->bounds.x == 100
  88. && service.findControl(page_id, second.id)->bounds.x == 100
  89. && service.findControl(page_id, third.id)->bounds.x == 100,
  90. "left alignment must use the selected group left edge");
  91. require(service.findControl(page_id, second.id)->bounds.y == 120,
  92. "alignment must preserve the non-aligned coordinate");
  93. require(service.undo().succeeded
  94. && service.findControl(page_id, second.id)->bounds
  95. .x == original_bounds[1].x,
  96. "alignment must be restored by one undo step");
  97. require(service.redo().succeeded
  98. && service.findControl(page_id, third.id)->bounds.x == 100,
  99. "alignment redo must restore the whole batch");
  100. const auto resetBounds = [&service, &page_id, &ids, &original_bounds]
  101. {
  102. for (std::size_t index = 0U; index < ids.size(); ++index)
  103. {
  104. HmiControl control = *service.findControl(page_id, ids[index]);
  105. control.bounds = original_bounds[index];
  106. require(service.updateControl(page_id, ids[index], control).succeeded,
  107. "alignment fixtures must be reset successfully");
  108. }
  109. };
  110. const auto assertBounds = [&service, &page_id, &ids, &resetBounds](
  111. HmiAlignment alignment, const std::vector<HmiRect> &expected,
  112. const char *message)
  113. {
  114. resetBounds();
  115. service.clearHistory();
  116. require(service.alignControls(page_id, ids, alignment).succeeded, message);
  117. for (std::size_t index = 0U; index < ids.size(); ++index)
  118. {
  119. const HmiControl *control = service.findControl(page_id, ids[index]);
  120. require(control != nullptr
  121. && control->bounds.x == expected[index].x
  122. && control->bounds.y == expected[index].y,
  123. "alignment must calculate deterministic target coordinates");
  124. }
  125. };
  126. assertBounds(
  127. HmiAlignment::HorizontalCenter,
  128. {{230, 80, 0, 0}, {220, 120, 0, 0}, {240, 160, 0, 0}},
  129. "horizontal center alignment must succeed");
  130. assertBounds(
  131. HmiAlignment::Right,
  132. {{360, 80, 0, 0}, {340, 120, 0, 0}, {380, 160, 0, 0}},
  133. "right alignment must succeed");
  134. assertBounds(
  135. HmiAlignment::Top,
  136. {{100, 80, 0, 0}, {240, 80, 0, 0}, {380, 80, 0, 0}},
  137. "top alignment must succeed");
  138. assertBounds(
  139. HmiAlignment::VerticalCenter,
  140. {{100, 115, 0, 0}, {240, 110, 0, 0}, {380, 120, 0, 0}},
  141. "vertical center alignment must succeed");
  142. assertBounds(
  143. HmiAlignment::Bottom,
  144. {{100, 150, 0, 0}, {240, 140, 0, 0}, {380, 160, 0, 0}},
  145. "bottom alignment must succeed");
  146. service.clearHistory();
  147. const std::vector<HmiRect> before_invalid{
  148. service.findControl(page_id, first.id)->bounds,
  149. service.findControl(page_id, second.id)->bounds,
  150. service.findControl(page_id, third.id)->bounds};
  151. require(!service.alignControls(
  152. page_id, {first.id, "missing-control"}, HmiAlignment::Left)
  153. .succeeded,
  154. "alignment must reject an unknown control before mutation");
  155. require(!service.canUndo()
  156. && service.findControl(page_id, first.id)->bounds.x
  157. == before_invalid[0].x
  158. && service.findControl(page_id, second.id)->bounds.y
  159. == before_invalid[1].y,
  160. "failed alignment must be atomic and leave history unchanged");
  161. require(!service.alignControls(page_id, {first.id}, HmiAlignment::Left).succeeded,
  162. "alignment must require at least two controls");
  163. HmiControl no_op = *service.findControl(page_id, first.id);
  164. no_op.bounds.x = 100;
  165. HmiControl no_op_second = *service.findControl(page_id, second.id);
  166. no_op_second.bounds.x = 100;
  167. HmiControl no_op_third = *service.findControl(page_id, third.id);
  168. no_op_third.bounds.x = 100;
  169. require(service.updateControl(page_id, first.id, no_op).succeeded
  170. && service.updateControl(page_id, second.id, no_op_second).succeeded
  171. && service.updateControl(page_id, third.id, no_op_third).succeeded,
  172. "alignment no-op fixtures must be positioned successfully");
  173. service.clearHistory();
  174. require(service.alignControls(page_id, ids, HmiAlignment::Left).succeeded
  175. && !service.canUndo(),
  176. "a no-op alignment must not create an undo step");
  177. }
  178. void testRuntimeUsesRegisterRepository()
  179. {
  180. // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现
  181. VirtualRegisterRepository repository;
  182. HmiRuntimeService runtime_service(repository);
  183. HmiControl button;
  184. button.id = "start";
  185. button.type = HmiControlType::Button;
  186. button.binding = RegisterAddress{RegisterArea::M, 12};
  187. require(button.buttonOperation == HmiButtonOperation::MomentaryOn,
  188. "new buttons must default to momentary ON");
  189. require(runtime_service.operateButton(button, HmiButtonEvent::Pressed).succeeded,
  190. "momentary button press must be accepted by the repository");
  191. require(repository.readBit(*button.binding).value,
  192. "momentary button press must write ON");
  193. require(runtime_service.operateButton(button, HmiButtonEvent::Released).succeeded,
  194. "momentary button release must be accepted by the repository");
  195. require(!repository.readBit(*button.binding).value,
  196. "momentary button release must write OFF");
  197. button.buttonOperation = HmiButtonOperation::SetOn;
  198. require(runtime_service.operateButton(button, HmiButtonEvent::Pressed).succeeded,
  199. "set ON button press must succeed");
  200. require(repository.readBit(*button.binding).value,
  201. "set ON button press must write ON");
  202. require(runtime_service.operateButton(button, HmiButtonEvent::Released).succeeded,
  203. "set ON button release must be ignored successfully");
  204. require(repository.readBit(*button.binding).value,
  205. "set ON button release must keep the bit ON");
  206. button.buttonOperation = HmiButtonOperation::SetOff;
  207. require(runtime_service.operateButton(button, HmiButtonEvent::Pressed).succeeded,
  208. "set OFF button press must succeed");
  209. require(!repository.readBit(*button.binding).value,
  210. "set OFF button press must write OFF");
  211. button.buttonOperation = HmiButtonOperation::Toggle;
  212. require(runtime_service.operateButton(button, HmiButtonEvent::Pressed).succeeded,
  213. "toggle button press must succeed");
  214. require(repository.readBit(*button.binding).value,
  215. "toggle button press must invert the current value");
  216. HmiControl indicator;
  217. indicator.id = "running";
  218. indicator.type = HmiControlType::Indicator;
  219. indicator.binding = RegisterAddress{RegisterArea::M, 12};
  220. const HmiRuntimeReadResult indicator_value = runtime_service.readControl(indicator);
  221. require(indicator_value.succeeded && indicator_value.bit_value,
  222. "indicators must read M values through the repository");
  223. HmiControl numeric_input;
  224. numeric_input.id = "target";
  225. numeric_input.type = HmiControlType::NumericInput;
  226. numeric_input.binding = RegisterAddress{RegisterArea::D, 9};
  227. require(runtime_service.writeNumericInput(numeric_input, -18).succeeded,
  228. "numeric input runtime writes must be accepted by the repository");
  229. HmiControl numeric_display;
  230. numeric_display.id = "actual";
  231. numeric_display.type = HmiControlType::NumericDisplay;
  232. numeric_display.binding = RegisterAddress{RegisterArea::D, 9};
  233. const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display);
  234. require(numeric_value.succeeded
  235. && std::get<std::int16_t>(numeric_value.numeric_value) == -18,
  236. "numeric display must read D values through the repository");
  237. HmiControl float_input = numeric_input;
  238. float_input.dataType = RegisterDataType::Float32;
  239. float_input.binding = RegisterAddress{RegisterArea::D, 20};
  240. require(runtime_service.writeNumericInput(float_input, -2.5).succeeded,
  241. "Float32 numeric input must write two consecutive D words");
  242. const HmiRuntimeReadResult float_value = runtime_service.readControl(float_input);
  243. require(float_value.succeeded
  244. && std::get<float>(float_value.numeric_value) == -2.5f,
  245. "Float32 numeric control must decode two consecutive D words");
  246. require(!runtime_service.writeNumericInput(
  247. float_input, std::numeric_limits<double>::infinity()).succeeded,
  248. "Float32 numeric input must reject infinity");
  249. HmiControl int32_input = numeric_input;
  250. int32_input.dataType = RegisterDataType::Int32;
  251. int32_input.binding = RegisterAddress{RegisterArea::D, 30};
  252. require(runtime_service.writeNumericInput(int32_input, 305419896).succeeded,
  253. "Int32 numeric input must write two consecutive D words");
  254. const WordsReadResult int32_words = repository.readWords(*int32_input.binding, 2);
  255. const HmiRuntimeReadResult int32_value = runtime_service.readControl(int32_input);
  256. require(int32_words.succeeded
  257. && static_cast<std::uint16_t>(int32_words.values[0]) == 0x5678U
  258. && static_cast<std::uint16_t>(int32_words.values[1]) == 0x1234U
  259. && int32_value.succeeded
  260. && std::get<std::int32_t>(int32_value.numeric_value) == 0x12345678,
  261. "Int32 HMI read/write must use low-word-first Xinje ordering");
  262. HmiControl double_input = numeric_input;
  263. double_input.dataType = RegisterDataType::Float64;
  264. double_input.binding = RegisterAddress{RegisterArea::D, 40};
  265. require(runtime_service.writeNumericInput(double_input, 1.0).succeeded,
  266. "Double numeric input must write four consecutive D words");
  267. const WordsReadResult double_words = repository.readWords(*double_input.binding, 4);
  268. const HmiRuntimeReadResult double_value = runtime_service.readControl(double_input);
  269. require(double_words.succeeded
  270. && static_cast<std::uint16_t>(double_words.values[0]) == 0x0000U
  271. && static_cast<std::uint16_t>(double_words.values[1]) == 0x0000U
  272. && static_cast<std::uint16_t>(double_words.values[2]) == 0x0000U
  273. && static_cast<std::uint16_t>(double_words.values[3]) == 0x3ff0U
  274. && double_value.succeeded
  275. && std::get<double>(double_value.numeric_value) == 1.0,
  276. "Double HMI read/write must use four low-address-first words");
  277. require(!runtime_service.writeNumericInput(
  278. double_input, std::numeric_limits<double>::quiet_NaN()).succeeded,
  279. "Double numeric input must reject NaN");
  280. double_input.binding = RegisterAddress{RegisterArea::D, 3997};
  281. require(runtime_service.writeNumericInput(double_input, 1.0).error
  282. == HmiRuntimeError::InvalidBinding,
  283. "Double numeric input must reject odd or overflowing start addresses");
  284. }
  285. void testStatusTextRuntimeMapping()
  286. {
  287. VirtualRegisterRepository repository;
  288. HmiRuntimeService runtime(repository);
  289. HmiControl status;
  290. status.id = "machine-status";
  291. status.type = HmiControlType::StatusText;
  292. status.text = "Status";
  293. status.binding = RegisterAddress{RegisterArea::M, 5};
  294. status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"};
  295. require(runtime.readStatusText(status).text == "Stopped",
  296. "an OFF M bit must resolve to the configured OFF text");
  297. repository.writeBit(*status.binding, true);
  298. require(runtime.readStatusText(status).text == "Running",
  299. "an ON M bit must resolve to the configured ON text");
  300. require(runtime.writeNumericInput(status, 1).error
  301. == HmiRuntimeError::UnsupportedControl,
  302. "status text must remain read-only");
  303. status.binding = RegisterAddress{RegisterArea::D, 20};
  304. status.dataType = RegisterDataType::Float32;
  305. status.statusText = HmiStatusWordTextConfig{{
  306. {std::nullopt, 30.0, "Low"},
  307. {30.0, 80.0, "Normal"},
  308. {80.0, std::nullopt, "High"}}};
  309. const auto write_float = [&repository, &status](float value)
  310. {
  311. const auto words = Float32Codec::encode(value);
  312. repository.writeWords(*status.binding, {words[0], words[1]});
  313. };
  314. write_float(29.5f);
  315. require(runtime.readStatusText(status).text == "Low",
  316. "values below the first upper bound must resolve to the first text");
  317. write_float(30.0f);
  318. require(runtime.readStatusText(status).text == "Normal",
  319. "a shared boundary must belong to the range starting at that boundary");
  320. write_float(80.0f);
  321. require(runtime.readStatusText(status).text == "High",
  322. "the final boundary must belong to the unlimited upper range");
  323. repository.writeWords(
  324. *status.binding,
  325. {static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7fc0)});
  326. require(!runtime.readStatusText(status).succeeded,
  327. "NaN register bits must make status text unavailable");
  328. }
  329. void testHistoryAndAtomicBatchDelete()
  330. {
  331. TestProjectStorage storage;
  332. ProjectService project_service(storage, defaultProjectLimitSettings());
  333. HmiEditorService service(project_service, HmiDefaultSettings{});
  334. const std::string page_id = service.ensureDefaultPage().id;
  335. const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label);
  336. const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label);
  337. const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label);
  338. require(first.succeeded && second.succeeded && third.succeeded,
  339. "controls for history testing must be created");
  340. service.clearHistory();
  341. require(!service.removeControls(page_id, {first.id, "missing-control"}).succeeded,
  342. "batch deletion must reject an unknown control before changing the page");
  343. require(service.findPage(page_id)->controls.size() == 3U
  344. && !service.canUndo(),
  345. "failed batch deletion must be atomic and leave history unchanged");
  346. require(service.removeControls(page_id, {first.id, second.id}).succeeded,
  347. "batch deletion of valid controls must succeed");
  348. require(service.findPage(page_id)->controls.size() == 1U
  349. && service.canUndo(),
  350. "valid batch deletion must remove all selected controls in one history step");
  351. require(service.undo().succeeded && service.findPage(page_id)->controls.size() == 3U,
  352. "HMI undo must restore a deleted batch");
  353. require(service.redo().succeeded && service.findPage(page_id)->controls.size() == 1U,
  354. "HMI redo must reapply a deleted batch");
  355. service.clearHistory();
  356. const HmiControl *remaining = service.findControl(page_id, third.id);
  357. require(remaining != nullptr,
  358. "the unselected control must survive a batch deletion");
  359. require(service.moveControl(page_id, third.id, remaining->bounds).succeeded
  360. && !service.canUndo(),
  361. "a no-op control move must not consume an undo step");
  362. service.clearHistory();
  363. HmiControl candidate = *service.findControl(page_id, third.id);
  364. for (int index = 1; index <= 101; ++index)
  365. {
  366. candidate.bounds.x = index;
  367. require(service.updateControl(page_id, third.id, candidate).succeeded,
  368. "repeated valid HMI edits must succeed");
  369. }
  370. int undo_count = 0;
  371. while (service.undo().succeeded)
  372. {
  373. ++undo_count;
  374. }
  375. require(undo_count == static_cast<int>(EditorHistory<int>::kMaximumEntries),
  376. "HMI history must retain exactly the configured most recent steps");
  377. }
  378. void testBatchPasteControls()
  379. {
  380. TestProjectStorage storage;
  381. ProjectService project_service(storage, defaultProjectLimitSettings());
  382. HmiEditorService service(project_service, HmiDefaultSettings{});
  383. const std::string page_id = service.ensureDefaultPage().id;
  384. const HmiEditorResult first = service.addControl(
  385. page_id, HmiControlType::NumericDisplay);
  386. const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label);
  387. require(first.succeeded && second.succeeded,
  388. "controls for paste testing must be created");
  389. HmiControl first_copy = *service.findControl(page_id, first.id);
  390. HmiControl second_copy = *service.findControl(page_id, second.id);
  391. first_copy.bounds = {100, 80, 80, 32};
  392. first_copy.type = HmiControlType::NumericDisplay;
  393. first_copy.text = "复制 Double";
  394. first_copy.binding = RegisterAddress{RegisterArea::D, 100};
  395. first_copy.dataType = RegisterDataType::Float64;
  396. first_copy.properties[HmiAppearanceProperty::kTextColor] = "#E53935";
  397. second_copy.bounds = {220, 80, 80, 32};
  398. require(service.updateControl(page_id, first.id, first_copy).succeeded
  399. && service.updateControl(page_id, second.id, second_copy).succeeded,
  400. "source controls must be movable before paste");
  401. service.clearHistory();
  402. const HmiEditorResult pasted = service.pasteControls(
  403. page_id, {first_copy, second_copy});
  404. require(pasted.succeeded
  405. && service.findPage(page_id)->controls.size() == 4U
  406. && pasted.id != first.id && pasted.id != second.id,
  407. "batch paste must create controls with fresh ids");
  408. const HmiControl *pasted_control = service.findControl(page_id, pasted.id);
  409. require(pasted_control != nullptr
  410. && pasted_control->bounds.x == first_copy.bounds.x + 20
  411. && pasted_control->bounds.y == first_copy.bounds.y + 20
  412. && pasted_control->text == first_copy.text
  413. && pasted_control->binding == first_copy.binding
  414. && pasted_control->dataType == RegisterDataType::Float64
  415. && pasted_control->properties == first_copy.properties,
  416. "pasted controls must preserve data type, binding and appearance with an offset");
  417. require(service.undo().succeeded
  418. && service.findPage(page_id)->controls.size() == 2U,
  419. "batch control paste must be one undoable operation");
  420. }
  421. void testRuntimeValidationAndPasteBoundaries()
  422. {
  423. TestProjectStorage storage;
  424. ProjectService project_service(storage, defaultProjectLimitSettings());
  425. HmiEditorService editor(project_service, HmiDefaultSettings{});
  426. const std::string page_id = editor.ensureDefaultPage().id;
  427. HmiControl label;
  428. label.id = "label-source";
  429. label.type = HmiControlType::Label;
  430. label.bounds = {760, 360, 32, 32};
  431. label.text = "边界";
  432. require(editor.pasteControls(page_id, {label}, 20, 20).succeeded,
  433. "a paste near the page edge must be clamped into the page");
  434. const HmiPage *page = editor.findPage(page_id);
  435. require(page != nullptr && page->controls.size() == 1U,
  436. "edge paste must add exactly one control");
  437. const HmiControl &edge_copy = page->controls.front();
  438. require(edge_copy.bounds.x + edge_copy.bounds.width <= page->width
  439. && edge_copy.bounds.y + edge_copy.bounds.height <= page->height
  440. && edge_copy.bounds.x >= 0 && edge_copy.bounds.y >= 0,
  441. "edge paste must keep the copied control inside page bounds");
  442. require(!editor.pasteControls(page_id, {}).succeeded,
  443. "an empty HMI clipboard must be rejected");
  444. HmiControl oversized = label;
  445. oversized.bounds = {0, 0, page->width + 1, 32};
  446. require(!editor.pasteControls(page_id, {oversized}).succeeded
  447. && editor.findPage(page_id)->controls.size() == 1U,
  448. "a copied control wider than the target page must fail atomically");
  449. HmiControl unsupported = label;
  450. unsupported.type = HmiControlType::Count;
  451. require(!editor.pasteControls(page_id, {unsupported}).succeeded
  452. && editor.findPage(page_id)->controls.size() == 1U,
  453. "an unsupported copied HMI type must fail atomically");
  454. std::vector<HmiControl> too_many(513U, label);
  455. require(!editor.pasteControls(page_id, too_many).succeeded
  456. && editor.findPage(page_id)->controls.size() == 1U,
  457. "a paste batch above the page limit must be rejected before mutation");
  458. VirtualRegisterRepository repository;
  459. HmiRuntimeService runtime(repository);
  460. HmiControl unbound_indicator;
  461. unbound_indicator.type = HmiControlType::Indicator;
  462. require(runtime.readControl(unbound_indicator).error
  463. == HmiRuntimeError::MissingBinding,
  464. "an unbound runtime indicator must report MissingBinding");
  465. HmiControl wrong_area_indicator = unbound_indicator;
  466. wrong_area_indicator.binding = RegisterAddress{RegisterArea::D, 0};
  467. require(runtime.readControl(wrong_area_indicator).error
  468. == HmiRuntimeError::InvalidBinding,
  469. "an indicator bound to D must report InvalidBinding");
  470. HmiControl invalid_indicator = unbound_indicator;
  471. invalid_indicator.binding = RegisterAddress{RegisterArea::M, 4001};
  472. require(runtime.readControl(invalid_indicator).error
  473. == HmiRuntimeError::InvalidBinding,
  474. "an indicator with an out-of-range address must report InvalidBinding");
  475. require(runtime.readControl(label).error
  476. == HmiRuntimeError::UnsupportedControl,
  477. "a static label must not be treated as a register runtime control");
  478. HmiControl wrong_button = unbound_indicator;
  479. wrong_button.type = HmiControlType::Button;
  480. wrong_button.binding = RegisterAddress{RegisterArea::D, 0};
  481. require(runtime.operateButton(wrong_button, HmiButtonEvent::Pressed).error
  482. == HmiRuntimeError::InvalidBinding,
  483. "a button bound to D must reject runtime writes");
  484. HmiControl wrong_numeric = unbound_indicator;
  485. wrong_numeric.type = HmiControlType::NumericInput;
  486. wrong_numeric.binding = RegisterAddress{RegisterArea::M, 0};
  487. require(runtime.writeNumericInput(wrong_numeric, 1).error
  488. == HmiRuntimeError::InvalidBinding,
  489. "a numeric input bound to M must reject runtime writes");
  490. }
  491. void testAppearanceEditing()
  492. {
  493. TestProjectStorage storage;
  494. ProjectService project_service(storage, defaultProjectLimitSettings());
  495. HmiEditorService service(project_service, HmiDefaultSettings{});
  496. const std::string page_id = service.ensureDefaultPage().id;
  497. const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label);
  498. require(label.succeeded, "a label must be available for appearance editing");
  499. HmiControl appearance = *service.findControl(page_id, label.id);
  500. appearance.properties[HmiAppearanceProperty::kTextColor] = "#E53935";
  501. appearance.properties[HmiAppearanceProperty::kFontSize] = "18";
  502. appearance.properties[HmiAppearanceProperty::kFontBold] = "true";
  503. appearance.properties[HmiAppearanceProperty::kFontItalic] = "false";
  504. require(service.updateControl(page_id, label.id, appearance).succeeded,
  505. "valid appearance properties must be applied atomically");
  506. const HmiControl *updated = service.findControl(page_id, label.id);
  507. require(updated != nullptr
  508. && updated->properties.at(HmiAppearanceProperty::kTextColor) == "#E53935"
  509. && updated->properties.at(HmiAppearanceProperty::kFontSize) == "18"
  510. && updated->properties.at(HmiAppearanceProperty::kFontBold) == "true",
  511. "appearance properties must be stored on the HMI control");
  512. HmiControl invalid = *updated;
  513. invalid.properties[HmiAppearanceProperty::kTextColor] = "invalid";
  514. require(!service.updateControl(page_id, label.id, invalid).succeeded,
  515. "invalid appearance properties must be rejected without a partial update");
  516. require(service.findControl(page_id, label.id)->properties.at(
  517. HmiAppearanceProperty::kTextColor) == "#E53935",
  518. "failed appearance updates must leave the old color intact");
  519. require(service.undo().succeeded,
  520. "appearance updates must participate in HMI undo history");
  521. require(service.findControl(page_id, label.id)->properties.empty(),
  522. "undo must remove the applied appearance properties");
  523. require(service.redo().succeeded
  524. && service.findControl(page_id, label.id)->properties.at(
  525. HmiAppearanceProperty::kFontSize) == "18",
  526. "redo must restore the applied appearance properties");
  527. }
  528. void testPageLifecycleAndNavigation()
  529. {
  530. TestProjectStorage storage;
  531. ProjectService project_service(storage, defaultProjectLimitSettings());
  532. HmiEditorService service(project_service, HmiDefaultSettings{});
  533. const std::string main_id = service.ensureDefaultPage().id;
  534. require(project_service.project().initialHmiPageId == main_id,
  535. "the first page must become the initial HMI page");
  536. const HmiEditorResult settings = service.addPage("Settings");
  537. const HmiEditorResult maintenance = service.addPage("Maintenance");
  538. require(settings.succeeded && maintenance.succeeded,
  539. "multiple HMI pages must be creatable");
  540. require(service.renamePage(settings.id, "Parameters").succeeded,
  541. "an HMI page must be renamable by stable id");
  542. require(service.renamePage(maintenance.id, "Parameters").error
  543. == HmiEditorError::DuplicateName,
  544. "HMI page names must remain unique");
  545. require(service.movePage(maintenance.id, -1).succeeded
  546. && project_service.project().hmiPages.at(1).id == maintenance.id,
  547. "HMI page order must be editable independently from page ids");
  548. const HmiEditorResult label = service.addControl(main_id, HmiControlType::Label);
  549. require(label.succeeded
  550. && service.findControl(main_id, label.id)->isConfigured(),
  551. "the editor service must expose a configured static Label control");
  552. const HmiEditorResult alarm_list = service.addControl(
  553. main_id, HmiControlType::AlarmList);
  554. require(alarm_list.succeeded
  555. && service.findControl(main_id, alarm_list.id)->isConfigured()
  556. && !service.findControl(main_id, alarm_list.id)->binding.has_value(),
  557. "AlarmList must be configured without a register binding");
  558. HmiControl bound_alarm_list = *service.findControl(main_id, alarm_list.id);
  559. bound_alarm_list.binding = RegisterAddress{RegisterArea::M, 20};
  560. require(!service.updateControl(
  561. main_id, alarm_list.id, bound_alarm_list).succeeded,
  562. "AlarmList must reject direct register bindings");
  563. const HmiEditorResult jump = service.addControl(main_id, HmiControlType::PageJump);
  564. require(jump.succeeded, "the editor service must expose PageJump creation");
  565. HmiControl jump_control = *service.findControl(main_id, jump.id);
  566. jump_control.pageJump = HmiPageJumpConfig{maintenance.id};
  567. require(service.updateControl(main_id, jump.id, jump_control).succeeded,
  568. "PageJump target updates must resolve stable page ids");
  569. require(service.removePage(maintenance.id).error == HmiEditorError::PageReferenced,
  570. "a page referenced by PageJump must not be deletable");
  571. require(service.setInitialPage(settings.id).succeeded,
  572. "the user must be able to select a different initial page");
  573. require(service.removePage(settings.id).error
  574. == HmiEditorError::InitialPageCannotBeRemoved,
  575. "the initial page must not be deleted implicitly");
  576. HmiNavigationService navigation(project_service);
  577. const HmiNavigationResult start = navigation.start();
  578. require(start.succeeded && start.pageId == settings.id,
  579. "runtime navigation must start from the persisted initial page");
  580. require(navigation.navigateTo(maintenance.id).succeeded
  581. && navigation.currentPageId() == maintenance.id,
  582. "runtime navigation must switch to an existing page");
  583. require(navigation.navigateTo("missing").error
  584. == HmiNavigationError::PageNotFound,
  585. "runtime navigation must reject an unknown page id");
  586. navigation.stop();
  587. require(navigation.currentPageId().empty(),
  588. "stopping runtime navigation must clear session state");
  589. }
  590. void testPageResizeIsAtomicAndUndoable()
  591. {
  592. TestProjectStorage storage;
  593. ProjectService project_service(storage, defaultProjectLimitSettings());
  594. HmiEditorService service(project_service, HmiDefaultSettings{});
  595. const std::string page_id = service.ensureDefaultPage().id;
  596. const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label);
  597. require(label.succeeded, "page resize fixture must add a control");
  598. HmiControl control = *service.findControl(page_id, label.id);
  599. control.bounds = {700, 340, 80, 32};
  600. require(service.updateControl(page_id, label.id, control).succeeded,
  601. "page resize fixture must place the control near the page edge");
  602. require(service.resizePage(page_id, 1024, 600).succeeded,
  603. "an HMI page must be resizable to a larger valid rectangle");
  604. require(service.findPage(page_id)->width == 1024
  605. && service.findPage(page_id)->height == 600,
  606. "page resize must update both dimensions");
  607. require(service.resizePage(page_id, 700, 300).error
  608. == HmiEditorError::InvalidPage,
  609. "page resize must reject dimensions that clip an existing control");
  610. require(service.findPage(page_id)->width == 1024
  611. && service.findPage(page_id)->height == 600,
  612. "a rejected page resize must leave the original dimensions intact");
  613. require(service.undo().succeeded
  614. && service.findPage(page_id)->width == 800
  615. && service.findPage(page_id)->height == 400,
  616. "page resize must participate in HMI undo history");
  617. require(service.redo().succeeded
  618. && service.findPage(page_id)->width == 1024
  619. && service.findPage(page_id)->height == 600,
  620. "page resize redo must restore the new dimensions");
  621. require(service.resizePage(page_id, 319, 600).error
  622. == HmiEditorError::InvalidPage,
  623. "page width below the business minimum must be rejected");
  624. }
  625. void testConfiguredPageDefaultsAndLimits()
  626. {
  627. TestProjectStorage storage;
  628. ProjectLimitSettings limits;
  629. limits.maximumHmiPages = 1U;
  630. limits.maximumHmiControlsPerPage = 1U;
  631. HmiDefaultSettings defaults;
  632. defaults.pageWidth = 1024;
  633. defaults.pageHeight = 600;
  634. ProjectService project_service(storage, limits);
  635. HmiEditorService service(project_service, defaults);
  636. const HmiEditorResult page = service.ensureDefaultPage();
  637. require(page.succeeded
  638. && service.findPage(page.id)->width == 1024
  639. && service.findPage(page.id)->height == 600,
  640. "configured HMI dimensions must initialize the default page");
  641. require(!service.addPage("Second page").succeeded,
  642. "the HMI editor must use the configured page limit");
  643. require(service.addControl(page.id, HmiControlType::Label).succeeded
  644. && !service.addControl(page.id, HmiControlType::Label).succeeded,
  645. "the HMI editor must use the configured per-page control limit");
  646. }
  647. } // namespace
  648. int main()
  649. {
  650. try
  651. {
  652. // 编辑和运行场景分别验证服务层两条独立职责
  653. testControlEditing();
  654. testBatchControlAlignment();
  655. testHistoryAndAtomicBatchDelete();
  656. testBatchPasteControls();
  657. testRuntimeValidationAndPasteBoundaries();
  658. testAppearanceEditing();
  659. testRuntimeUsesRegisterRepository();
  660. testStatusTextRuntimeMapping();
  661. testPageLifecycleAndNavigation();
  662. testPageResizeIsAtomicAndUndoable();
  663. testConfiguredPageDefaultsAndLimits();
  664. }
  665. catch (const std::exception &error)
  666. {
  667. std::cerr << "HMI editor service tests failed: " << error.what() << '\n';
  668. return 1;
  669. }
  670. std::cout << "HMI editor service tests passed\n";
  671. return 0;
  672. }