综合平台编程器项目的远程存储
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.
 
 
 
 

1208 linhas
52 KiB

  1. #include "domain/active_register_repository.h"
  2. #include "domain/project_storage.h"
  3. #include "domain/register_repository.h"
  4. #include "services/alarm_editor_service.h"
  5. #include "services/alarm_service.h"
  6. #include "services/hmi_editor_service.h"
  7. #include "services/hmi_runtime_service.h"
  8. #include "services/hmi_navigation_service.h"
  9. #include "services/logic_editor_service.h"
  10. #include "services/offline_simulation_service.h"
  11. #include "services/project_service.h"
  12. #include "services/runtime_mode_service.h"
  13. #include "services/register_monitor_service.h"
  14. #include "services/register_comment_service.h"
  15. #include "ui/hmi_editor_widget.h"
  16. #include "ui/free_monitor_widget.h"
  17. #include "ui/logic_editor_widget.h"
  18. #include "ui/main_window.h"
  19. #include "ui/plc_connection_dialog.h"
  20. #include "ui/runtime_monitor_widget.h"
  21. #include <QAction>
  22. #include <QApplication>
  23. #include <QComboBox>
  24. #include <QDialog>
  25. #include <QDockWidget>
  26. #include <QGraphicsItem>
  27. #include <QGraphicsScene>
  28. #include <QImage>
  29. #include <QLabel>
  30. #include <QLineEdit>
  31. #include <QListWidget>
  32. #include <QMenu>
  33. #include <QPainter>
  34. #include <QSpinBox>
  35. #include <QPushButton>
  36. #include <QStringList>
  37. #include <QTableWidget>
  38. #include <QTimer>
  39. #include <QTest>
  40. #include <QTreeWidget>
  41. #include <iostream>
  42. #include <stdexcept>
  43. #include <string>
  44. namespace {
  45. class TestProjectStorage final : public ProjectStorage
  46. {
  47. public:
  48. // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
  49. ProjectSaveResult save(const Project &, const std::string &) override
  50. {
  51. return {true, ProjectStorageError::None, {}};
  52. }
  53. ProjectLoadResult load(const std::string &) override
  54. {
  55. return {false, {}, ProjectStorageError::FileReadFailed, {}};
  56. }
  57. };
  58. class RepeatedStatusGateway final : public PlcCommunicationGateway
  59. {
  60. public:
  61. PlcCommunicationResult connectDevice(
  62. const PlcSerialConfiguration &) override
  63. {
  64. connection_state = PlcConnectionState::Connecting;
  65. notifyStateChanged();
  66. connection_state = PlcConnectionState::Connected;
  67. notifyStateChanged();
  68. notifyStateChanged();
  69. return {true, {}};
  70. }
  71. void disconnectDevice() override
  72. {
  73. connection_state = PlcConnectionState::Disconnected;
  74. notifyStateChanged();
  75. }
  76. void setPollAddresses(const std::vector<RegisterAddress> &) override {}
  77. PlcConnectionState state() const override { return connection_state; }
  78. bool initialReadCompleted() const override { return false; }
  79. PlcCommunicationError lastErrorType() const override
  80. {
  81. return PlcCommunicationError::None;
  82. }
  83. const std::string &lastError() const override { return last_error; }
  84. void setCallbacks(
  85. std::function<void()> state_callback,
  86. std::function<void(bool)> initial_callback,
  87. std::function<void()> cache_callback,
  88. std::function<void(const std::string &)> error_callback) override
  89. {
  90. state_changed = std::move(state_callback);
  91. initial_read_changed = std::move(initial_callback);
  92. cache_updated = std::move(cache_callback);
  93. error_reported = std::move(error_callback);
  94. }
  95. private:
  96. void notifyStateChanged()
  97. {
  98. if (state_changed)
  99. {
  100. state_changed();
  101. }
  102. }
  103. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  104. std::string last_error;
  105. std::function<void()> state_changed;
  106. std::function<void(bool)> initial_read_changed;
  107. std::function<void()> cache_updated;
  108. std::function<void(const std::string &)> error_reported;
  109. };
  110. class OnlineReadyGateway final : public PlcCommunicationGateway
  111. {
  112. public:
  113. PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override
  114. {
  115. connection_state = PlcConnectionState::Connected;
  116. if (state_changed)
  117. {
  118. state_changed();
  119. }
  120. return {true, {}};
  121. }
  122. void disconnectDevice() override
  123. {
  124. connection_state = PlcConnectionState::Disconnected;
  125. initial_read = false;
  126. if (initial_read_changed)
  127. {
  128. initial_read_changed(false);
  129. }
  130. if (state_changed)
  131. {
  132. state_changed();
  133. }
  134. }
  135. void setPollAddresses(const std::vector<RegisterAddress> &addresses) override
  136. {
  137. poll_addresses = addresses;
  138. }
  139. PlcConnectionState state() const override { return connection_state; }
  140. bool initialReadCompleted() const override { return initial_read; }
  141. PlcCommunicationError lastErrorType() const override { return last_error_type; }
  142. const std::string &lastError() const override { return last_error; }
  143. void setCallbacks(
  144. std::function<void()> state_callback,
  145. std::function<void(bool)> initial_callback,
  146. std::function<void()> cache_callback,
  147. std::function<void(const std::string &)> error_callback) override
  148. {
  149. state_changed = std::move(state_callback);
  150. initial_read_changed = std::move(initial_callback);
  151. cache_updated = std::move(cache_callback);
  152. error_reported = std::move(error_callback);
  153. }
  154. void completeInitialRead()
  155. {
  156. initial_read = true;
  157. last_error_type = PlcCommunicationError::None;
  158. last_error.clear();
  159. if (connection_state == PlcConnectionState::Recovering)
  160. {
  161. connection_state = PlcConnectionState::Connected;
  162. if (state_changed)
  163. {
  164. state_changed();
  165. }
  166. }
  167. if (initial_read_changed)
  168. {
  169. initial_read_changed(true);
  170. }
  171. }
  172. void timeoutCommunication(const std::string &message)
  173. {
  174. initial_read = false;
  175. last_error_type = PlcCommunicationError::CommunicationTimeout;
  176. last_error = message;
  177. connection_state = PlcConnectionState::Faulted;
  178. if (initial_read_changed)
  179. {
  180. initial_read_changed(false);
  181. }
  182. if (state_changed)
  183. {
  184. state_changed();
  185. }
  186. if (error_reported)
  187. {
  188. error_reported(last_error);
  189. }
  190. }
  191. void recoverCommunication()
  192. {
  193. initial_read = false;
  194. last_error_type = PlcCommunicationError::None;
  195. last_error.clear();
  196. connection_state = PlcConnectionState::Recovering;
  197. if (state_changed)
  198. {
  199. state_changed();
  200. }
  201. }
  202. void loseSerialConnection(const std::string &message)
  203. {
  204. initial_read = false;
  205. last_error_type = PlcCommunicationError::SerialConnectionLost;
  206. last_error = message;
  207. connection_state = PlcConnectionState::Disconnected;
  208. if (initial_read_changed)
  209. {
  210. initial_read_changed(false);
  211. }
  212. if (state_changed)
  213. {
  214. state_changed();
  215. }
  216. if (error_reported)
  217. {
  218. error_reported(last_error);
  219. }
  220. }
  221. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  222. bool initial_read = false;
  223. PlcCommunicationError last_error_type = PlcCommunicationError::None;
  224. std::string last_error;
  225. std::vector<RegisterAddress> poll_addresses;
  226. std::function<void()> state_changed;
  227. std::function<void(bool)> initial_read_changed;
  228. std::function<void()> cache_updated;
  229. std::function<void(const std::string &)> error_reported;
  230. };
  231. void require(bool condition, const std::string &message)
  232. {
  233. if (!condition)
  234. {
  235. throw std::runtime_error(message);
  236. }
  237. }
  238. template<typename ObjectType>
  239. ObjectType *requiredChild(MainWindow &window, const char *name)
  240. {
  241. // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
  242. ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
  243. require(child != nullptr, std::string("missing UI object ") + name);
  244. return child;
  245. }
  246. QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position)
  247. {
  248. QImage image(view.viewport()->size(), QImage::Format_ARGB32_Premultiplied);
  249. image.fill(Qt::transparent);
  250. QPainter painter(&image);
  251. view.viewport()->render(&painter);
  252. return image.pixelColor(viewport_position);
  253. }
  254. void testRuntimeButtonMouseInteraction()
  255. {
  256. TestProjectStorage storage;
  257. ProjectService project_service(storage);
  258. HmiEditorService editor_service(project_service);
  259. VirtualRegisterRepository repository;
  260. HmiRuntimeService runtime_service(repository);
  261. AlarmService alarm_service(project_service, repository);
  262. const HmiEditorResult page_result = editor_service.ensureDefaultPage();
  263. require(page_result.succeeded, "runtime button test must create an HMI page");
  264. const HmiEditorResult button_result = editor_service.addControl(
  265. page_result.id, HmiControlType::Button);
  266. require(button_result.succeeded, "runtime button test must create a button");
  267. HmiControl button = *editor_service.findControl(page_result.id, button_result.id);
  268. button.binding = RegisterAddress{RegisterArea::M, 0};
  269. button.buttonOperation = HmiButtonOperation::MomentaryOn;
  270. require(editor_service.updateControl(page_result.id, button.id, button).succeeded,
  271. "runtime button test must bind the button to M0");
  272. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  273. view.resize(900, 600);
  274. view.setPageId(page_result.id);
  275. view.setEditingEnabled(false);
  276. view.setRuntimeActive(true);
  277. view.setRuntimeWriteEnabled(true);
  278. view.show();
  279. QApplication::processEvents();
  280. const QPoint center = view.mapFromScene(QPointF(
  281. button.bounds.x + button.bounds.width / 2.0,
  282. button.bounds.y + button.bounds.height / 2.0));
  283. const QPoint color_sample = view.mapFromScene(QPointF(
  284. button.bounds.x + 8.0, button.bounds.y + 8.0));
  285. const QColor normal_color = renderedColorAt(view, color_sample);
  286. QTest::mouseMove(view.viewport(), center);
  287. QApplication::processEvents();
  288. QGraphicsItem *button_item = view.itemAt(center);
  289. require(button_item != nullptr,
  290. "runtime button must be hit-testable while writes are enabled");
  291. require(button_item->cursor().shape() == Qt::PointingHandCursor,
  292. "runtime button must use a pointing-hand cursor");
  293. const QColor hovered_color = renderedColorAt(view, color_sample);
  294. require(hovered_color != normal_color,
  295. "hovering a runtime button must change its visual state");
  296. QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  297. QApplication::processEvents();
  298. require(repository.readBit({RegisterArea::M, 0}).value,
  299. "pressing a momentary runtime button must write M0 ON");
  300. require(view.scene()->selectedItems().isEmpty(),
  301. "pressing a runtime button must not show an editing selection");
  302. const QColor pressed_color = renderedColorAt(view, color_sample);
  303. require(pressed_color != hovered_color,
  304. "pressing a runtime button must change its visual state");
  305. QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  306. QApplication::processEvents();
  307. require(!repository.readBit({RegisterArea::M, 0}).value,
  308. "releasing a momentary runtime button must write M0 OFF");
  309. require(renderedColorAt(view, color_sample) == hovered_color,
  310. "releasing a runtime button must restore its hovered visual state");
  311. view.setRuntimeWriteEnabled(false);
  312. QApplication::processEvents();
  313. const QColor disabled_color = renderedColorAt(view, color_sample);
  314. require(disabled_color != hovered_color,
  315. "a non-writable runtime button must use its disabled visual state");
  316. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  317. require(!repository.readBit({RegisterArea::M, 0}).value,
  318. "a disabled runtime button must not write its register");
  319. }
  320. void testRuntimePageJumpDoesNotRequireRegisterWritePermission()
  321. {
  322. TestProjectStorage storage;
  323. ProjectService project_service(storage);
  324. HmiEditorService editor_service(project_service);
  325. const std::string source_page = editor_service.ensureDefaultPage().id;
  326. const std::string target_page = editor_service.addPage("Target").id;
  327. const HmiEditorResult jump_result = editor_service.addControl(
  328. source_page, HmiControlType::PageJump);
  329. HmiControl jump = *editor_service.findControl(source_page, jump_result.id);
  330. jump.pageJump = HmiPageJumpConfig{target_page};
  331. require(editor_service.updateControl(
  332. source_page, jump_result.id, jump).succeeded,
  333. "PageJump fixture setup must succeed");
  334. VirtualRegisterRepository repository;
  335. HmiRuntimeService runtime_service(repository);
  336. AlarmService alarm_service(project_service, repository);
  337. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  338. view.setPageId(source_page);
  339. view.setEditingEnabled(false);
  340. view.setRuntimeActive(true);
  341. view.setRuntimeWriteEnabled(false);
  342. view.refreshRuntimeValues();
  343. view.resize(900, 560);
  344. view.show();
  345. QApplication::processEvents();
  346. QString requested_page;
  347. QObject::connect(
  348. &view, &HmiEditorWidget::pageNavigationRequested,
  349. [&requested_page](const QString &page_id)
  350. {
  351. requested_page = page_id;
  352. });
  353. QGraphicsItem *jump_item = nullptr;
  354. for (QGraphicsItem *item : view.scene()->items())
  355. {
  356. if (item->zValue() >= 0.0)
  357. {
  358. jump_item = item;
  359. break;
  360. }
  361. }
  362. require(jump_item != nullptr, "PageJump graphics item must be rendered");
  363. const QPoint center = view.mapFromScene(
  364. jump_item->mapToScene(jump_item->boundingRect().center()));
  365. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  366. require(requested_page == QString::fromStdString(target_page),
  367. "PageJump must remain active when register writes are disabled");
  368. }
  369. void testRuntimeAlarmListInteraction()
  370. {
  371. TestProjectStorage storage;
  372. ProjectService project_service(storage);
  373. HmiEditorService editor_service(project_service);
  374. AlarmEditorService alarm_editor_service(project_service);
  375. VirtualRegisterRepository repository;
  376. HmiRuntimeService runtime_service(repository);
  377. AlarmService alarm_service(project_service, repository);
  378. const std::string page_id = editor_service.ensureDefaultPage().id;
  379. const HmiEditorResult list_result = editor_service.addControl(
  380. page_id, HmiControlType::AlarmList);
  381. require(list_result.succeeded,
  382. "runtime alarm test must create an AlarmList control");
  383. const HmiControl *alarm_list = editor_service.findControl(
  384. page_id, list_result.id);
  385. require(alarm_list != nullptr && !alarm_list->binding.has_value(),
  386. "AlarmList must not require a register binding");
  387. AlarmDefinition definition;
  388. definition.address = RegisterAddress{RegisterArea::M, 0};
  389. definition.condition = AlarmCondition::MOn;
  390. definition.message = "Emergency stop";
  391. const AlarmEditorResult alarm_result =
  392. alarm_editor_service.addDefinition(definition);
  393. require(alarm_result.succeeded,
  394. "runtime alarm test must create an M alarm definition");
  395. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  396. view.setPageId(page_id);
  397. QGraphicsItem *alarm_item = nullptr;
  398. for (QGraphicsItem *item : view.scene()->items())
  399. {
  400. if (item->zValue() >= 0.0)
  401. {
  402. alarm_item = item;
  403. break;
  404. }
  405. }
  406. require(alarm_item != nullptr && alarm_item->isVisible(),
  407. "AlarmList must remain visible while editing");
  408. view.setEditingEnabled(false);
  409. view.setRuntimeActive(true);
  410. view.setRuntimeWriteEnabled(false);
  411. view.refreshRuntimeValues();
  412. view.resize(900, 560);
  413. view.show();
  414. QApplication::processEvents();
  415. require(!alarm_item->isVisible(),
  416. "AlarmList must be hidden when runtime has no active alarm");
  417. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
  418. alarm_service.refresh();
  419. view.refreshRuntimeValues();
  420. QApplication::processEvents();
  421. require(alarm_service.records().size() == 1,
  422. "an active M alarm must be available to AlarmList");
  423. require(alarm_item->isVisible() && alarm_item->zValue() > 0.0,
  424. "AlarmList must appear above the page for an active alarm");
  425. const QPoint header_position = view.mapFromScene(QPointF(
  426. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0));
  427. QTest::mouseClick(
  428. view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position);
  429. require(!alarm_service.records().front().acknowledged,
  430. "clicking the AlarmList header must not acknowledge an alarm");
  431. const QPoint first_row_position = view.mapFromScene(QPointF(
  432. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0));
  433. QTest::mouseClick(
  434. view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position);
  435. require(alarm_service.records().front().acknowledged,
  436. "clicking an active AlarmList row must acknowledge the alarm");
  437. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false);
  438. alarm_service.refresh();
  439. view.refreshRuntimeValues();
  440. QApplication::processEvents();
  441. require(alarm_service.records().empty(),
  442. "clearing the M condition must remove the alarm immediately");
  443. require(!alarm_item->isVisible(),
  444. "AlarmList must hide after the final alarm recovers");
  445. view.setRuntimeActive(false);
  446. view.setEditingEnabled(true);
  447. require(alarm_item->isVisible(),
  448. "AlarmList must become visible again after returning to editing");
  449. }
  450. void testModeActionsControlEditingAvailability()
  451. {
  452. // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
  453. TestProjectStorage storage;
  454. ProjectService project_service(storage);
  455. HmiEditorService editor_service(project_service);
  456. LogicEditorService logic_editor_service(project_service);
  457. VirtualRegisterRepository repository;
  458. HmiRuntimeService runtime_service(repository);
  459. AlarmEditorService alarm_editor_service(project_service);
  460. AlarmService alarm_service(project_service, repository);
  461. RegisterCommentService register_comment_service(project_service);
  462. OfflineSimulationService simulation_service(repository);
  463. RuntimeModeService mode_service(project_service, simulation_service);
  464. RegisterMonitorService monitor_service(repository);
  465. MainWindow window(
  466. mode_service,
  467. project_service,
  468. editor_service,
  469. logic_editor_service,
  470. runtime_service,
  471. alarm_editor_service,
  472. alarm_service,
  473. register_comment_service,
  474. monitor_service);
  475. window.resize(1000, 640);
  476. window.show();
  477. QApplication::processEvents();
  478. require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
  479. "the removed data point dock must not remain in the main window");
  480. require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
  481. && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
  482. "HMI and ladder properties must use direct M/D address inputs");
  483. QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
  484. QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
  485. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  486. QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
  487. QAction *add_indicator_action = requiredChild<QAction>(window, "addIndicatorAction");
  488. QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
  489. QAction *add_normally_open_action = requiredChild<QAction>(
  490. window, "addNormallyOpenAction");
  491. QAction *add_normal_coil_action = requiredChild<QAction>(
  492. window, "addNormalCoilAction");
  493. QAction *parallel_insert_action = requiredChild<QAction>(
  494. window, "parallelInsertAction");
  495. QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
  496. QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
  497. QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
  498. QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
  499. QComboBox *button_operation = requiredChild<QComboBox>(
  500. window, "buttonOperationComboBox");
  501. QPushButton *apply_properties = requiredChild<QPushButton>(
  502. window, "applyPropertiesButton");
  503. HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
  504. window, "hmiEditorWidget");
  505. QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
  506. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  507. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  508. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  509. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  510. require(!runtime_tab->isVisible(),
  511. "runtime monitor workspace must be hidden while editing");
  512. const qreal compact_scale = hmi_editor->transform().m11();
  513. window.resize(1600, 900);
  514. QApplication::processEvents();
  515. require(hmi_editor->transform().m11() > compact_scale,
  516. "HMI page must refit when the window becomes larger");
  517. require(editing_action->isChecked(), "editing action must be selected initially");
  518. require(project_dock->isEnabled(), "project dock must be enabled while editing");
  519. require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
  520. add_button_action->trigger();
  521. const std::string page_id = editor_service.firstPageId();
  522. require(editor_service.findPage(page_id)->controls.size() == 1,
  523. "adding a control must update the HMI page model");
  524. require(selection->text() == QStringLiteral("button-1(未绑定)"),
  525. "an unbound added control must be marked in the property panel");
  526. require(text_edit->text() == QStringLiteral("按钮"),
  527. "property panel must show the control text");
  528. require(button_operation->currentText() == QStringLiteral("瞬时 ON"),
  529. "new HMI buttons must default to momentary ON");
  530. const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
  531. require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
  532. "an unbound HMI control must not reserve an address label area");
  533. button_operation->setCurrentIndex(
  534. button_operation->findData(static_cast<int>(HmiButtonOperation::Toggle)));
  535. apply_properties->click();
  536. require(editor_service.findControl(page_id, "button-1")->buttonOperation
  537. == HmiButtonOperation::Toggle,
  538. "the property panel must update the HMI button operation");
  539. HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
  540. bound_button.binding = RegisterAddress{RegisterArea::M, 0};
  541. require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
  542. "binding an HMI button to M0 must succeed");
  543. hmi_editor->reloadPage();
  544. hmi_editor->selectControl(bound_button.id);
  545. const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
  546. require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
  547. "a bound HMI control must include its address label above the control body");
  548. delete_control_action->trigger();
  549. require(editor_service.findPage(page_id)->controls.empty(),
  550. "deleting a selected control must update the HMI page model");
  551. add_indicator_action->trigger();
  552. HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1");
  553. runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3};
  554. require(editor_service.updateControl(
  555. page_id, runtime_indicator.id, runtime_indicator).succeeded,
  556. "a configured HMI control must be available for the runtime projection");
  557. hmi_editor->reloadPage();
  558. add_normally_open_action->trigger();
  559. parallel_insert_action->trigger();
  560. add_normal_coil_action->trigger();
  561. const ControlLogic *logic = logic_editor_service.findLogic(
  562. logic_editor_service.firstLogicId());
  563. require(logic != nullptr && logic->rungs.size() == 1,
  564. "logic actions must edit the default ladder rung");
  565. require(logic->rungs.front().condition.has_value()
  566. && logic->rungs.front().condition->kind
  567. == ConditionExpressionKind::Parallel
  568. && logic->rungs.front().condition->children.size() == 2U,
  569. "parallel branch action must create a parallel expression");
  570. require(logic->rungs.front().output.has_value(),
  571. "coil action must set the fixed ladder output");
  572. offline_action->trigger();
  573. require(mode_service.mode() == ApplicationMode::Editing,
  574. "unconfigured ladder nodes must block offline running");
  575. const LadderRung &rung = logic->rungs.front();
  576. std::vector<const LogicNode *> condition_nodes;
  577. collectConditionNodes(*rung.condition, &condition_nodes);
  578. for (const LogicNode *node : condition_nodes)
  579. {
  580. require(logic_editor_service.updateNodeConfig(
  581. logic_editor_service.firstLogicId(),
  582. node->id,
  583. ContactNodeConfig{
  584. RegisterAddress{RegisterArea::M, 0},
  585. ContactMode::NormallyOpen})
  586. .succeeded,
  587. "applying a contact configuration must complete the ladder node");
  588. }
  589. require(logic_editor_service.updateNodeConfig(
  590. logic_editor_service.firstLogicId(),
  591. rung.output->id,
  592. CoilNodeConfig{
  593. RegisterAddress{RegisterArea::M, 1},
  594. CoilMode::Normal})
  595. .succeeded,
  596. "applying a coil configuration must complete the ladder node");
  597. offline_action->trigger();
  598. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  599. "offline action must enter offline running");
  600. require(simulation_service.state() == SimulationState::Running,
  601. "offline action must start the actual simulation service");
  602. require(executor_status->text().contains(QStringLiteral("运行")),
  603. "executor status label must report the actual running state");
  604. require(!project_dock->isEnabled(),
  605. "project dock must be disabled while running");
  606. require(!properties_dock->isEnabled(),
  607. "properties dock must be disabled while running");
  608. require(!add_button_action->isEnabled(),
  609. "HMI add controls must be disabled while running");
  610. require(!add_normally_open_action->isEnabled(),
  611. "logic add nodes must be disabled while running");
  612. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  613. && runtime_logic->isVisible() && free_monitor->isVisible(),
  614. "offline running must show HMI, ladder trace and free monitor together");
  615. auto *runtime_hmi_view = qobject_cast<HmiEditorWidget *>(runtime_hmi);
  616. require(runtime_hmi_view != nullptr
  617. && runtime_hmi_view->scene()->items().size() == 2,
  618. "offline runtime HMI must project the configured page control");
  619. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  620. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  621. QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
  622. monitor_address->setText(QStringLiteral("M0"));
  623. monitor_add->click();
  624. repository.writeBit({RegisterArea::M, 0}, true);
  625. auto *monitor_widget = requiredChild<FreeMonitorWidget>(window, "freeMonitorWidget");
  626. monitor_widget->refreshValues(
  627. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  628. require(monitor_table->rowCount() == 1
  629. && monitor_table->item(0, 2)->text() == QStringLiteral("ON"),
  630. "offline free monitor must read the same virtual M/D repository as HMI");
  631. online_action->trigger();
  632. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  633. "running modes must not switch directly through the UI");
  634. require(offline_action->isChecked(),
  635. "failed mode changes must restore the active action");
  636. editing_action->trigger();
  637. require(mode_service.mode() == ApplicationMode::Editing,
  638. "editing action must return to editing");
  639. require(simulation_service.state() == SimulationState::Stopped,
  640. "editing action must stop the simulation service");
  641. require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
  642. "executor status label must report the actual stopped state");
  643. require(project_dock->isEnabled(),
  644. "project dock must be restored after returning to editing");
  645. require(properties_dock->isEnabled(),
  646. "properties dock must be restored after returning to editing");
  647. require(add_button_action->isEnabled(),
  648. "HMI add controls must be restored after returning to editing");
  649. require(add_normally_open_action->isEnabled(),
  650. "logic add nodes must be restored after returning to editing");
  651. require(!runtime_tab->isVisible(),
  652. "returning to editing must hide the runtime monitor workspace");
  653. online_action->trigger();
  654. require(mode_service.mode() == ApplicationMode::Editing,
  655. "online action must require an initial PLC read");
  656. require(editing_action->isChecked(),
  657. "rejected online running must restore the editing action");
  658. }
  659. void testEdgeTimerPropertyEditingAndParallelMenu()
  660. {
  661. TestProjectStorage storage;
  662. ProjectService project_service(storage);
  663. HmiEditorService editor_service(project_service);
  664. LogicEditorService logic_editor_service(project_service);
  665. VirtualRegisterRepository repository;
  666. HmiRuntimeService runtime_service(repository);
  667. AlarmEditorService alarm_editor_service(project_service);
  668. AlarmService alarm_service(project_service, repository);
  669. RegisterCommentService register_comment_service(project_service);
  670. OfflineSimulationService simulation_service(repository);
  671. RuntimeModeService mode_service(project_service, simulation_service);
  672. RegisterMonitorService monitor_service(repository);
  673. MainWindow window(
  674. mode_service,
  675. project_service,
  676. editor_service,
  677. logic_editor_service,
  678. runtime_service,
  679. alarm_editor_service,
  680. alarm_service,
  681. register_comment_service,
  682. monitor_service);
  683. window.show();
  684. QApplication::processEvents();
  685. QAction *parallel_action = requiredChild<QAction>(window, "parallelInsertAction");
  686. require(parallel_action->menu() != nullptr,
  687. "parallel insertion must expose its node type menu");
  688. QStringList parallel_labels;
  689. for (QAction *action : parallel_action->menu()->actions())
  690. {
  691. parallel_labels.push_back(action->text());
  692. }
  693. require(parallel_labels.contains(QStringLiteral("并联上升沿触点"))
  694. && parallel_labels.contains(QStringLiteral("并联下降沿触点"))
  695. && parallel_labels.contains(QStringLiteral("并联 T 触点")),
  696. "parallel insertion must include edge and T contact choices");
  697. QAction *rising_action = requiredChild<QAction>(window, "addRisingEdgeAction");
  698. QAction *falling_action = requiredChild<QAction>(window, "addFallingEdgeAction");
  699. QAction *timer_contact_action = requiredChild<QAction>(
  700. window, "addTimerContactAction");
  701. QAction *ton_action = requiredChild<QAction>(window, "addTonAction");
  702. requiredChild<QAction>(window, "editRungCommentAction");
  703. requiredChild<QAction>(window, "configureRegisterCommentsAction");
  704. QSpinBox *address = requiredChild<QSpinBox>(window, "logicAddressSpinBox");
  705. QSpinBox *preset = requiredChild<QSpinBox>(window, "logicPresetSpinBox");
  706. QComboBox *mode = requiredChild<QComboBox>(window, "logicModeComboBox");
  707. QPushButton *apply = requiredChild<QPushButton>(
  708. window, "applyLogicPropertiesButton");
  709. const std::string logic_id = logic_editor_service.firstLogicId();
  710. rising_action->trigger();
  711. address->setValue(8);
  712. mode->setCurrentIndex(mode->findData(static_cast<int>(EdgeMode::Falling)));
  713. apply->click();
  714. const LogicNode *edge = logic_editor_service.findNode(logic_id, "edge-1");
  715. require(edge != nullptr && edge->configured
  716. && std::get<EdgeContactNodeConfig>(edge->config).address.index() == 8
  717. && std::get<EdgeContactNodeConfig>(edge->config).mode
  718. == EdgeMode::Falling,
  719. "edge properties must update M address and edge mode");
  720. timer_contact_action->trigger();
  721. address->setValue(9);
  722. mode->setCurrentIndex(mode->findData(
  723. static_cast<int>(ContactMode::NormallyClosed)));
  724. apply->click();
  725. const LogicNode *timer_contact = logic_editor_service.findNode(
  726. logic_id, "timer-contact-1");
  727. require(timer_contact != nullptr && timer_contact->configured
  728. && std::get<TimerContactNodeConfig>(timer_contact->config).address
  729. == TimerAddress{9}
  730. && std::get<TimerContactNodeConfig>(timer_contact->config).mode
  731. == ContactMode::NormallyClosed,
  732. "T contact properties must update T address and contact mode");
  733. ton_action->trigger();
  734. require(preset->isEnabled() && !mode->isEnabled(),
  735. "TON properties must enable PT and disable irrelevant mode editing");
  736. address->setValue(9);
  737. preset->setValue(1234);
  738. apply->click();
  739. const LogicNode *ton = logic_editor_service.findNode(logic_id, "ton-1");
  740. require(ton != nullptr && ton->configured
  741. && std::get<TonNodeConfig>(ton->config).address == TimerAddress{9}
  742. && std::get<TonNodeConfig>(ton->config).presetMs == 1234,
  743. "TON properties must update T address and preset milliseconds");
  744. falling_action->trigger();
  745. const LogicNode *falling = logic_editor_service.findNode(logic_id, "edge-2");
  746. require(falling != nullptr
  747. && std::get<EdgeContactNodeConfig>(falling->config).mode
  748. == EdgeMode::Falling,
  749. "falling edge action must create a falling-edge condition");
  750. }
  751. void testPlcConfigurationUsesDialog()
  752. {
  753. PlcSerialConfiguration initial;
  754. initial.portName = "COM17";
  755. initial.serverAddress = 12;
  756. initial.baudRate = 38400;
  757. initial.dataBits = 7;
  758. initial.parity = 3;
  759. initial.stopBits = 2;
  760. initial.responseTimeoutMs = 2500;
  761. initial.retries = 4;
  762. initial.pollIntervalMs = 350;
  763. PlcConnectionDialog configuration_dialog(initial);
  764. const PlcSerialConfiguration actual = configuration_dialog.configuration();
  765. require(actual.portName == initial.portName,
  766. "PLC dialog must preserve a manually entered serial port");
  767. require(actual.serverAddress == initial.serverAddress
  768. && actual.baudRate == initial.baudRate
  769. && actual.dataBits == initial.dataBits
  770. && actual.parity == initial.parity
  771. && actual.stopBits == initial.stopBits,
  772. "PLC dialog must preserve Modbus RTU serial parameters");
  773. require(actual.responseTimeoutMs == initial.responseTimeoutMs
  774. && actual.retries == initial.retries
  775. && actual.pollIntervalMs == initial.pollIntervalMs,
  776. "PLC dialog must preserve communication timing parameters");
  777. TestProjectStorage storage;
  778. ProjectService project_service(storage);
  779. HmiEditorService editor_service(project_service);
  780. LogicEditorService logic_editor_service(project_service);
  781. VirtualRegisterRepository repository;
  782. HmiRuntimeService runtime_service(repository);
  783. AlarmEditorService alarm_editor_service(project_service);
  784. AlarmService alarm_service(project_service, repository);
  785. RegisterCommentService register_comment_service(project_service);
  786. OfflineSimulationService simulation_service(repository);
  787. RuntimeModeService mode_service(project_service, simulation_service);
  788. RegisterMonitorService monitor_service(repository);
  789. MainWindow window(
  790. mode_service,
  791. project_service,
  792. editor_service,
  793. logic_editor_service,
  794. runtime_service,
  795. alarm_editor_service,
  796. alarm_service,
  797. register_comment_service,
  798. monitor_service);
  799. require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
  800. "serial configuration controls must not be embedded in the main window");
  801. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  802. bool dialog_opened = false;
  803. QTimer::singleShot(
  804. 0,
  805. [&dialog_opened]
  806. {
  807. auto *dialog = qobject_cast<PlcConnectionDialog *>(
  808. QApplication::activeModalWidget());
  809. dialog_opened = dialog != nullptr;
  810. if (dialog != nullptr)
  811. {
  812. dialog->reject();
  813. }
  814. });
  815. configure_action->trigger();
  816. require(dialog_opened,
  817. "PLC configuration action must open the dedicated configuration dialog");
  818. }
  819. void testRepeatedPlcStatusNotificationsAreCoalesced()
  820. {
  821. TestProjectStorage storage;
  822. ProjectService project_service(storage);
  823. HmiEditorService editor_service(project_service);
  824. LogicEditorService logic_editor_service(project_service);
  825. VirtualRegisterRepository virtual_repository;
  826. VirtualRegisterRepository plc_repository;
  827. ActiveRegisterRepository active_repository(virtual_repository);
  828. HmiRuntimeService runtime_service(active_repository);
  829. AlarmEditorService alarm_editor_service(project_service);
  830. AlarmService alarm_service(project_service, active_repository);
  831. RegisterCommentService register_comment_service(project_service);
  832. OfflineSimulationService simulation_service(virtual_repository);
  833. RepeatedStatusGateway gateway;
  834. RuntimeModeService mode_service(project_service, simulation_service);
  835. RegisterMonitorService monitor_service(active_repository);
  836. mode_service.configurePlc(
  837. gateway, active_repository, virtual_repository, plc_repository);
  838. MainWindow window(
  839. mode_service,
  840. project_service,
  841. editor_service,
  842. logic_editor_service,
  843. runtime_service,
  844. alarm_editor_service,
  845. alarm_service,
  846. register_comment_service,
  847. monitor_service);
  848. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  849. const int initial_count = output->count();
  850. const PlcCommunicationResult result = mode_service.connectPlc(
  851. {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
  852. require(result.succeeded, "PLC connection setup must succeed");
  853. QApplication::processEvents();
  854. const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
  855. int matching_count = 0;
  856. for (int index = initial_count; index < output->count(); ++index)
  857. {
  858. if (output->item(index)->text() == expected)
  859. {
  860. ++matching_count;
  861. }
  862. }
  863. require(matching_count == 1,
  864. "repeated PLC status callbacks must append one coalesced output message");
  865. }
  866. void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly()
  867. {
  868. TestProjectStorage storage;
  869. ProjectService project_service(storage);
  870. HmiEditorService editor_service(project_service);
  871. LogicEditorService logic_editor_service(project_service);
  872. VirtualRegisterRepository virtual_repository;
  873. VirtualRegisterRepository plc_repository;
  874. ActiveRegisterRepository active_repository(virtual_repository);
  875. HmiRuntimeService runtime_service(active_repository);
  876. AlarmEditorService alarm_editor_service(project_service);
  877. AlarmService alarm_service(project_service, active_repository);
  878. RegisterCommentService register_comment_service(project_service);
  879. OfflineSimulationService simulation_service(virtual_repository);
  880. OnlineReadyGateway gateway;
  881. RuntimeModeService mode_service(project_service, simulation_service);
  882. RegisterMonitorService monitor_service(active_repository);
  883. mode_service.configurePlc(
  884. gateway, active_repository, virtual_repository, plc_repository);
  885. MainWindow window(
  886. mode_service,
  887. project_service,
  888. editor_service,
  889. logic_editor_service,
  890. runtime_service,
  891. alarm_editor_service,
  892. alarm_service,
  893. register_comment_service,
  894. monitor_service);
  895. window.resize(1200, 760);
  896. window.show();
  897. QApplication::processEvents();
  898. require(mode_service.connectPlc(
  899. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  900. "online workspace test must connect the fake PLC gateway");
  901. gateway.completeInitialRead();
  902. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  903. online_action->trigger();
  904. QApplication::processEvents();
  905. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  906. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  907. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  908. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  909. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  910. "completed PLC initial read must allow online running");
  911. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  912. && free_monitor->isVisible(),
  913. "online running must show HMI and free monitor together");
  914. require(!runtime_logic->isVisible(),
  915. "online running must not show a misleading local ladder runtime trace");
  916. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  917. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  918. monitor_address->setText(QStringLiteral("D9"));
  919. monitor_add->click();
  920. require(std::find(
  921. gateway.poll_addresses.begin(),
  922. gateway.poll_addresses.end(),
  923. RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(),
  924. "online free monitor addresses must join the active PLC poll set immediately");
  925. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  926. QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction");
  927. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  928. gateway.timeoutCommunication(
  929. "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线");
  930. QApplication::processEvents();
  931. require(mode_service.mode() == ApplicationMode::Editing,
  932. "a PLC timeout must return online running to editing");
  933. require(disconnect_action->isEnabled(),
  934. "a timeout must keep disconnect available while the local port remains open");
  935. require(configure_action->isEnabled()
  936. && configure_action->text() == QStringLiteral("PLC 重新配置"),
  937. "a timeout must keep PLC reconfiguration available");
  938. gateway.recoverCommunication();
  939. QApplication::processEvents();
  940. require(mode_service.mode() == ApplicationMode::Editing,
  941. "automatic communication recovery must remain in editing mode");
  942. require(output->count() > 0
  943. && output->item(output->count() - 1)->text().contains(
  944. QStringLiteral("通信已恢复")),
  945. "automatic recovery must be retained in the output log");
  946. online_action->trigger();
  947. require(mode_service.mode() == ApplicationMode::Editing,
  948. "recovered communication must complete a fresh initial read before online mode");
  949. gateway.completeInitialRead();
  950. QApplication::processEvents();
  951. online_action->trigger();
  952. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  953. "users must be able to re-enter online mode after the recovered initial read");
  954. gateway.loseSerialConnection(
  955. "PLC 串口 COM9 连接已中断;请检查 USB 转串口是否被拔出或已经失效");
  956. QApplication::processEvents();
  957. require(mode_service.mode() == ApplicationMode::Editing,
  958. "a communication fault must return the UI from online running to editing");
  959. require(configure_action->isEnabled()
  960. && configure_action->text() == QStringLiteral("PLC 配置"),
  961. "a lost serial connection must expose direct configuration");
  962. require(!disconnect_action->isEnabled(),
  963. "a lost serial connection must disable redundant disconnect actions");
  964. require(output->count() > 0
  965. && output->item(output->count() - 1)->text().contains(
  966. QStringLiteral("连接已中断")),
  967. "communication fault details must remain in the output log");
  968. online_action->trigger();
  969. require(mode_service.mode() == ApplicationMode::Editing,
  970. "faulted PLC state must not re-enter online running without a fresh read");
  971. }
  972. void testMultiPageAndLogicMainWindowIntegration()
  973. {
  974. TestProjectStorage storage;
  975. ProjectService project_service(storage);
  976. HmiEditorService editor_service(project_service);
  977. LogicEditorService logic_editor_service(project_service);
  978. const std::string main_page_id = editor_service.ensureDefaultPage().id;
  979. const std::string settings_page_id = editor_service.addPage("Settings").id;
  980. const std::string first_logic_id = logic_editor_service.ensureDefaultLogic().id;
  981. const std::string second_logic_id =
  982. logic_editor_service.addLogic("Safety logic").id;
  983. const HmiEditorResult jump_result = editor_service.addControl(
  984. main_page_id, HmiControlType::PageJump);
  985. HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id);
  986. jump.pageJump = HmiPageJumpConfig{settings_page_id};
  987. require(editor_service.updateControl(
  988. main_page_id, jump_result.id, jump).succeeded,
  989. "the integration fixture must configure PageJump");
  990. VirtualRegisterRepository repository;
  991. HmiRuntimeService runtime_service(repository);
  992. AlarmEditorService alarm_editor_service(project_service);
  993. AlarmService alarm_service(project_service, repository);
  994. RegisterCommentService register_comment_service(project_service);
  995. OfflineSimulationService simulation_service(repository);
  996. RuntimeModeService mode_service(project_service, simulation_service);
  997. RegisterMonitorService monitor_service(repository);
  998. HmiNavigationService navigation_service(project_service);
  999. MainWindow window(
  1000. mode_service,
  1001. project_service,
  1002. editor_service,
  1003. logic_editor_service,
  1004. runtime_service,
  1005. alarm_editor_service,
  1006. alarm_service,
  1007. navigation_service,
  1008. register_comment_service,
  1009. monitor_service);
  1010. window.resize(1400, 820);
  1011. window.show();
  1012. QApplication::processEvents();
  1013. QTreeWidget *tree = requiredChild<QTreeWidget>(window, "projectTree");
  1014. require(tree->topLevelItemCount() == 2
  1015. && tree->topLevelItem(0)->childCount() == 2
  1016. && tree->topLevelItem(1)->childCount() == 2,
  1017. "the project tree must list every page and logic module");
  1018. tree->setCurrentItem(tree->topLevelItem(0)->child(1));
  1019. QApplication::processEvents();
  1020. require(window.currentHmiPageId() == settings_page_id,
  1021. "selecting a page tree node must change the current HMI page id");
  1022. QAction *add_label = requiredChild<QAction>(window, "addLabelAction");
  1023. add_label->trigger();
  1024. require(editor_service.findPage(settings_page_id)->controls.size() == 1U
  1025. && editor_service.findPage(main_page_id)->controls.size() == 1U,
  1026. "HMI toolbar actions must edit the selected page rather than the first page");
  1027. QComboBox *binding_area = requiredChild<QComboBox>(window, "bindingAreaComboBox");
  1028. QComboBox *target_page = requiredChild<QComboBox>(window, "targetPageComboBox");
  1029. require(!binding_area->isVisible() && !target_page->isVisible(),
  1030. "Label properties must hide both register binding and PageJump target fields");
  1031. tree->setCurrentItem(tree->topLevelItem(1)->child(1));
  1032. QApplication::processEvents();
  1033. require(window.currentLogicId() == second_logic_id,
  1034. "selecting a logic tree node must change the current logic id");
  1035. requiredChild<QAction>(window, "addRungAction")->trigger();
  1036. require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 2U
  1037. && logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U,
  1038. "logic toolbar actions must edit the selected logic module");
  1039. tree->setCurrentItem(tree->topLevelItem(0)->child(0));
  1040. QApplication::processEvents();
  1041. HmiEditorWidget *editor = requiredChild<HmiEditorWidget>(window, "hmiEditorWidget");
  1042. editor->selectControl(jump_result.id);
  1043. QApplication::processEvents();
  1044. require(target_page->isVisible() && !binding_area->isVisible()
  1045. && target_page->currentData().toString()
  1046. == QString::fromStdString(settings_page_id),
  1047. "PageJump properties must show a target-page selector without register fields");
  1048. requiredChild<QAction>(window, "offlineModeAction")->trigger();
  1049. QApplication::processEvents();
  1050. require(mode_service.mode() == ApplicationMode::OfflineRunning
  1051. && navigation_service.currentPageId() == main_page_id,
  1052. "runtime HMI navigation must start on the persisted initial page");
  1053. HmiEditorWidget *runtime_hmi = requiredChild<HmiEditorWidget>(
  1054. window, "runtimeHmiView");
  1055. QGraphicsItem *jump_item = nullptr;
  1056. for (QGraphicsItem *item : runtime_hmi->scene()->items())
  1057. {
  1058. if (item->zValue() >= 0.0)
  1059. {
  1060. jump_item = item;
  1061. break;
  1062. }
  1063. }
  1064. require(jump_item != nullptr, "runtime HMI must render the PageJump control");
  1065. const QPoint jump_center = runtime_hmi->mapFromScene(
  1066. jump_item->mapToScene(jump_item->boundingRect().center()));
  1067. QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton,
  1068. Qt::NoModifier, jump_center);
  1069. QApplication::processEvents();
  1070. require(navigation_service.currentPageId() == settings_page_id
  1071. && requiredChild<QLabel>(window, "runtimePageLabel")->text()
  1072. == QStringLiteral("Settings"),
  1073. "clicking PageJump at runtime must navigate locally to its target page");
  1074. QComboBox *logic_selector = requiredChild<QComboBox>(
  1075. window, "runtimeLogicComboBox");
  1076. require(logic_selector->count() == 2,
  1077. "runtime logic selector must list all enabled logic modules");
  1078. logic_selector->setCurrentIndex(
  1079. logic_selector->findData(QString::fromStdString(second_logic_id)));
  1080. QApplication::processEvents();
  1081. auto *runtime_monitor = requiredChild<RuntimeMonitorWidget>(
  1082. window, "runtimeMonitorWidget");
  1083. require(runtime_monitor->selectedLogicId() == second_logic_id,
  1084. "runtime logic selection must change only the visible trace projection");
  1085. requiredChild<QAction>(window, "editingModeAction")->trigger();
  1086. }
  1087. } // namespace
  1088. int main(int argc, char *argv[])
  1089. {
  1090. // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
  1091. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  1092. QApplication application(argc, argv);
  1093. try
  1094. {
  1095. testRuntimeButtonMouseInteraction();
  1096. testRuntimePageJumpDoesNotRequireRegisterWritePermission();
  1097. testRuntimeAlarmListInteraction();
  1098. testModeActionsControlEditingAvailability();
  1099. testEdgeTimerPropertyEditingAndParallelMenu();
  1100. testPlcConfigurationUsesDialog();
  1101. testRepeatedPlcStatusNotificationsAreCoalesced();
  1102. testOnlineWorkspaceShowsHmiAndFreeMonitorOnly();
  1103. testMultiPageAndLogicMainWindowIntegration();
  1104. }
  1105. catch (const std::exception &error)
  1106. {
  1107. std::cerr << "main window tests failed: " << error.what() << '\n';
  1108. return 1;
  1109. }
  1110. std::cout << "main window tests passed\n";
  1111. return 0;
  1112. }