综合平台编程器项目的远程存储
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 

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