综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

674 line
29 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/logic_editor_service.h"
  7. #include "services/offline_simulation_service.h"
  8. #include "services/project_service.h"
  9. #include "services/runtime_mode_service.h"
  10. #include "services/register_monitor_service.h"
  11. #include "ui/hmi_editor_widget.h"
  12. #include "ui/free_monitor_widget.h"
  13. #include "ui/logic_editor_widget.h"
  14. #include "ui/main_window.h"
  15. #include "ui/plc_connection_dialog.h"
  16. #include <QAction>
  17. #include <QApplication>
  18. #include <QComboBox>
  19. #include <QDialog>
  20. #include <QDockWidget>
  21. #include <QGraphicsItem>
  22. #include <QGraphicsScene>
  23. #include <QImage>
  24. #include <QLabel>
  25. #include <QLineEdit>
  26. #include <QListWidget>
  27. #include <QPainter>
  28. #include <QSpinBox>
  29. #include <QPushButton>
  30. #include <QTableWidget>
  31. #include <QTimer>
  32. #include <QTest>
  33. #include <iostream>
  34. #include <stdexcept>
  35. #include <string>
  36. namespace {
  37. class TestProjectStorage final : public ProjectStorage
  38. {
  39. public:
  40. // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
  41. ProjectSaveResult save(const Project &, const std::string &) override
  42. {
  43. return {true, ProjectStorageError::None, {}};
  44. }
  45. ProjectLoadResult load(const std::string &) override
  46. {
  47. return {false, {}, ProjectStorageError::FileReadFailed, {}};
  48. }
  49. };
  50. class RepeatedStatusGateway final : public PlcCommunicationGateway
  51. {
  52. public:
  53. PlcCommunicationResult connectDevice(
  54. const PlcSerialConfiguration &) override
  55. {
  56. connection_state = PlcConnectionState::Connecting;
  57. notifyStateChanged();
  58. connection_state = PlcConnectionState::Connected;
  59. notifyStateChanged();
  60. notifyStateChanged();
  61. return {true, {}};
  62. }
  63. void disconnectDevice() override
  64. {
  65. connection_state = PlcConnectionState::Disconnected;
  66. notifyStateChanged();
  67. }
  68. void setPollAddresses(const std::vector<RegisterAddress> &) override {}
  69. PlcConnectionState state() const override { return connection_state; }
  70. bool initialReadCompleted() const override { return false; }
  71. const std::string &lastError() const override { return last_error; }
  72. void setCallbacks(
  73. std::function<void()> state_callback,
  74. std::function<void(bool)> initial_callback,
  75. std::function<void()> cache_callback,
  76. std::function<void(const std::string &)> error_callback) override
  77. {
  78. state_changed = std::move(state_callback);
  79. initial_read_changed = std::move(initial_callback);
  80. cache_updated = std::move(cache_callback);
  81. error_reported = std::move(error_callback);
  82. }
  83. private:
  84. void notifyStateChanged()
  85. {
  86. if (state_changed)
  87. {
  88. state_changed();
  89. }
  90. }
  91. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  92. std::string last_error;
  93. std::function<void()> state_changed;
  94. std::function<void(bool)> initial_read_changed;
  95. std::function<void()> cache_updated;
  96. std::function<void(const std::string &)> error_reported;
  97. };
  98. class OnlineReadyGateway final : public PlcCommunicationGateway
  99. {
  100. public:
  101. PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override
  102. {
  103. connection_state = PlcConnectionState::Connected;
  104. if (state_changed)
  105. {
  106. state_changed();
  107. }
  108. return {true, {}};
  109. }
  110. void disconnectDevice() override
  111. {
  112. connection_state = PlcConnectionState::Disconnected;
  113. }
  114. void setPollAddresses(const std::vector<RegisterAddress> &addresses) override
  115. {
  116. poll_addresses = addresses;
  117. }
  118. PlcConnectionState state() const override { return connection_state; }
  119. bool initialReadCompleted() const override { return initial_read; }
  120. const std::string &lastError() const override { return last_error; }
  121. void setCallbacks(
  122. std::function<void()> state_callback,
  123. std::function<void(bool)> initial_callback,
  124. std::function<void()> cache_callback,
  125. std::function<void(const std::string &)> error_callback) override
  126. {
  127. state_changed = std::move(state_callback);
  128. initial_read_changed = std::move(initial_callback);
  129. cache_updated = std::move(cache_callback);
  130. error_reported = std::move(error_callback);
  131. }
  132. void completeInitialRead()
  133. {
  134. initial_read = true;
  135. if (initial_read_changed)
  136. {
  137. initial_read_changed(true);
  138. }
  139. }
  140. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  141. bool initial_read = false;
  142. std::string last_error;
  143. std::vector<RegisterAddress> poll_addresses;
  144. std::function<void()> state_changed;
  145. std::function<void(bool)> initial_read_changed;
  146. std::function<void()> cache_updated;
  147. std::function<void(const std::string &)> error_reported;
  148. };
  149. void require(bool condition, const std::string &message)
  150. {
  151. if (!condition)
  152. {
  153. throw std::runtime_error(message);
  154. }
  155. }
  156. template<typename ObjectType>
  157. ObjectType *requiredChild(MainWindow &window, const char *name)
  158. {
  159. // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
  160. ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
  161. require(child != nullptr, std::string("missing UI object ") + name);
  162. return child;
  163. }
  164. QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position)
  165. {
  166. QImage image(view.viewport()->size(), QImage::Format_ARGB32_Premultiplied);
  167. image.fill(Qt::transparent);
  168. QPainter painter(&image);
  169. view.viewport()->render(&painter);
  170. return image.pixelColor(viewport_position);
  171. }
  172. void testRuntimeButtonMouseInteraction()
  173. {
  174. TestProjectStorage storage;
  175. ProjectService project_service(storage);
  176. HmiEditorService editor_service(project_service);
  177. VirtualRegisterRepository repository;
  178. HmiRuntimeService runtime_service(repository);
  179. const HmiEditorResult page_result = editor_service.ensureDefaultPage();
  180. require(page_result.succeeded, "runtime button test must create an HMI page");
  181. const HmiEditorResult button_result = editor_service.addControl(
  182. page_result.id, HmiControlType::Button);
  183. require(button_result.succeeded, "runtime button test must create a button");
  184. HmiControl button = *editor_service.findControl(page_result.id, button_result.id);
  185. button.binding = RegisterAddress{RegisterArea::M, 0};
  186. button.buttonOperation = HmiButtonOperation::MomentaryOn;
  187. require(editor_service.updateControl(page_result.id, button.id, button).succeeded,
  188. "runtime button test must bind the button to M0");
  189. HmiEditorWidget view(editor_service, runtime_service);
  190. view.resize(900, 600);
  191. view.setPageId(page_result.id);
  192. view.setEditingEnabled(false);
  193. view.setRuntimeActive(true);
  194. view.setRuntimeWriteEnabled(true);
  195. view.show();
  196. QApplication::processEvents();
  197. const QPoint center = view.mapFromScene(QPointF(
  198. button.bounds.x + button.bounds.width / 2.0,
  199. button.bounds.y + button.bounds.height / 2.0));
  200. const QPoint color_sample = view.mapFromScene(QPointF(
  201. button.bounds.x + 8.0, button.bounds.y + 8.0));
  202. const QColor normal_color = renderedColorAt(view, color_sample);
  203. QTest::mouseMove(view.viewport(), center);
  204. QApplication::processEvents();
  205. QGraphicsItem *button_item = view.itemAt(center);
  206. require(button_item != nullptr,
  207. "runtime button must be hit-testable while writes are enabled");
  208. require(button_item->cursor().shape() == Qt::PointingHandCursor,
  209. "runtime button must use a pointing-hand cursor");
  210. const QColor hovered_color = renderedColorAt(view, color_sample);
  211. require(hovered_color != normal_color,
  212. "hovering a runtime button must change its visual state");
  213. QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  214. QApplication::processEvents();
  215. require(repository.readBit({RegisterArea::M, 0}).value,
  216. "pressing a momentary runtime button must write M0 ON");
  217. require(view.scene()->selectedItems().isEmpty(),
  218. "pressing a runtime button must not show an editing selection");
  219. const QColor pressed_color = renderedColorAt(view, color_sample);
  220. require(pressed_color != hovered_color,
  221. "pressing a runtime button must change its visual state");
  222. QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  223. QApplication::processEvents();
  224. require(!repository.readBit({RegisterArea::M, 0}).value,
  225. "releasing a momentary runtime button must write M0 OFF");
  226. require(renderedColorAt(view, color_sample) == hovered_color,
  227. "releasing a runtime button must restore its hovered visual state");
  228. view.setRuntimeWriteEnabled(false);
  229. QApplication::processEvents();
  230. const QColor disabled_color = renderedColorAt(view, color_sample);
  231. require(disabled_color != hovered_color,
  232. "a non-writable runtime button must use its disabled visual state");
  233. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  234. require(!repository.readBit({RegisterArea::M, 0}).value,
  235. "a disabled runtime button must not write its register");
  236. }
  237. void testModeActionsControlEditingAvailability()
  238. {
  239. // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
  240. TestProjectStorage storage;
  241. ProjectService project_service(storage);
  242. HmiEditorService editor_service(project_service);
  243. LogicEditorService logic_editor_service(project_service);
  244. VirtualRegisterRepository repository;
  245. HmiRuntimeService runtime_service(repository);
  246. OfflineSimulationService simulation_service(repository);
  247. RuntimeModeService mode_service(project_service, simulation_service);
  248. RegisterMonitorService monitor_service(repository);
  249. MainWindow window(
  250. mode_service,
  251. project_service,
  252. editor_service,
  253. logic_editor_service,
  254. runtime_service,
  255. monitor_service);
  256. window.resize(1000, 640);
  257. window.show();
  258. QApplication::processEvents();
  259. require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
  260. "the removed data point dock must not remain in the main window");
  261. require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
  262. && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
  263. "HMI and ladder properties must use direct M/D address inputs");
  264. QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
  265. QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
  266. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  267. QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
  268. QAction *add_indicator_action = requiredChild<QAction>(window, "addIndicatorAction");
  269. QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
  270. QAction *add_normally_open_action = requiredChild<QAction>(
  271. window, "addNormallyOpenAction");
  272. QAction *add_normal_coil_action = requiredChild<QAction>(
  273. window, "addNormalCoilAction");
  274. QAction *parallel_insert_action = requiredChild<QAction>(
  275. window, "parallelInsertAction");
  276. QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
  277. QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
  278. QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
  279. QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
  280. QComboBox *button_operation = requiredChild<QComboBox>(
  281. window, "buttonOperationComboBox");
  282. QPushButton *apply_properties = requiredChild<QPushButton>(
  283. window, "applyPropertiesButton");
  284. HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
  285. window, "hmiEditorWidget");
  286. QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
  287. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  288. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  289. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  290. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  291. require(!runtime_tab->isVisible(),
  292. "runtime monitor workspace must be hidden while editing");
  293. const qreal compact_scale = hmi_editor->transform().m11();
  294. window.resize(1600, 900);
  295. QApplication::processEvents();
  296. require(hmi_editor->transform().m11() > compact_scale,
  297. "HMI page must refit when the window becomes larger");
  298. require(editing_action->isChecked(), "editing action must be selected initially");
  299. require(project_dock->isEnabled(), "project dock must be enabled while editing");
  300. require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
  301. add_button_action->trigger();
  302. const std::string page_id = editor_service.firstPageId();
  303. require(editor_service.findPage(page_id)->controls.size() == 1,
  304. "adding a control must update the HMI page model");
  305. require(selection->text() == QStringLiteral("button-1(未绑定)"),
  306. "an unbound added control must be marked in the property panel");
  307. require(text_edit->text() == QStringLiteral("按钮"),
  308. "property panel must show the control text");
  309. require(button_operation->currentText() == QStringLiteral("瞬时 ON"),
  310. "new HMI buttons must default to momentary ON");
  311. const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
  312. require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
  313. "an unbound HMI control must not reserve an address label area");
  314. button_operation->setCurrentIndex(
  315. button_operation->findData(static_cast<int>(HmiButtonOperation::Toggle)));
  316. apply_properties->click();
  317. require(editor_service.findControl(page_id, "button-1")->buttonOperation
  318. == HmiButtonOperation::Toggle,
  319. "the property panel must update the HMI button operation");
  320. HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
  321. bound_button.binding = RegisterAddress{RegisterArea::M, 0};
  322. require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
  323. "binding an HMI button to M0 must succeed");
  324. hmi_editor->reloadPage();
  325. hmi_editor->selectControl(bound_button.id);
  326. const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
  327. require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
  328. "a bound HMI control must include its address label above the control body");
  329. delete_control_action->trigger();
  330. require(editor_service.findPage(page_id)->controls.empty(),
  331. "deleting a selected control must update the HMI page model");
  332. add_indicator_action->trigger();
  333. HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1");
  334. runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3};
  335. require(editor_service.updateControl(
  336. page_id, runtime_indicator.id, runtime_indicator).succeeded,
  337. "a configured HMI control must be available for the runtime projection");
  338. hmi_editor->reloadPage();
  339. add_normally_open_action->trigger();
  340. parallel_insert_action->trigger();
  341. add_normal_coil_action->trigger();
  342. const ControlLogic *logic = logic_editor_service.findLogic(
  343. logic_editor_service.firstLogicId());
  344. require(logic != nullptr && logic->rungs.size() == 1,
  345. "logic actions must edit the default ladder rung");
  346. require(logic->rungs.front().condition.has_value()
  347. && logic->rungs.front().condition->kind
  348. == ConditionExpressionKind::Parallel
  349. && logic->rungs.front().condition->children.size() == 2U,
  350. "parallel branch action must create a parallel expression");
  351. require(logic->rungs.front().output.has_value(),
  352. "coil action must set the fixed ladder output");
  353. offline_action->trigger();
  354. require(mode_service.mode() == ApplicationMode::Editing,
  355. "unconfigured ladder nodes must block offline running");
  356. const LadderRung &rung = logic->rungs.front();
  357. std::vector<const LogicNode *> condition_nodes;
  358. collectConditionNodes(*rung.condition, &condition_nodes);
  359. for (const LogicNode *node : condition_nodes)
  360. {
  361. require(logic_editor_service.updateNodeConfig(
  362. logic_editor_service.firstLogicId(),
  363. node->id,
  364. ContactNodeConfig{
  365. RegisterAddress{RegisterArea::M, 0},
  366. ContactMode::NormallyOpen})
  367. .succeeded,
  368. "applying a contact configuration must complete the ladder node");
  369. }
  370. require(logic_editor_service.updateNodeConfig(
  371. logic_editor_service.firstLogicId(),
  372. rung.output->id,
  373. CoilNodeConfig{
  374. RegisterAddress{RegisterArea::M, 1},
  375. CoilMode::Normal})
  376. .succeeded,
  377. "applying a coil configuration must complete the ladder node");
  378. offline_action->trigger();
  379. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  380. "offline action must enter offline running");
  381. require(simulation_service.state() == SimulationState::Running,
  382. "offline action must start the actual simulation service");
  383. require(executor_status->text().contains(QStringLiteral("运行")),
  384. "executor status label must report the actual running state");
  385. require(!project_dock->isEnabled(),
  386. "project dock must be disabled while running");
  387. require(!properties_dock->isEnabled(),
  388. "properties dock must be disabled while running");
  389. require(!add_button_action->isEnabled(),
  390. "HMI add controls must be disabled while running");
  391. require(!add_normally_open_action->isEnabled(),
  392. "logic add nodes must be disabled while running");
  393. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  394. && runtime_logic->isVisible() && free_monitor->isVisible(),
  395. "offline running must show HMI, ladder trace and free monitor together");
  396. auto *runtime_hmi_view = qobject_cast<HmiEditorWidget *>(runtime_hmi);
  397. require(runtime_hmi_view != nullptr
  398. && runtime_hmi_view->scene()->items().size() == 2,
  399. "offline runtime HMI must project the configured page control");
  400. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  401. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  402. QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
  403. monitor_address->setText(QStringLiteral("M0"));
  404. monitor_add->click();
  405. repository.writeBit({RegisterArea::M, 0}, true);
  406. auto *monitor_widget = requiredChild<FreeMonitorWidget>(window, "freeMonitorWidget");
  407. monitor_widget->refreshValues(
  408. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  409. require(monitor_table->rowCount() == 1
  410. && monitor_table->item(0, 2)->text() == QStringLiteral("ON"),
  411. "offline free monitor must read the same virtual M/D repository as HMI");
  412. online_action->trigger();
  413. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  414. "running modes must not switch directly through the UI");
  415. require(offline_action->isChecked(),
  416. "failed mode changes must restore the active action");
  417. editing_action->trigger();
  418. require(mode_service.mode() == ApplicationMode::Editing,
  419. "editing action must return to editing");
  420. require(simulation_service.state() == SimulationState::Stopped,
  421. "editing action must stop the simulation service");
  422. require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
  423. "executor status label must report the actual stopped state");
  424. require(project_dock->isEnabled(),
  425. "project dock must be restored after returning to editing");
  426. require(properties_dock->isEnabled(),
  427. "properties dock must be restored after returning to editing");
  428. require(add_button_action->isEnabled(),
  429. "HMI add controls must be restored after returning to editing");
  430. require(add_normally_open_action->isEnabled(),
  431. "logic add nodes must be restored after returning to editing");
  432. require(!runtime_tab->isVisible(),
  433. "returning to editing must hide the runtime monitor workspace");
  434. online_action->trigger();
  435. require(mode_service.mode() == ApplicationMode::Editing,
  436. "online action must require an initial PLC read");
  437. require(editing_action->isChecked(),
  438. "rejected online running must restore the editing action");
  439. }
  440. void testPlcConfigurationUsesDialog()
  441. {
  442. PlcSerialConfiguration initial;
  443. initial.portName = "COM17";
  444. initial.serverAddress = 12;
  445. initial.baudRate = 38400;
  446. initial.dataBits = 7;
  447. initial.parity = 3;
  448. initial.stopBits = 2;
  449. initial.responseTimeoutMs = 2500;
  450. initial.retries = 4;
  451. initial.pollIntervalMs = 350;
  452. PlcConnectionDialog configuration_dialog(initial);
  453. const PlcSerialConfiguration actual = configuration_dialog.configuration();
  454. require(actual.portName == initial.portName,
  455. "PLC dialog must preserve a manually entered serial port");
  456. require(actual.serverAddress == initial.serverAddress
  457. && actual.baudRate == initial.baudRate
  458. && actual.dataBits == initial.dataBits
  459. && actual.parity == initial.parity
  460. && actual.stopBits == initial.stopBits,
  461. "PLC dialog must preserve Modbus RTU serial parameters");
  462. require(actual.responseTimeoutMs == initial.responseTimeoutMs
  463. && actual.retries == initial.retries
  464. && actual.pollIntervalMs == initial.pollIntervalMs,
  465. "PLC dialog must preserve communication timing parameters");
  466. TestProjectStorage storage;
  467. ProjectService project_service(storage);
  468. HmiEditorService editor_service(project_service);
  469. LogicEditorService logic_editor_service(project_service);
  470. VirtualRegisterRepository repository;
  471. HmiRuntimeService runtime_service(repository);
  472. OfflineSimulationService simulation_service(repository);
  473. RuntimeModeService mode_service(project_service, simulation_service);
  474. RegisterMonitorService monitor_service(repository);
  475. MainWindow window(
  476. mode_service,
  477. project_service,
  478. editor_service,
  479. logic_editor_service,
  480. runtime_service,
  481. monitor_service);
  482. require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
  483. "serial configuration controls must not be embedded in the main window");
  484. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  485. bool dialog_opened = false;
  486. QTimer::singleShot(
  487. 0,
  488. [&dialog_opened]
  489. {
  490. auto *dialog = qobject_cast<PlcConnectionDialog *>(
  491. QApplication::activeModalWidget());
  492. dialog_opened = dialog != nullptr;
  493. if (dialog != nullptr)
  494. {
  495. dialog->reject();
  496. }
  497. });
  498. configure_action->trigger();
  499. require(dialog_opened,
  500. "PLC configuration action must open the dedicated configuration dialog");
  501. }
  502. void testRepeatedPlcStatusNotificationsAreCoalesced()
  503. {
  504. TestProjectStorage storage;
  505. ProjectService project_service(storage);
  506. HmiEditorService editor_service(project_service);
  507. LogicEditorService logic_editor_service(project_service);
  508. VirtualRegisterRepository virtual_repository;
  509. VirtualRegisterRepository plc_repository;
  510. ActiveRegisterRepository active_repository(virtual_repository);
  511. HmiRuntimeService runtime_service(active_repository);
  512. OfflineSimulationService simulation_service(virtual_repository);
  513. RepeatedStatusGateway gateway;
  514. RuntimeModeService mode_service(project_service, simulation_service);
  515. RegisterMonitorService monitor_service(active_repository);
  516. mode_service.configurePlc(
  517. gateway, active_repository, virtual_repository, plc_repository);
  518. MainWindow window(
  519. mode_service,
  520. project_service,
  521. editor_service,
  522. logic_editor_service,
  523. runtime_service,
  524. monitor_service);
  525. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  526. const int initial_count = output->count();
  527. const PlcCommunicationResult result = mode_service.connectPlc(
  528. {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
  529. require(result.succeeded, "PLC connection setup must succeed");
  530. QApplication::processEvents();
  531. const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
  532. int matching_count = 0;
  533. for (int index = initial_count; index < output->count(); ++index)
  534. {
  535. if (output->item(index)->text() == expected)
  536. {
  537. ++matching_count;
  538. }
  539. }
  540. require(matching_count == 1,
  541. "repeated PLC status callbacks must append one coalesced output message");
  542. }
  543. void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly()
  544. {
  545. TestProjectStorage storage;
  546. ProjectService project_service(storage);
  547. HmiEditorService editor_service(project_service);
  548. LogicEditorService logic_editor_service(project_service);
  549. VirtualRegisterRepository virtual_repository;
  550. VirtualRegisterRepository plc_repository;
  551. ActiveRegisterRepository active_repository(virtual_repository);
  552. HmiRuntimeService runtime_service(active_repository);
  553. OfflineSimulationService simulation_service(virtual_repository);
  554. OnlineReadyGateway gateway;
  555. RuntimeModeService mode_service(project_service, simulation_service);
  556. RegisterMonitorService monitor_service(active_repository);
  557. mode_service.configurePlc(
  558. gateway, active_repository, virtual_repository, plc_repository);
  559. MainWindow window(
  560. mode_service,
  561. project_service,
  562. editor_service,
  563. logic_editor_service,
  564. runtime_service,
  565. monitor_service);
  566. window.resize(1200, 760);
  567. window.show();
  568. QApplication::processEvents();
  569. require(mode_service.connectPlc(
  570. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  571. "online workspace test must connect the fake PLC gateway");
  572. gateway.completeInitialRead();
  573. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  574. online_action->trigger();
  575. QApplication::processEvents();
  576. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  577. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  578. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  579. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  580. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  581. "completed PLC initial read must allow online running");
  582. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  583. && free_monitor->isVisible(),
  584. "online running must show HMI and free monitor together");
  585. require(!runtime_logic->isVisible(),
  586. "online running must not show a misleading local ladder runtime trace");
  587. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  588. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  589. monitor_address->setText(QStringLiteral("D9"));
  590. monitor_add->click();
  591. require(std::find(
  592. gateway.poll_addresses.begin(),
  593. gateway.poll_addresses.end(),
  594. RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(),
  595. "online free monitor addresses must join the active PLC poll set immediately");
  596. }
  597. } // namespace
  598. int main(int argc, char *argv[])
  599. {
  600. // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
  601. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  602. QApplication application(argc, argv);
  603. try
  604. {
  605. testRuntimeButtonMouseInteraction();
  606. testModeActionsControlEditingAvailability();
  607. testPlcConfigurationUsesDialog();
  608. testRepeatedPlcStatusNotificationsAreCoalesced();
  609. testOnlineWorkspaceShowsHmiAndFreeMonitorOnly();
  610. }
  611. catch (const std::exception &error)
  612. {
  613. std::cerr << "main window tests failed: " << error.what() << '\n';
  614. return 1;
  615. }
  616. std::cout << "main window tests passed\n";
  617. return 0;
  618. }