综合平台编程器项目的远程存储
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

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