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

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