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

1670 righe
74 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 <QCheckBox>
  24. #include <QComboBox>
  25. #include <QDialog>
  26. #include <QDockWidget>
  27. #include <QGraphicsItem>
  28. #include <QGraphicsScene>
  29. #include <QImage>
  30. #include <QLabel>
  31. #include <QLineEdit>
  32. #include <QListWidget>
  33. #include <QMenu>
  34. #include <QPainter>
  35. #include <QSpinBox>
  36. #include <QPushButton>
  37. #include <QStringList>
  38. #include <QTableWidget>
  39. #include <QTabWidget>
  40. #include <QTimer>
  41. #include <QTest>
  42. #include <QToolButton>
  43. #include <QTreeWidget>
  44. #include <iostream>
  45. #include <stdexcept>
  46. #include <string>
  47. namespace {
  48. class TestProjectStorage final : public ProjectStorage
  49. {
  50. public:
  51. // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
  52. ProjectSaveResult save(const Project &, const std::string &) override
  53. {
  54. return {true, ProjectStorageError::None, {}};
  55. }
  56. ProjectLoadResult load(const std::string &) override
  57. {
  58. return {false, {}, ProjectStorageError::FileReadFailed, {}};
  59. }
  60. };
  61. class RepeatedStatusGateway final : public PlcCommunicationGateway
  62. {
  63. public:
  64. PlcCommunicationResult connectDevice(
  65. const PlcSerialConfiguration &) override
  66. {
  67. connection_state = PlcConnectionState::Connecting;
  68. notifyStateChanged();
  69. connection_state = PlcConnectionState::Connected;
  70. notifyStateChanged();
  71. notifyStateChanged();
  72. return {true, {}};
  73. }
  74. void disconnectDevice() override
  75. {
  76. connection_state = PlcConnectionState::Disconnected;
  77. notifyStateChanged();
  78. }
  79. PlcCommunicationResult setPollAddresses(
  80. const std::vector<RegisterAddress> &) override
  81. {
  82. return {true, {}};
  83. }
  84. PlcConnectionState state() const override { return connection_state; }
  85. bool initialReadCompleted() const override { return false; }
  86. PlcCommunicationError lastErrorType() const override
  87. {
  88. return PlcCommunicationError::None;
  89. }
  90. const std::string &lastError() const override { return last_error; }
  91. void setCallbacks(
  92. std::function<void()> state_callback,
  93. std::function<void(bool)> initial_callback,
  94. std::function<void()> cache_callback,
  95. std::function<void(const std::string &)> error_callback) override
  96. {
  97. state_changed = std::move(state_callback);
  98. initial_read_changed = std::move(initial_callback);
  99. cache_updated = std::move(cache_callback);
  100. error_reported = std::move(error_callback);
  101. }
  102. private:
  103. void notifyStateChanged()
  104. {
  105. if (state_changed)
  106. {
  107. state_changed();
  108. }
  109. }
  110. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  111. std::string last_error;
  112. std::function<void()> state_changed;
  113. std::function<void(bool)> initial_read_changed;
  114. std::function<void()> cache_updated;
  115. std::function<void(const std::string &)> error_reported;
  116. };
  117. class OnlineReadyGateway final : public PlcCommunicationGateway
  118. {
  119. public:
  120. PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override
  121. {
  122. connection_state = PlcConnectionState::Connected;
  123. if (state_changed)
  124. {
  125. state_changed();
  126. }
  127. return {true, {}};
  128. }
  129. void disconnectDevice() override
  130. {
  131. connection_state = PlcConnectionState::Disconnected;
  132. initial_read = false;
  133. if (initial_read_changed)
  134. {
  135. initial_read_changed(false);
  136. }
  137. if (state_changed)
  138. {
  139. state_changed();
  140. }
  141. }
  142. PlcCommunicationResult setPollAddresses(
  143. const std::vector<RegisterAddress> &addresses) override
  144. {
  145. poll_addresses = addresses;
  146. return {true, {}};
  147. }
  148. PlcConnectionState state() const override { return connection_state; }
  149. bool initialReadCompleted() const override { return initial_read; }
  150. PlcCommunicationError lastErrorType() const override { return last_error_type; }
  151. const std::string &lastError() const override { return last_error; }
  152. void setCallbacks(
  153. std::function<void()> state_callback,
  154. std::function<void(bool)> initial_callback,
  155. std::function<void()> cache_callback,
  156. std::function<void(const std::string &)> error_callback) override
  157. {
  158. state_changed = std::move(state_callback);
  159. initial_read_changed = std::move(initial_callback);
  160. cache_updated = std::move(cache_callback);
  161. error_reported = std::move(error_callback);
  162. }
  163. void completeInitialRead()
  164. {
  165. initial_read = true;
  166. last_error_type = PlcCommunicationError::None;
  167. last_error.clear();
  168. if (connection_state == PlcConnectionState::Recovering)
  169. {
  170. connection_state = PlcConnectionState::Connected;
  171. if (state_changed)
  172. {
  173. state_changed();
  174. }
  175. }
  176. if (initial_read_changed)
  177. {
  178. initial_read_changed(true);
  179. }
  180. }
  181. void timeoutCommunication(const std::string &message)
  182. {
  183. initial_read = false;
  184. last_error_type = PlcCommunicationError::CommunicationTimeout;
  185. last_error = message;
  186. connection_state = PlcConnectionState::Faulted;
  187. if (initial_read_changed)
  188. {
  189. initial_read_changed(false);
  190. }
  191. if (state_changed)
  192. {
  193. state_changed();
  194. }
  195. if (error_reported)
  196. {
  197. error_reported(last_error);
  198. }
  199. }
  200. void recoverCommunication()
  201. {
  202. initial_read = false;
  203. last_error_type = PlcCommunicationError::None;
  204. last_error.clear();
  205. connection_state = PlcConnectionState::Recovering;
  206. if (state_changed)
  207. {
  208. state_changed();
  209. }
  210. }
  211. void loseSerialConnection(const std::string &message)
  212. {
  213. initial_read = false;
  214. last_error_type = PlcCommunicationError::SerialConnectionLost;
  215. last_error = message;
  216. connection_state = PlcConnectionState::Disconnected;
  217. if (initial_read_changed)
  218. {
  219. initial_read_changed(false);
  220. }
  221. if (state_changed)
  222. {
  223. state_changed();
  224. }
  225. if (error_reported)
  226. {
  227. error_reported(last_error);
  228. }
  229. }
  230. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  231. bool initial_read = false;
  232. PlcCommunicationError last_error_type = PlcCommunicationError::None;
  233. std::string last_error;
  234. std::vector<RegisterAddress> poll_addresses;
  235. std::function<void()> state_changed;
  236. std::function<void(bool)> initial_read_changed;
  237. std::function<void()> cache_updated;
  238. std::function<void(const std::string &)> error_reported;
  239. };
  240. void require(bool condition, const std::string &message)
  241. {
  242. if (!condition)
  243. {
  244. throw std::runtime_error(message);
  245. }
  246. }
  247. template<typename ObjectType>
  248. ObjectType *requiredChild(MainWindow &window, const char *name)
  249. {
  250. // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
  251. ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
  252. require(child != nullptr, std::string("missing UI object ") + name);
  253. return child;
  254. }
  255. QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position)
  256. {
  257. QImage image(view.viewport()->size(), QImage::Format_ARGB32_Premultiplied);
  258. image.fill(Qt::transparent);
  259. QPainter painter(&image);
  260. view.viewport()->render(&painter);
  261. return image.pixelColor(viewport_position);
  262. }
  263. void testRuntimeButtonMouseInteraction()
  264. {
  265. TestProjectStorage storage;
  266. ProjectService project_service(storage);
  267. HmiEditorService editor_service(project_service);
  268. VirtualRegisterRepository repository;
  269. HmiRuntimeService runtime_service(repository);
  270. AlarmService alarm_service(project_service, repository);
  271. const HmiEditorResult page_result = editor_service.ensureDefaultPage();
  272. require(page_result.succeeded, "runtime button test must create an HMI page");
  273. const HmiEditorResult button_result = editor_service.addControl(
  274. page_result.id, HmiControlType::Button);
  275. require(button_result.succeeded, "runtime button test must create a button");
  276. HmiControl button = *editor_service.findControl(page_result.id, button_result.id);
  277. button.binding = RegisterAddress{RegisterArea::M, 0};
  278. button.buttonOperation = HmiButtonOperation::MomentaryOn;
  279. require(editor_service.updateControl(page_result.id, button.id, button).succeeded,
  280. "runtime button test must bind the button to M0");
  281. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  282. view.resize(900, 600);
  283. view.setPageId(page_result.id);
  284. view.setEditingEnabled(false);
  285. view.setRuntimeActive(true);
  286. view.setRuntimeWriteEnabled(true);
  287. view.show();
  288. QApplication::processEvents();
  289. const QPoint center = view.mapFromScene(QPointF(
  290. button.bounds.x + button.bounds.width / 2.0,
  291. button.bounds.y + button.bounds.height / 2.0));
  292. const QPoint color_sample = view.mapFromScene(QPointF(
  293. button.bounds.x + 8.0, button.bounds.y + 8.0));
  294. const QColor normal_color = renderedColorAt(view, color_sample);
  295. QTest::mouseMove(view.viewport(), center);
  296. QApplication::processEvents();
  297. QGraphicsItem *button_item = view.itemAt(center);
  298. require(button_item != nullptr,
  299. "runtime button must be hit-testable while writes are enabled");
  300. require(button_item->cursor().shape() == Qt::PointingHandCursor,
  301. "runtime button must use a pointing-hand cursor");
  302. const QColor hovered_color = renderedColorAt(view, color_sample);
  303. require(hovered_color != normal_color,
  304. "hovering a runtime button must change its visual state");
  305. QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  306. QApplication::processEvents();
  307. require(repository.readBit({RegisterArea::M, 0}).value,
  308. "pressing a momentary runtime button must write M0 ON");
  309. require(view.scene()->selectedItems().isEmpty(),
  310. "pressing a runtime button must not show an editing selection");
  311. const QColor pressed_color = renderedColorAt(view, color_sample);
  312. require(pressed_color != hovered_color,
  313. "pressing a runtime button must change its visual state");
  314. QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  315. QApplication::processEvents();
  316. require(!repository.readBit({RegisterArea::M, 0}).value,
  317. "releasing a momentary runtime button must write M0 OFF");
  318. require(renderedColorAt(view, color_sample) == hovered_color,
  319. "releasing a runtime button must restore its hovered visual state");
  320. view.setRuntimeWriteEnabled(false);
  321. QApplication::processEvents();
  322. const QColor disabled_color = renderedColorAt(view, color_sample);
  323. require(disabled_color != hovered_color,
  324. "a non-writable runtime button must use its disabled visual state");
  325. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  326. require(!repository.readBit({RegisterArea::M, 0}).value,
  327. "a disabled runtime button must not write its register");
  328. }
  329. void testRuntimeProgressBarRendering()
  330. {
  331. TestProjectStorage storage;
  332. ProjectService project_service(storage);
  333. HmiEditorService editor_service(project_service);
  334. VirtualRegisterRepository repository;
  335. HmiRuntimeService runtime_service(repository);
  336. AlarmService alarm_service(project_service, repository);
  337. const std::string page_id = editor_service.ensureDefaultPage().id;
  338. const HmiEditorResult progress_result = editor_service.addControl(
  339. page_id, HmiControlType::ProgressBar);
  340. require(progress_result.succeeded,
  341. "runtime progress test must create a ProgressBar control");
  342. HmiControl progress = *editor_service.findControl(
  343. page_id, progress_result.id);
  344. progress.binding = RegisterAddress{RegisterArea::D, 0};
  345. progress.progressBar = HmiProgressBarConfig{0, 100, true};
  346. require(editor_service.updateControl(
  347. page_id, progress.id, progress).succeeded,
  348. "runtime progress test must configure a D value range");
  349. require(repository.writeWord(
  350. {RegisterArea::D, 0}, static_cast<std::int16_t>(25)).succeeded,
  351. "runtime progress test must seed D0");
  352. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  353. view.resize(900, 560);
  354. view.setPageId(page_id);
  355. view.setEditingEnabled(false);
  356. view.setRuntimeActive(true);
  357. view.refreshRuntimeValues();
  358. view.show();
  359. QApplication::processEvents();
  360. const QPoint sample = view.mapFromScene(QPointF(
  361. progress.bounds.x + progress.bounds.width * 0.6,
  362. progress.bounds.y + 6.0));
  363. const QColor background_color = renderedColorAt(view, sample);
  364. QGraphicsItem *progress_item = view.itemAt(sample);
  365. require(progress_item != nullptr
  366. && progress_item->acceptedMouseButtons() == Qt::NoButton,
  367. "runtime ProgressBar must be visible and read-only");
  368. require(repository.writeWord(
  369. {RegisterArea::D, 0}, static_cast<std::int16_t>(75)).succeeded,
  370. "runtime progress test must update D0");
  371. view.refreshRuntimeValues();
  372. QApplication::processEvents();
  373. const QColor fill_color = renderedColorAt(view, sample);
  374. require(fill_color != background_color,
  375. "ProgressBar fill must expand when the D value increases");
  376. require(repository.writeWord(
  377. {RegisterArea::D, 0}, static_cast<std::int16_t>(200)).succeeded,
  378. "runtime progress test must accept an out-of-range source value");
  379. view.refreshRuntimeValues();
  380. QApplication::processEvents();
  381. const QPoint near_end = view.mapFromScene(QPointF(
  382. progress.bounds.x + progress.bounds.width * 0.95,
  383. progress.bounds.y + 6.0));
  384. require(renderedColorAt(view, near_end) == fill_color,
  385. "ProgressBar rendering must clamp values above its maximum");
  386. }
  387. void testRuntimePageJumpDoesNotRequireRegisterWritePermission()
  388. {
  389. TestProjectStorage storage;
  390. ProjectService project_service(storage);
  391. HmiEditorService editor_service(project_service);
  392. const std::string source_page = editor_service.ensureDefaultPage().id;
  393. const std::string target_page = editor_service.addPage("Target").id;
  394. const HmiEditorResult jump_result = editor_service.addControl(
  395. source_page, HmiControlType::PageJump);
  396. HmiControl jump = *editor_service.findControl(source_page, jump_result.id);
  397. jump.pageJump = HmiPageJumpConfig{target_page};
  398. require(editor_service.updateControl(
  399. source_page, jump_result.id, jump).succeeded,
  400. "PageJump fixture setup must succeed");
  401. VirtualRegisterRepository repository;
  402. HmiRuntimeService runtime_service(repository);
  403. AlarmService alarm_service(project_service, repository);
  404. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  405. view.setPageId(source_page);
  406. view.setEditingEnabled(false);
  407. view.setRuntimeActive(true);
  408. view.setRuntimeWriteEnabled(false);
  409. view.refreshRuntimeValues();
  410. view.resize(900, 560);
  411. view.show();
  412. QApplication::processEvents();
  413. QString requested_page;
  414. QObject::connect(
  415. &view, &HmiEditorWidget::pageNavigationRequested,
  416. [&requested_page](const QString &page_id)
  417. {
  418. requested_page = page_id;
  419. });
  420. QGraphicsItem *jump_item = nullptr;
  421. for (QGraphicsItem *item : view.scene()->items())
  422. {
  423. if (item->zValue() >= 0.0)
  424. {
  425. jump_item = item;
  426. break;
  427. }
  428. }
  429. require(jump_item != nullptr, "PageJump graphics item must be rendered");
  430. const QPoint center = view.mapFromScene(
  431. jump_item->mapToScene(jump_item->boundingRect().center()));
  432. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  433. require(requested_page == QString::fromStdString(target_page),
  434. "PageJump must remain active when register writes are disabled");
  435. }
  436. void testRuntimeAlarmListInteraction()
  437. {
  438. TestProjectStorage storage;
  439. ProjectService project_service(storage);
  440. HmiEditorService editor_service(project_service);
  441. AlarmEditorService alarm_editor_service(project_service);
  442. VirtualRegisterRepository repository;
  443. HmiRuntimeService runtime_service(repository);
  444. AlarmService alarm_service(project_service, repository);
  445. const std::string page_id = editor_service.ensureDefaultPage().id;
  446. const HmiEditorResult list_result = editor_service.addControl(
  447. page_id, HmiControlType::AlarmList);
  448. require(list_result.succeeded,
  449. "runtime alarm test must create an AlarmList control");
  450. const HmiControl *alarm_list = editor_service.findControl(
  451. page_id, list_result.id);
  452. require(alarm_list != nullptr && !alarm_list->binding.has_value(),
  453. "AlarmList must not require a register binding");
  454. AlarmDefinition definition;
  455. definition.address = RegisterAddress{RegisterArea::M, 0};
  456. definition.condition = AlarmCondition::MOn;
  457. definition.message = "Emergency stop";
  458. const AlarmEditorResult alarm_result =
  459. alarm_editor_service.addDefinition(definition);
  460. require(alarm_result.succeeded,
  461. "runtime alarm test must create an M alarm definition");
  462. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  463. view.setPageId(page_id);
  464. QGraphicsItem *alarm_item = nullptr;
  465. for (QGraphicsItem *item : view.scene()->items())
  466. {
  467. if (item->zValue() >= 0.0)
  468. {
  469. alarm_item = item;
  470. break;
  471. }
  472. }
  473. require(alarm_item != nullptr && alarm_item->isVisible(),
  474. "AlarmList must remain visible while editing");
  475. view.setEditingEnabled(false);
  476. view.setRuntimeActive(true);
  477. view.setRuntimeWriteEnabled(false);
  478. view.refreshRuntimeValues();
  479. view.resize(900, 560);
  480. view.show();
  481. QApplication::processEvents();
  482. require(!alarm_item->isVisible(),
  483. "AlarmList must be hidden when runtime has no active alarm");
  484. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
  485. alarm_service.refresh();
  486. view.refreshRuntimeValues();
  487. QApplication::processEvents();
  488. require(alarm_service.records().size() == 1,
  489. "an active M alarm must be available to AlarmList");
  490. require(alarm_item->isVisible() && alarm_item->zValue() > 0.0,
  491. "AlarmList must appear above the page for an active alarm");
  492. const QPoint header_position = view.mapFromScene(QPointF(
  493. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0));
  494. QTest::mouseClick(
  495. view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position);
  496. require(!alarm_service.records().front().acknowledged,
  497. "clicking the AlarmList header must not acknowledge an alarm");
  498. const QPoint first_row_position = view.mapFromScene(QPointF(
  499. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0));
  500. QTest::mouseClick(
  501. view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position);
  502. require(alarm_service.records().front().acknowledged,
  503. "clicking an active AlarmList row must acknowledge the alarm");
  504. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false);
  505. alarm_service.refresh();
  506. view.refreshRuntimeValues();
  507. QApplication::processEvents();
  508. require(alarm_service.records().empty(),
  509. "clearing the M condition must remove the alarm immediately");
  510. require(!alarm_item->isVisible(),
  511. "AlarmList must hide after the final alarm recovers");
  512. view.setRuntimeActive(false);
  513. view.setEditingEnabled(true);
  514. require(alarm_item->isVisible(),
  515. "AlarmList must become visible again after returning to editing");
  516. }
  517. void testWindowTitleTracksUnsavedProjectChanges()
  518. {
  519. TestProjectStorage storage;
  520. ProjectService project_service(storage);
  521. HmiEditorService editor_service(project_service);
  522. LogicEditorService logic_editor_service(project_service);
  523. require(editor_service.ensureDefaultPage().succeeded,
  524. "window-title test must create a default HMI page");
  525. require(logic_editor_service.ensureDefaultLogic().succeeded,
  526. "window-title test must create a default control logic");
  527. require(project_service.saveAs("window-title-test.json").succeeded,
  528. "window-title fixture must start from a saved project");
  529. VirtualRegisterRepository repository;
  530. HmiRuntimeService runtime_service(repository);
  531. AlarmEditorService alarm_editor_service(project_service);
  532. AlarmService alarm_service(project_service, repository);
  533. RegisterCommentService register_comment_service(project_service);
  534. OfflineSimulationService simulation_service(repository);
  535. RuntimeModeService mode_service(project_service, simulation_service);
  536. RegisterMonitorService monitor_service(repository);
  537. MainWindow window(
  538. mode_service,
  539. project_service,
  540. editor_service,
  541. logic_editor_service,
  542. runtime_service,
  543. alarm_editor_service,
  544. alarm_service,
  545. register_comment_service,
  546. monitor_service);
  547. require(!window.windowTitle().endsWith(QLatin1Char('*')),
  548. "a saved project title must not show an unsaved marker");
  549. requiredChild<QAction>(window, "addLabelAction")->trigger();
  550. require(project_service.isModified()
  551. && window.windowTitle().endsWith(QLatin1Char('*')),
  552. "editing the project must immediately add an unsaved marker");
  553. requiredChild<QAction>(window, "saveProjectAction")->trigger();
  554. require(!project_service.isModified()
  555. && !window.windowTitle().endsWith(QLatin1Char('*')),
  556. "saving the project must immediately remove the unsaved marker");
  557. }
  558. void testModeActionsControlEditingAvailability()
  559. {
  560. // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
  561. TestProjectStorage storage;
  562. ProjectService project_service(storage);
  563. HmiEditorService editor_service(project_service);
  564. LogicEditorService logic_editor_service(project_service);
  565. VirtualRegisterRepository repository;
  566. HmiRuntimeService runtime_service(repository);
  567. AlarmEditorService alarm_editor_service(project_service);
  568. AlarmService alarm_service(project_service, repository);
  569. RegisterCommentService register_comment_service(project_service);
  570. OfflineSimulationService simulation_service(repository);
  571. RuntimeModeService mode_service(project_service, simulation_service);
  572. RegisterMonitorService monitor_service(repository);
  573. MainWindow window(
  574. mode_service,
  575. project_service,
  576. editor_service,
  577. logic_editor_service,
  578. runtime_service,
  579. alarm_editor_service,
  580. alarm_service,
  581. register_comment_service,
  582. monitor_service);
  583. window.resize(1000, 640);
  584. window.show();
  585. QApplication::processEvents();
  586. require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
  587. "the removed data point dock must not remain in the main window");
  588. require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
  589. && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
  590. "HMI and ladder properties must use direct M/D address inputs");
  591. QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
  592. QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
  593. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  594. QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
  595. QAction *add_indicator_action = requiredChild<QAction>(window, "addIndicatorAction");
  596. QAction *add_progress_bar_action = requiredChild<QAction>(
  597. window, "addProgressBarAction");
  598. QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
  599. QAction *undo_action = requiredChild<QAction>(window, "undoAction");
  600. QAction *redo_action = requiredChild<QAction>(window, "redoAction");
  601. QAction *delete_selection_action = requiredChild<QAction>(
  602. window, "deleteSelectionAction");
  603. QAction *toggle_logic_enabled_action = requiredChild<QAction>(
  604. window, "toggleLogicEnabledAction");
  605. QToolButton *hmi_more_controls = requiredChild<QToolButton>(
  606. window, "hmiMoreControlsButton");
  607. QToolButton *logic_contact_menu = requiredChild<QToolButton>(
  608. window, "logicContactMenuButton");
  609. QToolButton *logic_output_menu = requiredChild<QToolButton>(
  610. window, "logicOutputMenuButton");
  611. QToolButton *logic_timer_counter_menu = requiredChild<QToolButton>(
  612. window, "logicTimerCounterMenuButton");
  613. QToolButton *logic_data_menu = requiredChild<QToolButton>(
  614. window, "logicDataMenuButton");
  615. QAction *add_normally_open_action = requiredChild<QAction>(
  616. window, "addNormallyOpenAction");
  617. QAction *add_normal_coil_action = requiredChild<QAction>(
  618. window, "addNormalCoilAction");
  619. QAction *parallel_insert_action = requiredChild<QAction>(
  620. window, "parallelInsertAction");
  621. QAction *insert_horizontal_wire_action = requiredChild<QAction>(
  622. window, "insertHorizontalWireAction");
  623. QAction *insert_vertical_wire_action = requiredChild<QAction>(
  624. window, "insertVerticalWireAction");
  625. QAction *delete_horizontal_wire_action = requiredChild<QAction>(
  626. window, "deleteHorizontalWireAction");
  627. QAction *delete_vertical_wire_action = requiredChild<QAction>(
  628. window, "deleteVerticalWireAction");
  629. QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
  630. QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
  631. QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
  632. QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
  633. QComboBox *button_operation = requiredChild<QComboBox>(
  634. window, "buttonOperationComboBox");
  635. QComboBox *binding_area = requiredChild<QComboBox>(
  636. window, "bindingAreaComboBox");
  637. QSpinBox *binding_index = requiredChild<QSpinBox>(
  638. window, "bindingIndexSpinBox");
  639. QSpinBox *progress_minimum = requiredChild<QSpinBox>(
  640. window, "progressMinimumSpinBox");
  641. QSpinBox *progress_maximum = requiredChild<QSpinBox>(
  642. window, "progressMaximumSpinBox");
  643. QCheckBox *progress_show_value = requiredChild<QCheckBox>(
  644. window, "progressShowValueCheckBox");
  645. QPushButton *apply_properties = requiredChild<QPushButton>(
  646. window, "applyPropertiesButton");
  647. HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
  648. window, "hmiEditorWidget");
  649. LogicEditorWidget *logic_editor = requiredChild<LogicEditorWidget>(
  650. window, "logicEditorWidget");
  651. QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
  652. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  653. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  654. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  655. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  656. QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget");
  657. QTreeWidget *project_tree = requiredChild<QTreeWidget>(window, "projectTree");
  658. require(!runtime_tab->isVisible(),
  659. "runtime monitor workspace must be hidden while editing");
  660. for (const char *action_name : {
  661. "exitAction",
  662. "newProjectAction",
  663. "saveProjectAction",
  664. "saveAsProjectAction",
  665. "loadProjectAction",
  666. "undoAction",
  667. "redoAction",
  668. "deleteSelectionAction",
  669. "clearSelectionAction",
  670. "configurePlcAction",
  671. "configureRegisterCommentsAction",
  672. "disconnectPlcAction",
  673. "addButtonAction",
  674. "addIndicatorAction",
  675. "addNumericDisplayAction",
  676. "addNumericInputAction",
  677. "addProgressBarAction",
  678. "addLabelAction",
  679. "addPageJumpAction",
  680. "addAlarmListAction",
  681. "configureAlarmsAction",
  682. "deleteControlAction",
  683. "addRungAction",
  684. "insertHorizontalWireAction",
  685. "insertVerticalWireAction",
  686. "deleteHorizontalWireAction",
  687. "deleteVerticalWireAction",
  688. "parallelInsertAction",
  689. "addNormallyOpenAction",
  690. "addNormallyClosedAction",
  691. "addRisingEdgeAction",
  692. "addFallingEdgeAction",
  693. "addTimerContactAction",
  694. "addCounterContactAction",
  695. "addNormalCoilAction",
  696. "addSetCoilAction",
  697. "addResetCoilAction",
  698. "addTonAction",
  699. "addCtuAction",
  700. "addCtdAction",
  701. "addMoveAction",
  702. "addAddAction",
  703. "addSubAction",
  704. "addCompareAction",
  705. "editRungCommentAction",
  706. "deleteLogicAction",
  707. "editingModeAction",
  708. "offlineModeAction",
  709. "onlineModeAction"})
  710. {
  711. require(!requiredChild<QAction>(window, action_name)->icon().isNull(),
  712. std::string("UI action must have a semantic icon: ") + action_name);
  713. }
  714. require(!hmi_more_controls->icon().isNull()
  715. && !logic_contact_menu->icon().isNull()
  716. && !logic_output_menu->icon().isNull()
  717. && !logic_timer_counter_menu->icon().isNull()
  718. && !logic_data_menu->icon().isNull(),
  719. "every grouped toolbar menu must have an icon");
  720. require(editor_tabs->count() == 3
  721. && !editor_tabs->tabIcon(0).isNull()
  722. && !editor_tabs->tabIcon(1).isNull()
  723. && !editor_tabs->tabIcon(2).isNull(),
  724. "every editor workspace tab must have an icon");
  725. QToolButton *monitor_remove = free_monitor->findChild<QToolButton *>(
  726. QStringLiteral("removeButton"));
  727. QToolButton *monitor_clear = free_monitor->findChild<QToolButton *>(
  728. QStringLiteral("clearButton"));
  729. require(monitor_remove != nullptr && monitor_clear != nullptr
  730. && !monitor_remove->icon().isNull()
  731. && !monitor_clear->icon().isNull(),
  732. "free-monitor delete and clear commands must have icons");
  733. require(hmi_more_controls->menu() != nullptr
  734. && hmi_more_controls->menu()->actions().contains(add_progress_bar_action),
  735. "advanced HMI controls must remain reachable from the more-controls menu");
  736. require(logic_contact_menu->menu() != nullptr
  737. && logic_contact_menu->menu()->actions().contains(
  738. requiredChild<QAction>(window, "addRisingEdgeAction")),
  739. "edge and resource contacts must remain reachable from the contact menu");
  740. require(logic_output_menu->menu() != nullptr
  741. && logic_output_menu->menu()->actions().contains(
  742. requiredChild<QAction>(window, "addResetCoilAction")),
  743. "set/reset coils must remain reachable from the output menu");
  744. require(logic_timer_counter_menu->menu() != nullptr
  745. && logic_timer_counter_menu->menu()->actions().contains(
  746. requiredChild<QAction>(window, "addCtdAction")),
  747. "timer and counter instructions must remain reachable from their menu");
  748. require(logic_data_menu->menu() != nullptr
  749. && logic_data_menu->menu()->actions().contains(
  750. requiredChild<QAction>(window, "addSubAction")),
  751. "data instructions must remain reachable from the data menu");
  752. const qreal compact_scale = hmi_editor->transform().m11();
  753. window.resize(1600, 900);
  754. QApplication::processEvents();
  755. require(hmi_editor->transform().m11() > compact_scale,
  756. "HMI page must refit when the window becomes larger");
  757. require(editing_action->isChecked(), "editing action must be selected initially");
  758. require(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z)
  759. && redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y)
  760. && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete)
  761. && insert_horizontal_wire_action->shortcut()
  762. == QKeySequence(Qt::Key_F11)
  763. && insert_vertical_wire_action->shortcut()
  764. == QKeySequence(Qt::Key_F12)
  765. && delete_horizontal_wire_action->shortcut()
  766. == QKeySequence(Qt::SHIFT | Qt::Key_F11)
  767. && delete_vertical_wire_action->shortcut()
  768. == QKeySequence(Qt::SHIFT | Qt::Key_F12),
  769. "core editor actions must expose the agreed keyboard shortcuts");
  770. require(project_dock->isEnabled(), "project dock must be enabled while editing");
  771. require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
  772. const std::string default_logic_id = logic_editor_service.firstLogicId();
  773. project_tree->setCurrentItem(project_tree->topLevelItem(1)->child(0));
  774. QApplication::processEvents();
  775. const bool default_logic_enabled =
  776. logic_editor_service.findLogic(default_logic_id)->enabled;
  777. toggle_logic_enabled_action->trigger();
  778. require(undo_action->isEnabled()
  779. && logic_editor_service.findLogic(default_logic_id)->enabled
  780. != default_logic_enabled,
  781. "project-tree edits must immediately enable ladder undo");
  782. undo_action->trigger();
  783. require(logic_editor_service.findLogic(default_logic_id)->enabled
  784. == default_logic_enabled,
  785. "ladder undo must restore a project-tree edit");
  786. editor_tabs->setCurrentIndex(0);
  787. QApplication::processEvents();
  788. add_button_action->trigger();
  789. const std::string page_id = editor_service.firstPageId();
  790. require(editor_service.findPage(page_id)->controls.size() == 1,
  791. "adding a control must update the HMI page model");
  792. require(selection->text() == QStringLiteral("button-1(未绑定)"),
  793. "an unbound added control must be marked in the property panel");
  794. require(text_edit->text() == QStringLiteral("按钮"),
  795. "property panel must show the control text");
  796. require(button_operation->currentText() == QStringLiteral("瞬时 ON"),
  797. "new HMI buttons must default to momentary ON");
  798. text_edit->setFocus();
  799. text_edit->selectAll();
  800. QTest::keyClicks(text_edit, "modified");
  801. QTest::keyClick(text_edit, Qt::Key_Z, Qt::ControlModifier);
  802. require(text_edit->text() == QStringLiteral("按钮")
  803. && editor_service.findControl(page_id, "button-1") != nullptr,
  804. "Ctrl+Z in a property input must undo text without undoing the HMI model");
  805. const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
  806. require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
  807. "an unbound HMI control must not reserve an address label area");
  808. button_operation->setCurrentIndex(
  809. button_operation->findData(static_cast<int>(HmiButtonOperation::Toggle)));
  810. apply_properties->click();
  811. require(editor_service.findControl(page_id, "button-1")->buttonOperation
  812. == HmiButtonOperation::Toggle,
  813. "the property panel must update the HMI button operation");
  814. HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
  815. bound_button.binding = RegisterAddress{RegisterArea::M, 0};
  816. require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
  817. "binding an HMI button to M0 must succeed");
  818. hmi_editor->reloadPage();
  819. hmi_editor->selectControl(bound_button.id);
  820. const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
  821. require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
  822. "a bound HMI control must include its address label above the control body");
  823. delete_control_action->trigger();
  824. require(editor_service.findPage(page_id)->controls.empty(),
  825. "deleting a selected control must update the HMI page model");
  826. QAction *add_label_action = requiredChild<QAction>(window, "addLabelAction");
  827. add_label_action->trigger();
  828. add_label_action->trigger();
  829. add_label_action->trigger();
  830. hmi_editor->scene()->clearSelection();
  831. for (QGraphicsItem *item : hmi_editor->scene()->items())
  832. {
  833. if (item->zValue() >= 0.0)
  834. {
  835. item->setSelected(true);
  836. }
  837. }
  838. delete_selection_action->trigger();
  839. require(editor_service.findPage(page_id)->controls.empty(),
  840. "Delete must remove every selected HMI control in one operation");
  841. undo_action->trigger();
  842. require(editor_service.findPage(page_id)->controls.size() == 3U,
  843. "HMI undo must restore a batch deletion");
  844. redo_action->trigger();
  845. require(editor_service.findPage(page_id)->controls.empty(),
  846. "HMI redo must reapply a batch deletion");
  847. add_progress_bar_action->trigger();
  848. const HmiControl *default_progress = editor_service.findControl(
  849. page_id, "progress-bar-1");
  850. require(default_progress != nullptr
  851. && default_progress->progressBar.has_value()
  852. && progress_minimum->isVisible()
  853. && progress_maximum->isVisible()
  854. && progress_show_value->isVisible()
  855. && progress_minimum->value() == 0
  856. && progress_maximum->value() == 100
  857. && progress_show_value->isChecked(),
  858. "ProgressBar properties must expose the registered defaults");
  859. progress_minimum->setValue(-20);
  860. progress_maximum->setValue(80);
  861. progress_show_value->setChecked(false);
  862. binding_area->setCurrentIndex(binding_area->findData(1));
  863. binding_index->setValue(4);
  864. apply_properties->click();
  865. const HmiControl *configured_progress = editor_service.findControl(
  866. page_id, "progress-bar-1");
  867. require(configured_progress != nullptr
  868. && configured_progress->binding
  869. == RegisterAddress{RegisterArea::D, 4}
  870. && configured_progress->progressBar->minimumValue == -20
  871. && configured_progress->progressBar->maximumValue == 80
  872. && !configured_progress->progressBar->showValue,
  873. "the property panel must update ProgressBar binding and range settings");
  874. delete_control_action->trigger();
  875. require(editor_service.findPage(page_id)->controls.empty(),
  876. "deleting a ProgressBar must remove it from the HMI page");
  877. add_indicator_action->trigger();
  878. HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1");
  879. runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3};
  880. require(editor_service.updateControl(
  881. page_id, runtime_indicator.id, runtime_indicator).succeeded,
  882. "a configured HMI control must be available for the runtime projection");
  883. hmi_editor->reloadPage();
  884. add_normally_open_action->trigger();
  885. editor_tabs->setCurrentIndex(1);
  886. QApplication::processEvents();
  887. undo_action->trigger();
  888. require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
  889. ->rungs.front().condition.has_value() == false,
  890. "logic undo must use the ladder history when the ladder tab is active");
  891. redo_action->trigger();
  892. const LadderRung &restored_rung = logic_editor_service.findLogic(
  893. logic_editor_service.firstLogicId())->rungs.front();
  894. require(restored_rung.condition.has_value(),
  895. "logic redo must restore the ladder edit");
  896. std::vector<const LogicNode *> restored_nodes;
  897. collectConditionNodes(*restored_rung.condition, &restored_nodes);
  898. require(restored_nodes.size() == 1U,
  899. "logic redo must restore the original condition node");
  900. const std::string restored_node_id = restored_nodes.front()->id;
  901. logic_editor->selectNode(restored_node_id);
  902. insert_horizontal_wire_action->trigger();
  903. const LadderRung &wired_rung = logic_editor_service.findLogic(
  904. logic_editor_service.firstLogicId())->rungs.front();
  905. require(wired_rung.condition->kind == ConditionExpressionKind::Series
  906. && wired_rung.condition->children.at(1).kind
  907. == ConditionExpressionKind::Wire,
  908. "F11 must insert a selectable horizontal wire after the current condition");
  909. delete_horizontal_wire_action->trigger();
  910. require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
  911. ->rungs.front().condition->kind == ConditionExpressionKind::Node,
  912. "Shift+F11 must delete the selected horizontal wire");
  913. logic_editor->selectNode(restored_node_id);
  914. insert_horizontal_wire_action->trigger();
  915. add_normally_open_action->trigger();
  916. const LadderRung &replaced_wire_rung = logic_editor_service.findLogic(
  917. logic_editor_service.firstLogicId())->rungs.front();
  918. require(replaced_wire_rung.condition->kind == ConditionExpressionKind::Series
  919. && replaced_wire_rung.condition->children.at(1).kind
  920. == ConditionExpressionKind::Node,
  921. "adding a contact on a selected wire must replace the wire in place");
  922. undo_action->trigger();
  923. undo_action->trigger();
  924. logic_editor->selectNode(restored_node_id);
  925. insert_vertical_wire_action->trigger();
  926. const LadderRung &vertical_rung = logic_editor_service.findLogic(
  927. logic_editor_service.firstLogicId())->rungs.front();
  928. require(vertical_rung.condition->kind == ConditionExpressionKind::Parallel
  929. && vertical_rung.condition->children.at(1).kind
  930. == ConditionExpressionKind::Wire,
  931. "F12 must create a structured wire bypass with vertical connectors");
  932. logic_editor->scene()->clearSelection();
  933. for (QGraphicsItem *item : logic_editor->scene()->items())
  934. {
  935. if (qFuzzyCompare(item->zValue(), 2.5))
  936. {
  937. item->setSelected(true);
  938. }
  939. }
  940. delete_vertical_wire_action->trigger();
  941. require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
  942. ->rungs.front().condition->kind == ConditionExpressionKind::Node,
  943. "Shift+F12 must remove the selected vertical connection branch");
  944. logic_editor->selectNode(restored_node_id);
  945. parallel_insert_action->trigger();
  946. add_normal_coil_action->trigger();
  947. const ControlLogic *logic = logic_editor_service.findLogic(
  948. logic_editor_service.firstLogicId());
  949. require(logic != nullptr && logic->rungs.size() == 1,
  950. "logic actions must edit the default ladder rung");
  951. require(logic->rungs.front().condition.has_value()
  952. && logic->rungs.front().condition->kind
  953. == ConditionExpressionKind::Parallel
  954. && logic->rungs.front().condition->children.size() == 2U,
  955. "parallel branch action must create a parallel expression");
  956. require(logic->rungs.front().output.has_value(),
  957. "coil action must set the fixed ladder output");
  958. offline_action->trigger();
  959. require(mode_service.mode() == ApplicationMode::Editing,
  960. "unconfigured ladder nodes must block offline running");
  961. const LadderRung &rung = logic->rungs.front();
  962. std::vector<const LogicNode *> condition_nodes;
  963. collectConditionNodes(*rung.condition, &condition_nodes);
  964. for (const LogicNode *node : condition_nodes)
  965. {
  966. require(logic_editor_service.updateNodeConfig(
  967. logic_editor_service.firstLogicId(),
  968. node->id,
  969. ContactNodeConfig{
  970. RegisterAddress{RegisterArea::M, 0},
  971. ContactMode::NormallyOpen})
  972. .succeeded,
  973. "applying a contact configuration must complete the ladder node");
  974. }
  975. require(logic_editor_service.updateNodeConfig(
  976. logic_editor_service.firstLogicId(),
  977. rung.output->id,
  978. CoilNodeConfig{
  979. RegisterAddress{RegisterArea::M, 1},
  980. CoilMode::Normal})
  981. .succeeded,
  982. "applying a coil configuration must complete the ladder node");
  983. offline_action->trigger();
  984. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  985. "offline action must enter offline running");
  986. require(simulation_service.state() == SimulationState::Running,
  987. "offline action must start the actual simulation service");
  988. require(executor_status->text().contains(QStringLiteral("运行")),
  989. "executor status label must report the actual running state");
  990. require(!project_dock->isEnabled(),
  991. "project dock must be disabled while running");
  992. require(!properties_dock->isEnabled(),
  993. "properties dock must be disabled while running");
  994. require(!add_button_action->isEnabled(),
  995. "HMI add controls must be disabled while running");
  996. require(!add_progress_bar_action->isEnabled(),
  997. "ProgressBar creation must be disabled while running");
  998. require(!add_normally_open_action->isEnabled(),
  999. "logic add nodes must be disabled while running");
  1000. require(!undo_action->isEnabled() && !redo_action->isEnabled()
  1001. && !delete_selection_action->isEnabled(),
  1002. "undo, redo and delete must be disabled while running");
  1003. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  1004. && runtime_logic->isVisible() && free_monitor->isVisible(),
  1005. "offline running must show HMI, ladder trace and free monitor together");
  1006. auto *runtime_hmi_view = qobject_cast<HmiEditorWidget *>(runtime_hmi);
  1007. require(runtime_hmi_view != nullptr
  1008. && runtime_hmi_view->scene()->items().size() == 2,
  1009. "offline runtime HMI must project the configured page control");
  1010. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  1011. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  1012. QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
  1013. monitor_address->setText(QStringLiteral("M0"));
  1014. monitor_add->click();
  1015. repository.writeBit({RegisterArea::M, 0}, true);
  1016. auto *monitor_widget = requiredChild<FreeMonitorWidget>(window, "freeMonitorWidget");
  1017. monitor_widget->refreshValues(
  1018. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  1019. require(monitor_table->rowCount() == 1
  1020. && monitor_table->item(0, 2)->text() == QStringLiteral("ON"),
  1021. "offline free monitor must read the same virtual M/D repository as HMI");
  1022. online_action->trigger();
  1023. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  1024. "running modes must not switch directly through the UI");
  1025. require(offline_action->isChecked(),
  1026. "failed mode changes must restore the active action");
  1027. editing_action->trigger();
  1028. require(mode_service.mode() == ApplicationMode::Editing,
  1029. "editing action must return to editing");
  1030. require(simulation_service.state() == SimulationState::Stopped,
  1031. "editing action must stop the simulation service");
  1032. require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
  1033. "executor status label must report the actual stopped state");
  1034. require(project_dock->isEnabled(),
  1035. "project dock must be restored after returning to editing");
  1036. require(properties_dock->isEnabled(),
  1037. "properties dock must be restored after returning to editing");
  1038. require(add_button_action->isEnabled(),
  1039. "HMI add controls must be restored after returning to editing");
  1040. require(add_progress_bar_action->isEnabled(),
  1041. "ProgressBar creation must be restored after returning to editing");
  1042. require(add_normally_open_action->isEnabled(),
  1043. "logic add nodes must be restored after returning to editing");
  1044. require(!runtime_tab->isVisible(),
  1045. "returning to editing must hide the runtime monitor workspace");
  1046. online_action->trigger();
  1047. require(mode_service.mode() == ApplicationMode::Editing,
  1048. "online action must require an initial PLC read");
  1049. require(editing_action->isChecked(),
  1050. "rejected online running must restore the editing action");
  1051. }
  1052. void testEdgeTimerPropertyEditingAndParallelMenu()
  1053. {
  1054. TestProjectStorage storage;
  1055. ProjectService project_service(storage);
  1056. HmiEditorService editor_service(project_service);
  1057. LogicEditorService logic_editor_service(project_service);
  1058. VirtualRegisterRepository repository;
  1059. HmiRuntimeService runtime_service(repository);
  1060. AlarmEditorService alarm_editor_service(project_service);
  1061. AlarmService alarm_service(project_service, repository);
  1062. RegisterCommentService register_comment_service(project_service);
  1063. OfflineSimulationService simulation_service(repository);
  1064. RuntimeModeService mode_service(project_service, simulation_service);
  1065. RegisterMonitorService monitor_service(repository);
  1066. MainWindow window(
  1067. mode_service,
  1068. project_service,
  1069. editor_service,
  1070. logic_editor_service,
  1071. runtime_service,
  1072. alarm_editor_service,
  1073. alarm_service,
  1074. register_comment_service,
  1075. monitor_service);
  1076. window.show();
  1077. QApplication::processEvents();
  1078. QAction *parallel_action = requiredChild<QAction>(window, "parallelInsertAction");
  1079. require(parallel_action->menu() != nullptr,
  1080. "parallel insertion must expose its node type menu");
  1081. QStringList parallel_labels;
  1082. for (QAction *action : parallel_action->menu()->actions())
  1083. {
  1084. parallel_labels.push_back(action->text());
  1085. }
  1086. require(parallel_labels.contains(QStringLiteral("并联上升沿触点"))
  1087. && parallel_labels.contains(QStringLiteral("并联下降沿触点"))
  1088. && parallel_labels.contains(QStringLiteral("并联 T 触点")),
  1089. "parallel insertion must include edge and T contact choices");
  1090. QAction *rising_action = requiredChild<QAction>(window, "addRisingEdgeAction");
  1091. QAction *falling_action = requiredChild<QAction>(window, "addFallingEdgeAction");
  1092. QAction *timer_contact_action = requiredChild<QAction>(
  1093. window, "addTimerContactAction");
  1094. QAction *ton_action = requiredChild<QAction>(window, "addTonAction");
  1095. requiredChild<QAction>(window, "editRungCommentAction");
  1096. requiredChild<QAction>(window, "configureRegisterCommentsAction");
  1097. QSpinBox *address = requiredChild<QSpinBox>(window, "logicAddressSpinBox");
  1098. QSpinBox *preset = requiredChild<QSpinBox>(window, "logicPresetSpinBox");
  1099. QComboBox *mode = requiredChild<QComboBox>(window, "logicModeComboBox");
  1100. QPushButton *apply = requiredChild<QPushButton>(
  1101. window, "applyLogicPropertiesButton");
  1102. const std::string logic_id = logic_editor_service.firstLogicId();
  1103. rising_action->trigger();
  1104. address->setValue(8);
  1105. mode->setCurrentIndex(mode->findData(static_cast<int>(EdgeMode::Falling)));
  1106. apply->click();
  1107. const LogicNode *edge = logic_editor_service.findNode(logic_id, "edge-1");
  1108. require(edge != nullptr && edge->configured
  1109. && std::get<EdgeContactNodeConfig>(edge->config).address.index() == 8
  1110. && std::get<EdgeContactNodeConfig>(edge->config).mode
  1111. == EdgeMode::Falling,
  1112. "edge properties must update M address and edge mode");
  1113. timer_contact_action->trigger();
  1114. address->setValue(9);
  1115. mode->setCurrentIndex(mode->findData(
  1116. static_cast<int>(ContactMode::NormallyClosed)));
  1117. apply->click();
  1118. const LogicNode *timer_contact = logic_editor_service.findNode(
  1119. logic_id, "timer-contact-1");
  1120. require(timer_contact != nullptr && timer_contact->configured
  1121. && std::get<TimerContactNodeConfig>(timer_contact->config).address
  1122. == TimerAddress{9}
  1123. && std::get<TimerContactNodeConfig>(timer_contact->config).mode
  1124. == ContactMode::NormallyClosed,
  1125. "T contact properties must update T address and contact mode");
  1126. ton_action->trigger();
  1127. require(preset->isEnabled() && !mode->isEnabled(),
  1128. "TON properties must enable PT and disable irrelevant mode editing");
  1129. address->setValue(9);
  1130. preset->setValue(1234);
  1131. apply->click();
  1132. const LogicNode *ton = logic_editor_service.findNode(logic_id, "ton-1");
  1133. require(ton != nullptr && ton->configured
  1134. && std::get<TonNodeConfig>(ton->config).address == TimerAddress{9}
  1135. && std::get<TonNodeConfig>(ton->config).presetMs == 1234,
  1136. "TON properties must update T address and preset milliseconds");
  1137. falling_action->trigger();
  1138. const LogicNode *falling = logic_editor_service.findNode(logic_id, "edge-2");
  1139. require(falling != nullptr
  1140. && std::get<EdgeContactNodeConfig>(falling->config).mode
  1141. == EdgeMode::Falling,
  1142. "falling edge action must create a falling-edge condition");
  1143. }
  1144. void testPlcConfigurationUsesDialog()
  1145. {
  1146. PlcSerialConfiguration initial;
  1147. initial.portName = "COM17";
  1148. initial.serverAddress = 12;
  1149. initial.baudRate = 38400;
  1150. initial.dataBits = 7;
  1151. initial.parity = 3;
  1152. initial.stopBits = 2;
  1153. initial.responseTimeoutMs = 2500;
  1154. initial.retries = 4;
  1155. initial.pollIntervalMs = 350;
  1156. PlcConnectionDialog configuration_dialog(initial);
  1157. const PlcSerialConfiguration actual = configuration_dialog.configuration();
  1158. require(actual.portName == initial.portName,
  1159. "PLC dialog must preserve a manually entered serial port");
  1160. require(actual.serverAddress == initial.serverAddress
  1161. && actual.baudRate == initial.baudRate
  1162. && actual.dataBits == initial.dataBits
  1163. && actual.parity == initial.parity
  1164. && actual.stopBits == initial.stopBits,
  1165. "PLC dialog must preserve Modbus RTU serial parameters");
  1166. require(actual.responseTimeoutMs == initial.responseTimeoutMs
  1167. && actual.retries == initial.retries
  1168. && actual.pollIntervalMs == initial.pollIntervalMs,
  1169. "PLC dialog must preserve communication timing parameters");
  1170. TestProjectStorage storage;
  1171. ProjectService project_service(storage);
  1172. HmiEditorService editor_service(project_service);
  1173. LogicEditorService logic_editor_service(project_service);
  1174. VirtualRegisterRepository repository;
  1175. HmiRuntimeService runtime_service(repository);
  1176. AlarmEditorService alarm_editor_service(project_service);
  1177. AlarmService alarm_service(project_service, repository);
  1178. RegisterCommentService register_comment_service(project_service);
  1179. OfflineSimulationService simulation_service(repository);
  1180. RuntimeModeService mode_service(project_service, simulation_service);
  1181. RegisterMonitorService monitor_service(repository);
  1182. MainWindow window(
  1183. mode_service,
  1184. project_service,
  1185. editor_service,
  1186. logic_editor_service,
  1187. runtime_service,
  1188. alarm_editor_service,
  1189. alarm_service,
  1190. register_comment_service,
  1191. monitor_service);
  1192. require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
  1193. "serial configuration controls must not be embedded in the main window");
  1194. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  1195. bool dialog_opened = false;
  1196. QTimer::singleShot(
  1197. 0,
  1198. [&dialog_opened]
  1199. {
  1200. auto *dialog = qobject_cast<PlcConnectionDialog *>(
  1201. QApplication::activeModalWidget());
  1202. dialog_opened = dialog != nullptr;
  1203. if (dialog != nullptr)
  1204. {
  1205. dialog->reject();
  1206. }
  1207. });
  1208. configure_action->trigger();
  1209. require(dialog_opened,
  1210. "PLC configuration action must open the dedicated configuration dialog");
  1211. }
  1212. void testRepeatedPlcStatusNotificationsAreCoalesced()
  1213. {
  1214. TestProjectStorage storage;
  1215. ProjectService project_service(storage);
  1216. HmiEditorService editor_service(project_service);
  1217. LogicEditorService logic_editor_service(project_service);
  1218. VirtualRegisterRepository virtual_repository;
  1219. VirtualRegisterRepository plc_repository;
  1220. ActiveRegisterRepository active_repository(virtual_repository);
  1221. HmiRuntimeService runtime_service(active_repository);
  1222. AlarmEditorService alarm_editor_service(project_service);
  1223. AlarmService alarm_service(project_service, active_repository);
  1224. RegisterCommentService register_comment_service(project_service);
  1225. OfflineSimulationService simulation_service(virtual_repository);
  1226. RepeatedStatusGateway gateway;
  1227. RuntimeModeService mode_service(project_service, simulation_service);
  1228. RegisterMonitorService monitor_service(active_repository);
  1229. mode_service.configurePlc(
  1230. gateway, active_repository, virtual_repository, plc_repository);
  1231. MainWindow window(
  1232. mode_service,
  1233. project_service,
  1234. editor_service,
  1235. logic_editor_service,
  1236. runtime_service,
  1237. alarm_editor_service,
  1238. alarm_service,
  1239. register_comment_service,
  1240. monitor_service);
  1241. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  1242. const int initial_count = output->count();
  1243. const PlcCommunicationResult result = mode_service.connectPlc(
  1244. {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
  1245. require(result.succeeded, "PLC connection setup must succeed");
  1246. QApplication::processEvents();
  1247. const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
  1248. int matching_count = 0;
  1249. for (int index = initial_count; index < output->count(); ++index)
  1250. {
  1251. if (output->item(index)->text() == expected)
  1252. {
  1253. ++matching_count;
  1254. }
  1255. }
  1256. require(matching_count == 1,
  1257. "repeated PLC status callbacks must append one coalesced output message");
  1258. }
  1259. void testFreeMonitorWritesSingleValues()
  1260. {
  1261. VirtualRegisterRepository repository;
  1262. RegisterMonitorService monitor_service(repository);
  1263. FreeMonitorWidget widget(monitor_service);
  1264. widget.show();
  1265. QApplication::processEvents();
  1266. QLineEdit *address_edit = widget.findChild<QLineEdit *>("addressEdit");
  1267. QPushButton *add_button = widget.findChild<QPushButton *>("addButton");
  1268. QTableWidget *table = widget.findChild<QTableWidget *>("monitorTable");
  1269. require(address_edit != nullptr && add_button != nullptr && table != nullptr,
  1270. "free monitor UI test requires the address, add and table controls");
  1271. address_edit->setText(QStringLiteral("M0"));
  1272. add_button->click();
  1273. require(table->rowCount() == 1, "free monitor UI must add an M row before writing");
  1274. auto *bit_target = qobject_cast<QComboBox *>(table->cellWidget(0, 3));
  1275. auto *bit_write = qobject_cast<QPushButton *>(table->cellWidget(0, 4));
  1276. require(bit_target != nullptr && bit_write != nullptr && !bit_write->isEnabled(),
  1277. "free monitor writes must be disabled outside a running mode");
  1278. widget.setWriteEnabled(true);
  1279. bit_target->setCurrentIndex(bit_target->findData(true));
  1280. bit_write->click();
  1281. require(repository.readBit({RegisterArea::M, 0}).succeeded
  1282. && repository.readBit({RegisterArea::M, 0}).value,
  1283. "free monitor UI must write the selected M value");
  1284. address_edit->setText(QStringLiteral("D0"));
  1285. add_button->click();
  1286. require(table->rowCount() == 2, "free monitor UI must add a D row before writing");
  1287. auto *word_target = qobject_cast<QLineEdit *>(table->cellWidget(1, 3));
  1288. auto *word_write = qobject_cast<QPushButton *>(table->cellWidget(1, 4));
  1289. require(word_target != nullptr && word_write != nullptr && word_write->isEnabled(),
  1290. "D monitor rows must expose an enabled write action when permitted");
  1291. word_target->setText(QStringLiteral("-321"));
  1292. word_write->click();
  1293. require(repository.readWord({RegisterArea::D, 0}).succeeded
  1294. && repository.readWord({RegisterArea::D, 0}).value == -321,
  1295. "free monitor UI must write signed D values");
  1296. }
  1297. void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly()
  1298. {
  1299. TestProjectStorage storage;
  1300. ProjectService project_service(storage);
  1301. HmiEditorService editor_service(project_service);
  1302. LogicEditorService logic_editor_service(project_service);
  1303. VirtualRegisterRepository virtual_repository;
  1304. VirtualRegisterRepository plc_repository;
  1305. ActiveRegisterRepository active_repository(virtual_repository);
  1306. HmiRuntimeService runtime_service(active_repository);
  1307. AlarmEditorService alarm_editor_service(project_service);
  1308. AlarmService alarm_service(project_service, active_repository);
  1309. RegisterCommentService register_comment_service(project_service);
  1310. OfflineSimulationService simulation_service(virtual_repository);
  1311. OnlineReadyGateway gateway;
  1312. RuntimeModeService mode_service(project_service, simulation_service);
  1313. RegisterMonitorService monitor_service(active_repository);
  1314. mode_service.configurePlc(
  1315. gateway, active_repository, virtual_repository, plc_repository);
  1316. MainWindow window(
  1317. mode_service,
  1318. project_service,
  1319. editor_service,
  1320. logic_editor_service,
  1321. runtime_service,
  1322. alarm_editor_service,
  1323. alarm_service,
  1324. register_comment_service,
  1325. monitor_service);
  1326. window.resize(1200, 760);
  1327. window.show();
  1328. QApplication::processEvents();
  1329. require(mode_service.connectPlc(
  1330. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  1331. "online workspace test must connect the fake PLC gateway");
  1332. gateway.completeInitialRead();
  1333. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  1334. online_action->trigger();
  1335. QApplication::processEvents();
  1336. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  1337. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  1338. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  1339. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  1340. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  1341. "completed PLC initial read must allow online running");
  1342. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  1343. && free_monitor->isVisible(),
  1344. "online running must show HMI and free monitor together");
  1345. require(!runtime_logic->isVisible(),
  1346. "online running must not show a misleading local ladder runtime trace");
  1347. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  1348. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  1349. QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
  1350. monitor_address->setText(QStringLiteral("D9"));
  1351. monitor_add->click();
  1352. require(std::find(
  1353. gateway.poll_addresses.begin(),
  1354. gateway.poll_addresses.end(),
  1355. RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(),
  1356. "online free monitor addresses must join the active PLC poll set immediately");
  1357. auto *monitor_write = qobject_cast<QPushButton *>(monitor_table->cellWidget(0, 4));
  1358. require(monitor_write != nullptr && monitor_write->isEnabled(),
  1359. "online Connected mode must enable free monitor writes");
  1360. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  1361. QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction");
  1362. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  1363. gateway.timeoutCommunication(
  1364. "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线");
  1365. QApplication::processEvents();
  1366. require(mode_service.mode() == ApplicationMode::Editing,
  1367. "a PLC timeout must return online running to editing");
  1368. require(!monitor_write->isEnabled(),
  1369. "a PLC communication fault must disable free monitor writes");
  1370. require(disconnect_action->isEnabled(),
  1371. "a timeout must keep disconnect available while the local port remains open");
  1372. require(configure_action->isEnabled()
  1373. && configure_action->text() == QStringLiteral("PLC 重新配置"),
  1374. "a timeout must keep PLC reconfiguration available");
  1375. gateway.recoverCommunication();
  1376. QApplication::processEvents();
  1377. require(mode_service.mode() == ApplicationMode::Editing,
  1378. "automatic communication recovery must remain in editing mode");
  1379. require(output->count() > 0
  1380. && output->item(output->count() - 1)->text().contains(
  1381. QStringLiteral("通信已恢复")),
  1382. "automatic recovery must be retained in the output log");
  1383. online_action->trigger();
  1384. require(mode_service.mode() == ApplicationMode::Editing,
  1385. "recovered communication must complete a fresh initial read before online mode");
  1386. gateway.completeInitialRead();
  1387. QApplication::processEvents();
  1388. online_action->trigger();
  1389. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  1390. "users must be able to re-enter online mode after the recovered initial read");
  1391. gateway.loseSerialConnection(
  1392. "PLC 串口 COM9 连接已中断;请检查 USB 转串口是否被拔出或已经失效");
  1393. QApplication::processEvents();
  1394. require(mode_service.mode() == ApplicationMode::Editing,
  1395. "a communication fault must return the UI from online running to editing");
  1396. require(configure_action->isEnabled()
  1397. && configure_action->text() == QStringLiteral("PLC 配置"),
  1398. "a lost serial connection must expose direct configuration");
  1399. require(!disconnect_action->isEnabled(),
  1400. "a lost serial connection must disable redundant disconnect actions");
  1401. require(output->count() > 0
  1402. && output->item(output->count() - 1)->text().contains(
  1403. QStringLiteral("连接已中断")),
  1404. "communication fault details must remain in the output log");
  1405. online_action->trigger();
  1406. require(mode_service.mode() == ApplicationMode::Editing,
  1407. "faulted PLC state must not re-enter online running without a fresh read");
  1408. }
  1409. void testMultiPageAndLogicMainWindowIntegration()
  1410. {
  1411. TestProjectStorage storage;
  1412. ProjectService project_service(storage);
  1413. HmiEditorService editor_service(project_service);
  1414. LogicEditorService logic_editor_service(project_service);
  1415. const std::string main_page_id = editor_service.ensureDefaultPage().id;
  1416. const std::string settings_page_id = editor_service.addPage("Settings").id;
  1417. const std::string first_logic_id = logic_editor_service.ensureDefaultLogic().id;
  1418. const std::string second_logic_id =
  1419. logic_editor_service.addLogic("Safety logic").id;
  1420. const HmiEditorResult jump_result = editor_service.addControl(
  1421. main_page_id, HmiControlType::PageJump);
  1422. HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id);
  1423. jump.pageJump = HmiPageJumpConfig{settings_page_id};
  1424. require(editor_service.updateControl(
  1425. main_page_id, jump_result.id, jump).succeeded,
  1426. "the integration fixture must configure PageJump");
  1427. VirtualRegisterRepository repository;
  1428. HmiRuntimeService runtime_service(repository);
  1429. AlarmEditorService alarm_editor_service(project_service);
  1430. AlarmService alarm_service(project_service, repository);
  1431. RegisterCommentService register_comment_service(project_service);
  1432. OfflineSimulationService simulation_service(repository);
  1433. RuntimeModeService mode_service(project_service, simulation_service);
  1434. RegisterMonitorService monitor_service(repository);
  1435. HmiNavigationService navigation_service(project_service);
  1436. MainWindow window(
  1437. mode_service,
  1438. project_service,
  1439. editor_service,
  1440. logic_editor_service,
  1441. runtime_service,
  1442. alarm_editor_service,
  1443. alarm_service,
  1444. navigation_service,
  1445. register_comment_service,
  1446. monitor_service);
  1447. window.resize(1400, 820);
  1448. window.show();
  1449. QApplication::processEvents();
  1450. QTreeWidget *tree = requiredChild<QTreeWidget>(window, "projectTree");
  1451. require(tree->topLevelItemCount() == 2
  1452. && tree->topLevelItem(0)->childCount() == 2
  1453. && tree->topLevelItem(1)->childCount() == 2,
  1454. "the project tree must list every page and logic module");
  1455. tree->setCurrentItem(tree->topLevelItem(0)->child(1));
  1456. QApplication::processEvents();
  1457. require(window.currentHmiPageId() == settings_page_id,
  1458. "selecting a page tree node must change the current HMI page id");
  1459. QAction *add_label = requiredChild<QAction>(window, "addLabelAction");
  1460. add_label->trigger();
  1461. require(editor_service.findPage(settings_page_id)->controls.size() == 1U
  1462. && editor_service.findPage(main_page_id)->controls.size() == 1U,
  1463. "HMI toolbar actions must edit the selected page rather than the first page");
  1464. QComboBox *binding_area = requiredChild<QComboBox>(window, "bindingAreaComboBox");
  1465. QComboBox *target_page = requiredChild<QComboBox>(window, "targetPageComboBox");
  1466. require(!binding_area->isVisible() && !target_page->isVisible(),
  1467. "Label properties must hide both register binding and PageJump target fields");
  1468. tree->setCurrentItem(tree->topLevelItem(1)->child(1));
  1469. QApplication::processEvents();
  1470. require(window.currentLogicId() == second_logic_id,
  1471. "selecting a logic tree node must change the current logic id");
  1472. requiredChild<QAction>(window, "addRungAction")->trigger();
  1473. require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 2U
  1474. && logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U,
  1475. "logic toolbar actions must edit the selected logic module");
  1476. tree->setCurrentItem(tree->topLevelItem(0)->child(0));
  1477. QApplication::processEvents();
  1478. HmiEditorWidget *editor = requiredChild<HmiEditorWidget>(window, "hmiEditorWidget");
  1479. editor->selectControl(jump_result.id);
  1480. QApplication::processEvents();
  1481. require(target_page->isVisible() && !binding_area->isVisible()
  1482. && target_page->currentData().toString()
  1483. == QString::fromStdString(settings_page_id),
  1484. "PageJump properties must show a target-page selector without register fields");
  1485. requiredChild<QAction>(window, "offlineModeAction")->trigger();
  1486. QApplication::processEvents();
  1487. require(mode_service.mode() == ApplicationMode::OfflineRunning
  1488. && navigation_service.currentPageId() == main_page_id,
  1489. "runtime HMI navigation must start on the persisted initial page");
  1490. HmiEditorWidget *runtime_hmi = requiredChild<HmiEditorWidget>(
  1491. window, "runtimeHmiView");
  1492. QGraphicsItem *jump_item = nullptr;
  1493. for (QGraphicsItem *item : runtime_hmi->scene()->items())
  1494. {
  1495. if (item->zValue() >= 0.0)
  1496. {
  1497. jump_item = item;
  1498. break;
  1499. }
  1500. }
  1501. require(jump_item != nullptr, "runtime HMI must render the PageJump control");
  1502. const QPoint jump_center = runtime_hmi->mapFromScene(
  1503. jump_item->mapToScene(jump_item->boundingRect().center()));
  1504. QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton,
  1505. Qt::NoModifier, jump_center);
  1506. QApplication::processEvents();
  1507. require(navigation_service.currentPageId() == settings_page_id
  1508. && requiredChild<QLabel>(window, "runtimePageLabel")->text()
  1509. == QStringLiteral("Settings"),
  1510. "clicking PageJump at runtime must navigate locally to its target page");
  1511. QComboBox *logic_selector = requiredChild<QComboBox>(
  1512. window, "runtimeLogicComboBox");
  1513. require(logic_selector->count() == 2,
  1514. "runtime logic selector must list all enabled logic modules");
  1515. logic_selector->setCurrentIndex(
  1516. logic_selector->findData(QString::fromStdString(second_logic_id)));
  1517. QApplication::processEvents();
  1518. auto *runtime_monitor = requiredChild<RuntimeMonitorWidget>(
  1519. window, "runtimeMonitorWidget");
  1520. require(runtime_monitor->selectedLogicId() == second_logic_id,
  1521. "runtime logic selection must change only the visible trace projection");
  1522. requiredChild<QAction>(window, "editingModeAction")->trigger();
  1523. }
  1524. } // namespace
  1525. int main(int argc, char *argv[])
  1526. {
  1527. // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
  1528. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  1529. QApplication application(argc, argv);
  1530. try
  1531. {
  1532. testRuntimeButtonMouseInteraction();
  1533. testRuntimeProgressBarRendering();
  1534. testRuntimePageJumpDoesNotRequireRegisterWritePermission();
  1535. testRuntimeAlarmListInteraction();
  1536. testWindowTitleTracksUnsavedProjectChanges();
  1537. testModeActionsControlEditingAvailability();
  1538. testEdgeTimerPropertyEditingAndParallelMenu();
  1539. testPlcConfigurationUsesDialog();
  1540. testRepeatedPlcStatusNotificationsAreCoalesced();
  1541. testFreeMonitorWritesSingleValues();
  1542. testOnlineWorkspaceShowsHmiAndFreeMonitorOnly();
  1543. testMultiPageAndLogicMainWindowIntegration();
  1544. }
  1545. catch (const std::exception &error)
  1546. {
  1547. std::cerr << "main window tests failed: " << error.what() << '\n';
  1548. return 1;
  1549. }
  1550. std::cout << "main window tests passed\n";
  1551. return 0;
  1552. }