综合平台编程器项目的远程存储
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

1071 satır
45 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. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
  393. alarm_service.refresh();
  394. require(alarm_service.records().size() == 1
  395. && alarm_service.records().front().active,
  396. "an active M alarm must be available to AlarmList");
  397. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  398. view.setPageId(page_id);
  399. view.setEditingEnabled(false);
  400. view.setRuntimeActive(true);
  401. view.setRuntimeWriteEnabled(false);
  402. view.refreshRuntimeValues();
  403. view.resize(900, 560);
  404. view.show();
  405. QApplication::processEvents();
  406. const QPoint header_position = view.mapFromScene(QPointF(
  407. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0));
  408. QTest::mouseClick(
  409. view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position);
  410. require(!alarm_service.records().front().acknowledged,
  411. "clicking the AlarmList header must not acknowledge an alarm");
  412. const QPoint first_row_position = view.mapFromScene(QPointF(
  413. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0));
  414. QTest::mouseClick(
  415. view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position);
  416. require(alarm_service.records().front().acknowledged,
  417. "clicking an active AlarmList row must acknowledge the alarm");
  418. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false);
  419. alarm_service.refresh();
  420. view.refreshRuntimeValues();
  421. require(!alarm_service.records().front().active
  422. && alarm_service.records().front().clearedAt.has_value(),
  423. "clearing the M condition must retain a cleared history record");
  424. }
  425. void testModeActionsControlEditingAvailability()
  426. {
  427. // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
  428. TestProjectStorage storage;
  429. ProjectService project_service(storage);
  430. HmiEditorService editor_service(project_service);
  431. LogicEditorService logic_editor_service(project_service);
  432. VirtualRegisterRepository repository;
  433. HmiRuntimeService runtime_service(repository);
  434. AlarmEditorService alarm_editor_service(project_service);
  435. AlarmService alarm_service(project_service, repository);
  436. OfflineSimulationService simulation_service(repository);
  437. RuntimeModeService mode_service(project_service, simulation_service);
  438. RegisterMonitorService monitor_service(repository);
  439. MainWindow window(
  440. mode_service,
  441. project_service,
  442. editor_service,
  443. logic_editor_service,
  444. runtime_service,
  445. alarm_editor_service,
  446. alarm_service,
  447. monitor_service);
  448. window.resize(1000, 640);
  449. window.show();
  450. QApplication::processEvents();
  451. require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
  452. "the removed data point dock must not remain in the main window");
  453. require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
  454. && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
  455. "HMI and ladder properties must use direct M/D address inputs");
  456. QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
  457. QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
  458. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  459. QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
  460. QAction *add_indicator_action = requiredChild<QAction>(window, "addIndicatorAction");
  461. QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
  462. QAction *add_normally_open_action = requiredChild<QAction>(
  463. window, "addNormallyOpenAction");
  464. QAction *add_normal_coil_action = requiredChild<QAction>(
  465. window, "addNormalCoilAction");
  466. QAction *parallel_insert_action = requiredChild<QAction>(
  467. window, "parallelInsertAction");
  468. QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
  469. QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
  470. QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
  471. QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
  472. QComboBox *button_operation = requiredChild<QComboBox>(
  473. window, "buttonOperationComboBox");
  474. QPushButton *apply_properties = requiredChild<QPushButton>(
  475. window, "applyPropertiesButton");
  476. HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
  477. window, "hmiEditorWidget");
  478. QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
  479. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  480. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  481. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  482. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  483. require(!runtime_tab->isVisible(),
  484. "runtime monitor workspace must be hidden while editing");
  485. const qreal compact_scale = hmi_editor->transform().m11();
  486. window.resize(1600, 900);
  487. QApplication::processEvents();
  488. require(hmi_editor->transform().m11() > compact_scale,
  489. "HMI page must refit when the window becomes larger");
  490. require(editing_action->isChecked(), "editing action must be selected initially");
  491. require(project_dock->isEnabled(), "project dock must be enabled while editing");
  492. require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
  493. add_button_action->trigger();
  494. const std::string page_id = editor_service.firstPageId();
  495. require(editor_service.findPage(page_id)->controls.size() == 1,
  496. "adding a control must update the HMI page model");
  497. require(selection->text() == QStringLiteral("button-1(未绑定)"),
  498. "an unbound added control must be marked in the property panel");
  499. require(text_edit->text() == QStringLiteral("按钮"),
  500. "property panel must show the control text");
  501. require(button_operation->currentText() == QStringLiteral("瞬时 ON"),
  502. "new HMI buttons must default to momentary ON");
  503. const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
  504. require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
  505. "an unbound HMI control must not reserve an address label area");
  506. button_operation->setCurrentIndex(
  507. button_operation->findData(static_cast<int>(HmiButtonOperation::Toggle)));
  508. apply_properties->click();
  509. require(editor_service.findControl(page_id, "button-1")->buttonOperation
  510. == HmiButtonOperation::Toggle,
  511. "the property panel must update the HMI button operation");
  512. HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
  513. bound_button.binding = RegisterAddress{RegisterArea::M, 0};
  514. require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
  515. "binding an HMI button to M0 must succeed");
  516. hmi_editor->reloadPage();
  517. hmi_editor->selectControl(bound_button.id);
  518. const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
  519. require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
  520. "a bound HMI control must include its address label above the control body");
  521. delete_control_action->trigger();
  522. require(editor_service.findPage(page_id)->controls.empty(),
  523. "deleting a selected control must update the HMI page model");
  524. add_indicator_action->trigger();
  525. HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1");
  526. runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3};
  527. require(editor_service.updateControl(
  528. page_id, runtime_indicator.id, runtime_indicator).succeeded,
  529. "a configured HMI control must be available for the runtime projection");
  530. hmi_editor->reloadPage();
  531. add_normally_open_action->trigger();
  532. parallel_insert_action->trigger();
  533. add_normal_coil_action->trigger();
  534. const ControlLogic *logic = logic_editor_service.findLogic(
  535. logic_editor_service.firstLogicId());
  536. require(logic != nullptr && logic->rungs.size() == 1,
  537. "logic actions must edit the default ladder rung");
  538. require(logic->rungs.front().condition.has_value()
  539. && logic->rungs.front().condition->kind
  540. == ConditionExpressionKind::Parallel
  541. && logic->rungs.front().condition->children.size() == 2U,
  542. "parallel branch action must create a parallel expression");
  543. require(logic->rungs.front().output.has_value(),
  544. "coil action must set the fixed ladder output");
  545. offline_action->trigger();
  546. require(mode_service.mode() == ApplicationMode::Editing,
  547. "unconfigured ladder nodes must block offline running");
  548. const LadderRung &rung = logic->rungs.front();
  549. std::vector<const LogicNode *> condition_nodes;
  550. collectConditionNodes(*rung.condition, &condition_nodes);
  551. for (const LogicNode *node : condition_nodes)
  552. {
  553. require(logic_editor_service.updateNodeConfig(
  554. logic_editor_service.firstLogicId(),
  555. node->id,
  556. ContactNodeConfig{
  557. RegisterAddress{RegisterArea::M, 0},
  558. ContactMode::NormallyOpen})
  559. .succeeded,
  560. "applying a contact configuration must complete the ladder node");
  561. }
  562. require(logic_editor_service.updateNodeConfig(
  563. logic_editor_service.firstLogicId(),
  564. rung.output->id,
  565. CoilNodeConfig{
  566. RegisterAddress{RegisterArea::M, 1},
  567. CoilMode::Normal})
  568. .succeeded,
  569. "applying a coil configuration must complete the ladder node");
  570. offline_action->trigger();
  571. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  572. "offline action must enter offline running");
  573. require(simulation_service.state() == SimulationState::Running,
  574. "offline action must start the actual simulation service");
  575. require(executor_status->text().contains(QStringLiteral("运行")),
  576. "executor status label must report the actual running state");
  577. require(!project_dock->isEnabled(),
  578. "project dock must be disabled while running");
  579. require(!properties_dock->isEnabled(),
  580. "properties dock must be disabled while running");
  581. require(!add_button_action->isEnabled(),
  582. "HMI add controls must be disabled while running");
  583. require(!add_normally_open_action->isEnabled(),
  584. "logic add nodes must be disabled while running");
  585. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  586. && runtime_logic->isVisible() && free_monitor->isVisible(),
  587. "offline running must show HMI, ladder trace and free monitor together");
  588. auto *runtime_hmi_view = qobject_cast<HmiEditorWidget *>(runtime_hmi);
  589. require(runtime_hmi_view != nullptr
  590. && runtime_hmi_view->scene()->items().size() == 2,
  591. "offline runtime HMI must project the configured page control");
  592. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  593. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  594. QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
  595. monitor_address->setText(QStringLiteral("M0"));
  596. monitor_add->click();
  597. repository.writeBit({RegisterArea::M, 0}, true);
  598. auto *monitor_widget = requiredChild<FreeMonitorWidget>(window, "freeMonitorWidget");
  599. monitor_widget->refreshValues(
  600. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  601. require(monitor_table->rowCount() == 1
  602. && monitor_table->item(0, 2)->text() == QStringLiteral("ON"),
  603. "offline free monitor must read the same virtual M/D repository as HMI");
  604. online_action->trigger();
  605. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  606. "running modes must not switch directly through the UI");
  607. require(offline_action->isChecked(),
  608. "failed mode changes must restore the active action");
  609. editing_action->trigger();
  610. require(mode_service.mode() == ApplicationMode::Editing,
  611. "editing action must return to editing");
  612. require(simulation_service.state() == SimulationState::Stopped,
  613. "editing action must stop the simulation service");
  614. require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
  615. "executor status label must report the actual stopped state");
  616. require(project_dock->isEnabled(),
  617. "project dock must be restored after returning to editing");
  618. require(properties_dock->isEnabled(),
  619. "properties dock must be restored after returning to editing");
  620. require(add_button_action->isEnabled(),
  621. "HMI add controls must be restored after returning to editing");
  622. require(add_normally_open_action->isEnabled(),
  623. "logic add nodes must be restored after returning to editing");
  624. require(!runtime_tab->isVisible(),
  625. "returning to editing must hide the runtime monitor workspace");
  626. online_action->trigger();
  627. require(mode_service.mode() == ApplicationMode::Editing,
  628. "online action must require an initial PLC read");
  629. require(editing_action->isChecked(),
  630. "rejected online running must restore the editing action");
  631. }
  632. void testPlcConfigurationUsesDialog()
  633. {
  634. PlcSerialConfiguration initial;
  635. initial.portName = "COM17";
  636. initial.serverAddress = 12;
  637. initial.baudRate = 38400;
  638. initial.dataBits = 7;
  639. initial.parity = 3;
  640. initial.stopBits = 2;
  641. initial.responseTimeoutMs = 2500;
  642. initial.retries = 4;
  643. initial.pollIntervalMs = 350;
  644. PlcConnectionDialog configuration_dialog(initial);
  645. const PlcSerialConfiguration actual = configuration_dialog.configuration();
  646. require(actual.portName == initial.portName,
  647. "PLC dialog must preserve a manually entered serial port");
  648. require(actual.serverAddress == initial.serverAddress
  649. && actual.baudRate == initial.baudRate
  650. && actual.dataBits == initial.dataBits
  651. && actual.parity == initial.parity
  652. && actual.stopBits == initial.stopBits,
  653. "PLC dialog must preserve Modbus RTU serial parameters");
  654. require(actual.responseTimeoutMs == initial.responseTimeoutMs
  655. && actual.retries == initial.retries
  656. && actual.pollIntervalMs == initial.pollIntervalMs,
  657. "PLC dialog must preserve communication timing parameters");
  658. TestProjectStorage storage;
  659. ProjectService project_service(storage);
  660. HmiEditorService editor_service(project_service);
  661. LogicEditorService logic_editor_service(project_service);
  662. VirtualRegisterRepository repository;
  663. HmiRuntimeService runtime_service(repository);
  664. AlarmEditorService alarm_editor_service(project_service);
  665. AlarmService alarm_service(project_service, repository);
  666. OfflineSimulationService simulation_service(repository);
  667. RuntimeModeService mode_service(project_service, simulation_service);
  668. RegisterMonitorService monitor_service(repository);
  669. MainWindow window(
  670. mode_service,
  671. project_service,
  672. editor_service,
  673. logic_editor_service,
  674. runtime_service,
  675. alarm_editor_service,
  676. alarm_service,
  677. monitor_service);
  678. require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
  679. "serial configuration controls must not be embedded in the main window");
  680. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  681. bool dialog_opened = false;
  682. QTimer::singleShot(
  683. 0,
  684. [&dialog_opened]
  685. {
  686. auto *dialog = qobject_cast<PlcConnectionDialog *>(
  687. QApplication::activeModalWidget());
  688. dialog_opened = dialog != nullptr;
  689. if (dialog != nullptr)
  690. {
  691. dialog->reject();
  692. }
  693. });
  694. configure_action->trigger();
  695. require(dialog_opened,
  696. "PLC configuration action must open the dedicated configuration dialog");
  697. }
  698. void testRepeatedPlcStatusNotificationsAreCoalesced()
  699. {
  700. TestProjectStorage storage;
  701. ProjectService project_service(storage);
  702. HmiEditorService editor_service(project_service);
  703. LogicEditorService logic_editor_service(project_service);
  704. VirtualRegisterRepository virtual_repository;
  705. VirtualRegisterRepository plc_repository;
  706. ActiveRegisterRepository active_repository(virtual_repository);
  707. HmiRuntimeService runtime_service(active_repository);
  708. AlarmEditorService alarm_editor_service(project_service);
  709. AlarmService alarm_service(project_service, active_repository);
  710. OfflineSimulationService simulation_service(virtual_repository);
  711. RepeatedStatusGateway gateway;
  712. RuntimeModeService mode_service(project_service, simulation_service);
  713. RegisterMonitorService monitor_service(active_repository);
  714. mode_service.configurePlc(
  715. gateway, active_repository, virtual_repository, plc_repository);
  716. MainWindow window(
  717. mode_service,
  718. project_service,
  719. editor_service,
  720. logic_editor_service,
  721. runtime_service,
  722. alarm_editor_service,
  723. alarm_service,
  724. monitor_service);
  725. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  726. const int initial_count = output->count();
  727. const PlcCommunicationResult result = mode_service.connectPlc(
  728. {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
  729. require(result.succeeded, "PLC connection setup must succeed");
  730. QApplication::processEvents();
  731. const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
  732. int matching_count = 0;
  733. for (int index = initial_count; index < output->count(); ++index)
  734. {
  735. if (output->item(index)->text() == expected)
  736. {
  737. ++matching_count;
  738. }
  739. }
  740. require(matching_count == 1,
  741. "repeated PLC status callbacks must append one coalesced output message");
  742. }
  743. void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly()
  744. {
  745. TestProjectStorage storage;
  746. ProjectService project_service(storage);
  747. HmiEditorService editor_service(project_service);
  748. LogicEditorService logic_editor_service(project_service);
  749. VirtualRegisterRepository virtual_repository;
  750. VirtualRegisterRepository plc_repository;
  751. ActiveRegisterRepository active_repository(virtual_repository);
  752. HmiRuntimeService runtime_service(active_repository);
  753. AlarmEditorService alarm_editor_service(project_service);
  754. AlarmService alarm_service(project_service, active_repository);
  755. OfflineSimulationService simulation_service(virtual_repository);
  756. OnlineReadyGateway gateway;
  757. RuntimeModeService mode_service(project_service, simulation_service);
  758. RegisterMonitorService monitor_service(active_repository);
  759. mode_service.configurePlc(
  760. gateway, active_repository, virtual_repository, plc_repository);
  761. MainWindow window(
  762. mode_service,
  763. project_service,
  764. editor_service,
  765. logic_editor_service,
  766. runtime_service,
  767. alarm_editor_service,
  768. alarm_service,
  769. monitor_service);
  770. window.resize(1200, 760);
  771. window.show();
  772. QApplication::processEvents();
  773. require(mode_service.connectPlc(
  774. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  775. "online workspace test must connect the fake PLC gateway");
  776. gateway.completeInitialRead();
  777. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  778. online_action->trigger();
  779. QApplication::processEvents();
  780. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  781. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  782. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  783. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  784. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  785. "completed PLC initial read must allow online running");
  786. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  787. && free_monitor->isVisible(),
  788. "online running must show HMI and free monitor together");
  789. require(!runtime_logic->isVisible(),
  790. "online running must not show a misleading local ladder runtime trace");
  791. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  792. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  793. monitor_address->setText(QStringLiteral("D9"));
  794. monitor_add->click();
  795. require(std::find(
  796. gateway.poll_addresses.begin(),
  797. gateway.poll_addresses.end(),
  798. RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(),
  799. "online free monitor addresses must join the active PLC poll set immediately");
  800. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  801. QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction");
  802. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  803. gateway.timeoutCommunication(
  804. "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线");
  805. QApplication::processEvents();
  806. require(mode_service.mode() == ApplicationMode::Editing,
  807. "a PLC timeout must return online running to editing");
  808. require(disconnect_action->isEnabled(),
  809. "a timeout must keep disconnect available while the local port remains open");
  810. require(configure_action->isEnabled()
  811. && configure_action->text() == QStringLiteral("PLC 重新配置"),
  812. "a timeout must keep PLC reconfiguration available");
  813. gateway.recoverCommunication();
  814. QApplication::processEvents();
  815. require(mode_service.mode() == ApplicationMode::Editing,
  816. "automatic communication recovery must remain in editing mode");
  817. require(output->count() > 0
  818. && output->item(output->count() - 1)->text().contains(
  819. QStringLiteral("通信已恢复")),
  820. "automatic recovery must be retained in the output log");
  821. online_action->trigger();
  822. require(mode_service.mode() == ApplicationMode::Editing,
  823. "recovered communication must complete a fresh initial read before online mode");
  824. gateway.completeInitialRead();
  825. QApplication::processEvents();
  826. online_action->trigger();
  827. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  828. "users must be able to re-enter online mode after the recovered initial read");
  829. gateway.loseSerialConnection(
  830. "PLC 串口 COM9 连接已中断;请检查 USB 转串口是否被拔出或已经失效");
  831. QApplication::processEvents();
  832. require(mode_service.mode() == ApplicationMode::Editing,
  833. "a communication fault must return the UI from online running to editing");
  834. require(configure_action->isEnabled()
  835. && configure_action->text() == QStringLiteral("PLC 配置"),
  836. "a lost serial connection must expose direct configuration");
  837. require(!disconnect_action->isEnabled(),
  838. "a lost serial connection must disable redundant disconnect actions");
  839. require(output->count() > 0
  840. && output->item(output->count() - 1)->text().contains(
  841. QStringLiteral("连接已中断")),
  842. "communication fault details must remain in the output log");
  843. online_action->trigger();
  844. require(mode_service.mode() == ApplicationMode::Editing,
  845. "faulted PLC state must not re-enter online running without a fresh read");
  846. }
  847. void testMultiPageAndLogicMainWindowIntegration()
  848. {
  849. TestProjectStorage storage;
  850. ProjectService project_service(storage);
  851. HmiEditorService editor_service(project_service);
  852. LogicEditorService logic_editor_service(project_service);
  853. const std::string main_page_id = editor_service.ensureDefaultPage().id;
  854. const std::string settings_page_id = editor_service.addPage("Settings").id;
  855. const std::string first_logic_id = logic_editor_service.ensureDefaultLogic().id;
  856. const std::string second_logic_id =
  857. logic_editor_service.addLogic("Safety logic").id;
  858. const HmiEditorResult jump_result = editor_service.addControl(
  859. main_page_id, HmiControlType::PageJump);
  860. HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id);
  861. jump.pageJump = HmiPageJumpConfig{settings_page_id};
  862. require(editor_service.updateControl(
  863. main_page_id, jump_result.id, jump).succeeded,
  864. "the integration fixture must configure PageJump");
  865. VirtualRegisterRepository repository;
  866. HmiRuntimeService runtime_service(repository);
  867. AlarmEditorService alarm_editor_service(project_service);
  868. AlarmService alarm_service(project_service, repository);
  869. OfflineSimulationService simulation_service(repository);
  870. RuntimeModeService mode_service(project_service, simulation_service);
  871. RegisterMonitorService monitor_service(repository);
  872. HmiNavigationService navigation_service(project_service);
  873. MainWindow window(
  874. mode_service,
  875. project_service,
  876. editor_service,
  877. logic_editor_service,
  878. runtime_service,
  879. alarm_editor_service,
  880. alarm_service,
  881. navigation_service,
  882. monitor_service);
  883. window.resize(1400, 820);
  884. window.show();
  885. QApplication::processEvents();
  886. QTreeWidget *tree = requiredChild<QTreeWidget>(window, "projectTree");
  887. require(tree->topLevelItemCount() == 2
  888. && tree->topLevelItem(0)->childCount() == 2
  889. && tree->topLevelItem(1)->childCount() == 2,
  890. "the project tree must list every page and logic module");
  891. tree->setCurrentItem(tree->topLevelItem(0)->child(1));
  892. QApplication::processEvents();
  893. require(window.currentHmiPageId() == settings_page_id,
  894. "selecting a page tree node must change the current HMI page id");
  895. QAction *add_label = requiredChild<QAction>(window, "addLabelAction");
  896. add_label->trigger();
  897. require(editor_service.findPage(settings_page_id)->controls.size() == 1U
  898. && editor_service.findPage(main_page_id)->controls.size() == 1U,
  899. "HMI toolbar actions must edit the selected page rather than the first page");
  900. QComboBox *binding_area = requiredChild<QComboBox>(window, "bindingAreaComboBox");
  901. QComboBox *target_page = requiredChild<QComboBox>(window, "targetPageComboBox");
  902. require(!binding_area->isVisible() && !target_page->isVisible(),
  903. "Label properties must hide both register binding and PageJump target fields");
  904. tree->setCurrentItem(tree->topLevelItem(1)->child(1));
  905. QApplication::processEvents();
  906. require(window.currentLogicId() == second_logic_id,
  907. "selecting a logic tree node must change the current logic id");
  908. requiredChild<QAction>(window, "addRungAction")->trigger();
  909. require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 2U
  910. && logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U,
  911. "logic toolbar actions must edit the selected logic module");
  912. tree->setCurrentItem(tree->topLevelItem(0)->child(0));
  913. QApplication::processEvents();
  914. HmiEditorWidget *editor = requiredChild<HmiEditorWidget>(window, "hmiEditorWidget");
  915. editor->selectControl(jump_result.id);
  916. QApplication::processEvents();
  917. require(target_page->isVisible() && !binding_area->isVisible()
  918. && target_page->currentData().toString()
  919. == QString::fromStdString(settings_page_id),
  920. "PageJump properties must show a target-page selector without register fields");
  921. requiredChild<QAction>(window, "offlineModeAction")->trigger();
  922. QApplication::processEvents();
  923. require(mode_service.mode() == ApplicationMode::OfflineRunning
  924. && navigation_service.currentPageId() == main_page_id,
  925. "runtime HMI navigation must start on the persisted initial page");
  926. HmiEditorWidget *runtime_hmi = requiredChild<HmiEditorWidget>(
  927. window, "runtimeHmiView");
  928. QGraphicsItem *jump_item = nullptr;
  929. for (QGraphicsItem *item : runtime_hmi->scene()->items())
  930. {
  931. if (item->zValue() >= 0.0)
  932. {
  933. jump_item = item;
  934. break;
  935. }
  936. }
  937. require(jump_item != nullptr, "runtime HMI must render the PageJump control");
  938. const QPoint jump_center = runtime_hmi->mapFromScene(
  939. jump_item->mapToScene(jump_item->boundingRect().center()));
  940. QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton,
  941. Qt::NoModifier, jump_center);
  942. QApplication::processEvents();
  943. require(navigation_service.currentPageId() == settings_page_id
  944. && requiredChild<QLabel>(window, "runtimePageLabel")->text()
  945. == QStringLiteral("Settings"),
  946. "clicking PageJump at runtime must navigate locally to its target page");
  947. QComboBox *logic_selector = requiredChild<QComboBox>(
  948. window, "runtimeLogicComboBox");
  949. require(logic_selector->count() == 2,
  950. "runtime logic selector must list all enabled logic modules");
  951. logic_selector->setCurrentIndex(
  952. logic_selector->findData(QString::fromStdString(second_logic_id)));
  953. QApplication::processEvents();
  954. auto *runtime_monitor = requiredChild<RuntimeMonitorWidget>(
  955. window, "runtimeMonitorWidget");
  956. require(runtime_monitor->selectedLogicId() == second_logic_id,
  957. "runtime logic selection must change only the visible trace projection");
  958. requiredChild<QAction>(window, "editingModeAction")->trigger();
  959. }
  960. } // namespace
  961. int main(int argc, char *argv[])
  962. {
  963. // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
  964. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  965. QApplication application(argc, argv);
  966. try
  967. {
  968. testRuntimeButtonMouseInteraction();
  969. testRuntimePageJumpDoesNotRequireRegisterWritePermission();
  970. testRuntimeAlarmListInteraction();
  971. testModeActionsControlEditingAvailability();
  972. testPlcConfigurationUsesDialog();
  973. testRepeatedPlcStatusNotificationsAreCoalesced();
  974. testOnlineWorkspaceShowsHmiAndFreeMonitorOnly();
  975. testMultiPageAndLogicMainWindowIntegration();
  976. }
  977. catch (const std::exception &error)
  978. {
  979. std::cerr << "main window tests failed: " << error.what() << '\n';
  980. return 1;
  981. }
  982. std::cout << "main window tests passed\n";
  983. return 0;
  984. }