综合平台编程器项目的远程存储
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

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