综合平台编程器项目的远程存储
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

978 рядки
41 KiB

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