综合平台编程器项目的远程存储
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.
 
 
 
 

1614 lines
71 KiB

  1. #include "domain/active_register_repository.h"
  2. #include "domain/project_storage.h"
  3. #include "domain/register_repository.h"
  4. #include "services/alarm_editor_service.h"
  5. #include "services/alarm_service.h"
  6. #include "services/hmi_editor_service.h"
  7. #include "services/hmi_runtime_service.h"
  8. #include "services/hmi_navigation_service.h"
  9. #include "services/logic_editor_service.h"
  10. #include "services/offline_simulation_service.h"
  11. #include "services/project_service.h"
  12. #include "services/runtime_mode_service.h"
  13. #include "services/register_monitor_service.h"
  14. #include "services/register_comment_service.h"
  15. #include "ui/hmi_editor_widget.h"
  16. #include "ui/free_monitor_widget.h"
  17. #include "ui/logic_editor_widget.h"
  18. #include "ui/main_window.h"
  19. #include "ui/plc_connection_dialog.h"
  20. #include "ui/runtime_monitor_widget.h"
  21. #include <QAction>
  22. #include <QApplication>
  23. #include <QCheckBox>
  24. #include <QComboBox>
  25. #include <QDialog>
  26. #include <QDockWidget>
  27. #include <QGraphicsItem>
  28. #include <QGraphicsScene>
  29. #include <QImage>
  30. #include <QLabel>
  31. #include <QLineEdit>
  32. #include <QListWidget>
  33. #include <QMenu>
  34. #include <QPainter>
  35. #include <QSpinBox>
  36. #include <QPushButton>
  37. #include <QStringList>
  38. #include <QTableWidget>
  39. #include <QTabWidget>
  40. #include <QTimer>
  41. #include <QTest>
  42. #include <QToolButton>
  43. #include <QTreeWidget>
  44. #include <iostream>
  45. #include <stdexcept>
  46. #include <string>
  47. namespace {
  48. class TestProjectStorage final : public ProjectStorage
  49. {
  50. public:
  51. // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
  52. ProjectSaveResult save(const Project &, const std::string &) override
  53. {
  54. return {true, ProjectStorageError::None, {}};
  55. }
  56. ProjectLoadResult load(const std::string &) override
  57. {
  58. return {false, {}, ProjectStorageError::FileReadFailed, {}};
  59. }
  60. };
  61. class RepeatedStatusGateway final : public PlcCommunicationGateway
  62. {
  63. public:
  64. PlcCommunicationResult connectDevice(
  65. const PlcSerialConfiguration &) override
  66. {
  67. connection_state = PlcConnectionState::Connecting;
  68. notifyStateChanged();
  69. connection_state = PlcConnectionState::Connected;
  70. notifyStateChanged();
  71. notifyStateChanged();
  72. return {true, {}};
  73. }
  74. void disconnectDevice() override
  75. {
  76. connection_state = PlcConnectionState::Disconnected;
  77. notifyStateChanged();
  78. }
  79. void setPollAddresses(const std::vector<RegisterAddress> &) override {}
  80. PlcConnectionState state() const override { return connection_state; }
  81. bool initialReadCompleted() const override { return false; }
  82. PlcCommunicationError lastErrorType() const override
  83. {
  84. return PlcCommunicationError::None;
  85. }
  86. const std::string &lastError() const override { return last_error; }
  87. void setCallbacks(
  88. std::function<void()> state_callback,
  89. std::function<void(bool)> initial_callback,
  90. std::function<void()> cache_callback,
  91. std::function<void(const std::string &)> error_callback) override
  92. {
  93. state_changed = std::move(state_callback);
  94. initial_read_changed = std::move(initial_callback);
  95. cache_updated = std::move(cache_callback);
  96. error_reported = std::move(error_callback);
  97. }
  98. private:
  99. void notifyStateChanged()
  100. {
  101. if (state_changed)
  102. {
  103. state_changed();
  104. }
  105. }
  106. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  107. std::string last_error;
  108. std::function<void()> state_changed;
  109. std::function<void(bool)> initial_read_changed;
  110. std::function<void()> cache_updated;
  111. std::function<void(const std::string &)> error_reported;
  112. };
  113. class OnlineReadyGateway final : public PlcCommunicationGateway
  114. {
  115. public:
  116. PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override
  117. {
  118. connection_state = PlcConnectionState::Connected;
  119. if (state_changed)
  120. {
  121. state_changed();
  122. }
  123. return {true, {}};
  124. }
  125. void disconnectDevice() override
  126. {
  127. connection_state = PlcConnectionState::Disconnected;
  128. initial_read = false;
  129. if (initial_read_changed)
  130. {
  131. initial_read_changed(false);
  132. }
  133. if (state_changed)
  134. {
  135. state_changed();
  136. }
  137. }
  138. void setPollAddresses(const std::vector<RegisterAddress> &addresses) override
  139. {
  140. poll_addresses = addresses;
  141. }
  142. PlcConnectionState state() const override { return connection_state; }
  143. bool initialReadCompleted() const override { return initial_read; }
  144. PlcCommunicationError lastErrorType() const override { return last_error_type; }
  145. const std::string &lastError() const override { return last_error; }
  146. void setCallbacks(
  147. std::function<void()> state_callback,
  148. std::function<void(bool)> initial_callback,
  149. std::function<void()> cache_callback,
  150. std::function<void(const std::string &)> error_callback) override
  151. {
  152. state_changed = std::move(state_callback);
  153. initial_read_changed = std::move(initial_callback);
  154. cache_updated = std::move(cache_callback);
  155. error_reported = std::move(error_callback);
  156. }
  157. void completeInitialRead()
  158. {
  159. initial_read = true;
  160. last_error_type = PlcCommunicationError::None;
  161. last_error.clear();
  162. if (connection_state == PlcConnectionState::Recovering)
  163. {
  164. connection_state = PlcConnectionState::Connected;
  165. if (state_changed)
  166. {
  167. state_changed();
  168. }
  169. }
  170. if (initial_read_changed)
  171. {
  172. initial_read_changed(true);
  173. }
  174. }
  175. void timeoutCommunication(const std::string &message)
  176. {
  177. initial_read = false;
  178. last_error_type = PlcCommunicationError::CommunicationTimeout;
  179. last_error = message;
  180. connection_state = PlcConnectionState::Faulted;
  181. if (initial_read_changed)
  182. {
  183. initial_read_changed(false);
  184. }
  185. if (state_changed)
  186. {
  187. state_changed();
  188. }
  189. if (error_reported)
  190. {
  191. error_reported(last_error);
  192. }
  193. }
  194. void recoverCommunication()
  195. {
  196. initial_read = false;
  197. last_error_type = PlcCommunicationError::None;
  198. last_error.clear();
  199. connection_state = PlcConnectionState::Recovering;
  200. if (state_changed)
  201. {
  202. state_changed();
  203. }
  204. }
  205. void loseSerialConnection(const std::string &message)
  206. {
  207. initial_read = false;
  208. last_error_type = PlcCommunicationError::SerialConnectionLost;
  209. last_error = message;
  210. connection_state = PlcConnectionState::Disconnected;
  211. if (initial_read_changed)
  212. {
  213. initial_read_changed(false);
  214. }
  215. if (state_changed)
  216. {
  217. state_changed();
  218. }
  219. if (error_reported)
  220. {
  221. error_reported(last_error);
  222. }
  223. }
  224. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  225. bool initial_read = false;
  226. PlcCommunicationError last_error_type = PlcCommunicationError::None;
  227. std::string last_error;
  228. std::vector<RegisterAddress> poll_addresses;
  229. std::function<void()> state_changed;
  230. std::function<void(bool)> initial_read_changed;
  231. std::function<void()> cache_updated;
  232. std::function<void(const std::string &)> error_reported;
  233. };
  234. void require(bool condition, const std::string &message)
  235. {
  236. if (!condition)
  237. {
  238. throw std::runtime_error(message);
  239. }
  240. }
  241. template<typename ObjectType>
  242. ObjectType *requiredChild(MainWindow &window, const char *name)
  243. {
  244. // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
  245. ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
  246. require(child != nullptr, std::string("missing UI object ") + name);
  247. return child;
  248. }
  249. QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position)
  250. {
  251. QImage image(view.viewport()->size(), QImage::Format_ARGB32_Premultiplied);
  252. image.fill(Qt::transparent);
  253. QPainter painter(&image);
  254. view.viewport()->render(&painter);
  255. return image.pixelColor(viewport_position);
  256. }
  257. void testRuntimeButtonMouseInteraction()
  258. {
  259. TestProjectStorage storage;
  260. ProjectService project_service(storage);
  261. HmiEditorService editor_service(project_service);
  262. VirtualRegisterRepository repository;
  263. HmiRuntimeService runtime_service(repository);
  264. AlarmService alarm_service(project_service, repository);
  265. const HmiEditorResult page_result = editor_service.ensureDefaultPage();
  266. require(page_result.succeeded, "runtime button test must create an HMI page");
  267. const HmiEditorResult button_result = editor_service.addControl(
  268. page_result.id, HmiControlType::Button);
  269. require(button_result.succeeded, "runtime button test must create a button");
  270. HmiControl button = *editor_service.findControl(page_result.id, button_result.id);
  271. button.binding = RegisterAddress{RegisterArea::M, 0};
  272. button.buttonOperation = HmiButtonOperation::MomentaryOn;
  273. require(editor_service.updateControl(page_result.id, button.id, button).succeeded,
  274. "runtime button test must bind the button to M0");
  275. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  276. view.resize(900, 600);
  277. view.setPageId(page_result.id);
  278. view.setEditingEnabled(false);
  279. view.setRuntimeActive(true);
  280. view.setRuntimeWriteEnabled(true);
  281. view.show();
  282. QApplication::processEvents();
  283. const QPoint center = view.mapFromScene(QPointF(
  284. button.bounds.x + button.bounds.width / 2.0,
  285. button.bounds.y + button.bounds.height / 2.0));
  286. const QPoint color_sample = view.mapFromScene(QPointF(
  287. button.bounds.x + 8.0, button.bounds.y + 8.0));
  288. const QColor normal_color = renderedColorAt(view, color_sample);
  289. QTest::mouseMove(view.viewport(), center);
  290. QApplication::processEvents();
  291. QGraphicsItem *button_item = view.itemAt(center);
  292. require(button_item != nullptr,
  293. "runtime button must be hit-testable while writes are enabled");
  294. require(button_item->cursor().shape() == Qt::PointingHandCursor,
  295. "runtime button must use a pointing-hand cursor");
  296. const QColor hovered_color = renderedColorAt(view, color_sample);
  297. require(hovered_color != normal_color,
  298. "hovering a runtime button must change its visual state");
  299. QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  300. QApplication::processEvents();
  301. require(repository.readBit({RegisterArea::M, 0}).value,
  302. "pressing a momentary runtime button must write M0 ON");
  303. require(view.scene()->selectedItems().isEmpty(),
  304. "pressing a runtime button must not show an editing selection");
  305. const QColor pressed_color = renderedColorAt(view, color_sample);
  306. require(pressed_color != hovered_color,
  307. "pressing a runtime button must change its visual state");
  308. QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  309. QApplication::processEvents();
  310. require(!repository.readBit({RegisterArea::M, 0}).value,
  311. "releasing a momentary runtime button must write M0 OFF");
  312. require(renderedColorAt(view, color_sample) == hovered_color,
  313. "releasing a runtime button must restore its hovered visual state");
  314. view.setRuntimeWriteEnabled(false);
  315. QApplication::processEvents();
  316. const QColor disabled_color = renderedColorAt(view, color_sample);
  317. require(disabled_color != hovered_color,
  318. "a non-writable runtime button must use its disabled visual state");
  319. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  320. require(!repository.readBit({RegisterArea::M, 0}).value,
  321. "a disabled runtime button must not write its register");
  322. }
  323. void testRuntimeProgressBarRendering()
  324. {
  325. TestProjectStorage storage;
  326. ProjectService project_service(storage);
  327. HmiEditorService editor_service(project_service);
  328. VirtualRegisterRepository repository;
  329. HmiRuntimeService runtime_service(repository);
  330. AlarmService alarm_service(project_service, repository);
  331. const std::string page_id = editor_service.ensureDefaultPage().id;
  332. const HmiEditorResult progress_result = editor_service.addControl(
  333. page_id, HmiControlType::ProgressBar);
  334. require(progress_result.succeeded,
  335. "runtime progress test must create a ProgressBar control");
  336. HmiControl progress = *editor_service.findControl(
  337. page_id, progress_result.id);
  338. progress.binding = RegisterAddress{RegisterArea::D, 0};
  339. progress.progressBar = HmiProgressBarConfig{0, 100, true};
  340. require(editor_service.updateControl(
  341. page_id, progress.id, progress).succeeded,
  342. "runtime progress test must configure a D value range");
  343. require(repository.writeWord(
  344. {RegisterArea::D, 0}, static_cast<std::int16_t>(25)).succeeded,
  345. "runtime progress test must seed D0");
  346. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  347. view.resize(900, 560);
  348. view.setPageId(page_id);
  349. view.setEditingEnabled(false);
  350. view.setRuntimeActive(true);
  351. view.refreshRuntimeValues();
  352. view.show();
  353. QApplication::processEvents();
  354. const QPoint sample = view.mapFromScene(QPointF(
  355. progress.bounds.x + progress.bounds.width * 0.6,
  356. progress.bounds.y + 6.0));
  357. const QColor background_color = renderedColorAt(view, sample);
  358. QGraphicsItem *progress_item = view.itemAt(sample);
  359. require(progress_item != nullptr
  360. && progress_item->acceptedMouseButtons() == Qt::NoButton,
  361. "runtime ProgressBar must be visible and read-only");
  362. require(repository.writeWord(
  363. {RegisterArea::D, 0}, static_cast<std::int16_t>(75)).succeeded,
  364. "runtime progress test must update D0");
  365. view.refreshRuntimeValues();
  366. QApplication::processEvents();
  367. const QColor fill_color = renderedColorAt(view, sample);
  368. require(fill_color != background_color,
  369. "ProgressBar fill must expand when the D value increases");
  370. require(repository.writeWord(
  371. {RegisterArea::D, 0}, static_cast<std::int16_t>(200)).succeeded,
  372. "runtime progress test must accept an out-of-range source value");
  373. view.refreshRuntimeValues();
  374. QApplication::processEvents();
  375. const QPoint near_end = view.mapFromScene(QPointF(
  376. progress.bounds.x + progress.bounds.width * 0.95,
  377. progress.bounds.y + 6.0));
  378. require(renderedColorAt(view, near_end) == fill_color,
  379. "ProgressBar rendering must clamp values above its maximum");
  380. }
  381. void testRuntimePageJumpDoesNotRequireRegisterWritePermission()
  382. {
  383. TestProjectStorage storage;
  384. ProjectService project_service(storage);
  385. HmiEditorService editor_service(project_service);
  386. const std::string source_page = editor_service.ensureDefaultPage().id;
  387. const std::string target_page = editor_service.addPage("Target").id;
  388. const HmiEditorResult jump_result = editor_service.addControl(
  389. source_page, HmiControlType::PageJump);
  390. HmiControl jump = *editor_service.findControl(source_page, jump_result.id);
  391. jump.pageJump = HmiPageJumpConfig{target_page};
  392. require(editor_service.updateControl(
  393. source_page, jump_result.id, jump).succeeded,
  394. "PageJump fixture setup must succeed");
  395. VirtualRegisterRepository repository;
  396. HmiRuntimeService runtime_service(repository);
  397. AlarmService alarm_service(project_service, repository);
  398. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  399. view.setPageId(source_page);
  400. view.setEditingEnabled(false);
  401. view.setRuntimeActive(true);
  402. view.setRuntimeWriteEnabled(false);
  403. view.refreshRuntimeValues();
  404. view.resize(900, 560);
  405. view.show();
  406. QApplication::processEvents();
  407. QString requested_page;
  408. QObject::connect(
  409. &view, &HmiEditorWidget::pageNavigationRequested,
  410. [&requested_page](const QString &page_id)
  411. {
  412. requested_page = page_id;
  413. });
  414. QGraphicsItem *jump_item = nullptr;
  415. for (QGraphicsItem *item : view.scene()->items())
  416. {
  417. if (item->zValue() >= 0.0)
  418. {
  419. jump_item = item;
  420. break;
  421. }
  422. }
  423. require(jump_item != nullptr, "PageJump graphics item must be rendered");
  424. const QPoint center = view.mapFromScene(
  425. jump_item->mapToScene(jump_item->boundingRect().center()));
  426. QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center);
  427. require(requested_page == QString::fromStdString(target_page),
  428. "PageJump must remain active when register writes are disabled");
  429. }
  430. void testRuntimeAlarmListInteraction()
  431. {
  432. TestProjectStorage storage;
  433. ProjectService project_service(storage);
  434. HmiEditorService editor_service(project_service);
  435. AlarmEditorService alarm_editor_service(project_service);
  436. VirtualRegisterRepository repository;
  437. HmiRuntimeService runtime_service(repository);
  438. AlarmService alarm_service(project_service, repository);
  439. const std::string page_id = editor_service.ensureDefaultPage().id;
  440. const HmiEditorResult list_result = editor_service.addControl(
  441. page_id, HmiControlType::AlarmList);
  442. require(list_result.succeeded,
  443. "runtime alarm test must create an AlarmList control");
  444. const HmiControl *alarm_list = editor_service.findControl(
  445. page_id, list_result.id);
  446. require(alarm_list != nullptr && !alarm_list->binding.has_value(),
  447. "AlarmList must not require a register binding");
  448. AlarmDefinition definition;
  449. definition.address = RegisterAddress{RegisterArea::M, 0};
  450. definition.condition = AlarmCondition::MOn;
  451. definition.message = "Emergency stop";
  452. const AlarmEditorResult alarm_result =
  453. alarm_editor_service.addDefinition(definition);
  454. require(alarm_result.succeeded,
  455. "runtime alarm test must create an M alarm definition");
  456. HmiEditorWidget view(editor_service, runtime_service, alarm_service);
  457. view.setPageId(page_id);
  458. QGraphicsItem *alarm_item = nullptr;
  459. for (QGraphicsItem *item : view.scene()->items())
  460. {
  461. if (item->zValue() >= 0.0)
  462. {
  463. alarm_item = item;
  464. break;
  465. }
  466. }
  467. require(alarm_item != nullptr && alarm_item->isVisible(),
  468. "AlarmList must remain visible while editing");
  469. view.setEditingEnabled(false);
  470. view.setRuntimeActive(true);
  471. view.setRuntimeWriteEnabled(false);
  472. view.refreshRuntimeValues();
  473. view.resize(900, 560);
  474. view.show();
  475. QApplication::processEvents();
  476. require(!alarm_item->isVisible(),
  477. "AlarmList must be hidden when runtime has no active alarm");
  478. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
  479. alarm_service.refresh();
  480. view.refreshRuntimeValues();
  481. QApplication::processEvents();
  482. require(alarm_service.records().size() == 1,
  483. "an active M alarm must be available to AlarmList");
  484. require(alarm_item->isVisible() && alarm_item->zValue() > 0.0,
  485. "AlarmList must appear above the page for an active alarm");
  486. const QPoint header_position = view.mapFromScene(QPointF(
  487. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0));
  488. QTest::mouseClick(
  489. view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position);
  490. require(!alarm_service.records().front().acknowledged,
  491. "clicking the AlarmList header must not acknowledge an alarm");
  492. const QPoint first_row_position = view.mapFromScene(QPointF(
  493. alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0));
  494. QTest::mouseClick(
  495. view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position);
  496. require(alarm_service.records().front().acknowledged,
  497. "clicking an active AlarmList row must acknowledge the alarm");
  498. repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false);
  499. alarm_service.refresh();
  500. view.refreshRuntimeValues();
  501. QApplication::processEvents();
  502. require(alarm_service.records().empty(),
  503. "clearing the M condition must remove the alarm immediately");
  504. require(!alarm_item->isVisible(),
  505. "AlarmList must hide after the final alarm recovers");
  506. view.setRuntimeActive(false);
  507. view.setEditingEnabled(true);
  508. require(alarm_item->isVisible(),
  509. "AlarmList must become visible again after returning to editing");
  510. }
  511. void testWindowTitleTracksUnsavedProjectChanges()
  512. {
  513. TestProjectStorage storage;
  514. ProjectService project_service(storage);
  515. HmiEditorService editor_service(project_service);
  516. LogicEditorService logic_editor_service(project_service);
  517. require(editor_service.ensureDefaultPage().succeeded,
  518. "window-title test must create a default HMI page");
  519. require(logic_editor_service.ensureDefaultLogic().succeeded,
  520. "window-title test must create a default control logic");
  521. require(project_service.saveAs("window-title-test.json").succeeded,
  522. "window-title fixture must start from a saved project");
  523. VirtualRegisterRepository repository;
  524. HmiRuntimeService runtime_service(repository);
  525. AlarmEditorService alarm_editor_service(project_service);
  526. AlarmService alarm_service(project_service, repository);
  527. RegisterCommentService register_comment_service(project_service);
  528. OfflineSimulationService simulation_service(repository);
  529. RuntimeModeService mode_service(project_service, simulation_service);
  530. RegisterMonitorService monitor_service(repository);
  531. MainWindow window(
  532. mode_service,
  533. project_service,
  534. editor_service,
  535. logic_editor_service,
  536. runtime_service,
  537. alarm_editor_service,
  538. alarm_service,
  539. register_comment_service,
  540. monitor_service);
  541. require(!window.windowTitle().endsWith(QLatin1Char('*')),
  542. "a saved project title must not show an unsaved marker");
  543. requiredChild<QAction>(window, "addLabelAction")->trigger();
  544. require(project_service.isModified()
  545. && window.windowTitle().endsWith(QLatin1Char('*')),
  546. "editing the project must immediately add an unsaved marker");
  547. requiredChild<QAction>(window, "saveProjectAction")->trigger();
  548. require(!project_service.isModified()
  549. && !window.windowTitle().endsWith(QLatin1Char('*')),
  550. "saving the project must immediately remove the unsaved marker");
  551. }
  552. void testModeActionsControlEditingAvailability()
  553. {
  554. // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
  555. TestProjectStorage storage;
  556. ProjectService project_service(storage);
  557. HmiEditorService editor_service(project_service);
  558. LogicEditorService logic_editor_service(project_service);
  559. VirtualRegisterRepository repository;
  560. HmiRuntimeService runtime_service(repository);
  561. AlarmEditorService alarm_editor_service(project_service);
  562. AlarmService alarm_service(project_service, repository);
  563. RegisterCommentService register_comment_service(project_service);
  564. OfflineSimulationService simulation_service(repository);
  565. RuntimeModeService mode_service(project_service, simulation_service);
  566. RegisterMonitorService monitor_service(repository);
  567. MainWindow window(
  568. mode_service,
  569. project_service,
  570. editor_service,
  571. logic_editor_service,
  572. runtime_service,
  573. alarm_editor_service,
  574. alarm_service,
  575. register_comment_service,
  576. monitor_service);
  577. window.resize(1000, 640);
  578. window.show();
  579. QApplication::processEvents();
  580. require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
  581. "the removed data point dock must not remain in the main window");
  582. require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
  583. && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
  584. "HMI and ladder properties must use direct M/D address inputs");
  585. QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
  586. QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
  587. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  588. QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
  589. QAction *add_indicator_action = requiredChild<QAction>(window, "addIndicatorAction");
  590. QAction *add_progress_bar_action = requiredChild<QAction>(
  591. window, "addProgressBarAction");
  592. QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
  593. QAction *undo_action = requiredChild<QAction>(window, "undoAction");
  594. QAction *redo_action = requiredChild<QAction>(window, "redoAction");
  595. QAction *delete_selection_action = requiredChild<QAction>(
  596. window, "deleteSelectionAction");
  597. QAction *toggle_logic_enabled_action = requiredChild<QAction>(
  598. window, "toggleLogicEnabledAction");
  599. QToolButton *hmi_more_controls = requiredChild<QToolButton>(
  600. window, "hmiMoreControlsButton");
  601. QToolButton *logic_contact_menu = requiredChild<QToolButton>(
  602. window, "logicContactMenuButton");
  603. QToolButton *logic_output_menu = requiredChild<QToolButton>(
  604. window, "logicOutputMenuButton");
  605. QToolButton *logic_timer_counter_menu = requiredChild<QToolButton>(
  606. window, "logicTimerCounterMenuButton");
  607. QToolButton *logic_data_menu = requiredChild<QToolButton>(
  608. window, "logicDataMenuButton");
  609. QAction *add_normally_open_action = requiredChild<QAction>(
  610. window, "addNormallyOpenAction");
  611. QAction *add_normal_coil_action = requiredChild<QAction>(
  612. window, "addNormalCoilAction");
  613. QAction *parallel_insert_action = requiredChild<QAction>(
  614. window, "parallelInsertAction");
  615. QAction *insert_horizontal_wire_action = requiredChild<QAction>(
  616. window, "insertHorizontalWireAction");
  617. QAction *insert_vertical_wire_action = requiredChild<QAction>(
  618. window, "insertVerticalWireAction");
  619. QAction *delete_horizontal_wire_action = requiredChild<QAction>(
  620. window, "deleteHorizontalWireAction");
  621. QAction *delete_vertical_wire_action = requiredChild<QAction>(
  622. window, "deleteVerticalWireAction");
  623. QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
  624. QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
  625. QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
  626. QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
  627. QComboBox *button_operation = requiredChild<QComboBox>(
  628. window, "buttonOperationComboBox");
  629. QComboBox *binding_area = requiredChild<QComboBox>(
  630. window, "bindingAreaComboBox");
  631. QSpinBox *binding_index = requiredChild<QSpinBox>(
  632. window, "bindingIndexSpinBox");
  633. QSpinBox *progress_minimum = requiredChild<QSpinBox>(
  634. window, "progressMinimumSpinBox");
  635. QSpinBox *progress_maximum = requiredChild<QSpinBox>(
  636. window, "progressMaximumSpinBox");
  637. QCheckBox *progress_show_value = requiredChild<QCheckBox>(
  638. window, "progressShowValueCheckBox");
  639. QPushButton *apply_properties = requiredChild<QPushButton>(
  640. window, "applyPropertiesButton");
  641. HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
  642. window, "hmiEditorWidget");
  643. LogicEditorWidget *logic_editor = requiredChild<LogicEditorWidget>(
  644. window, "logicEditorWidget");
  645. QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
  646. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  647. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  648. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  649. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  650. QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget");
  651. QTreeWidget *project_tree = requiredChild<QTreeWidget>(window, "projectTree");
  652. require(!runtime_tab->isVisible(),
  653. "runtime monitor workspace must be hidden while editing");
  654. for (const char *action_name : {
  655. "exitAction",
  656. "newProjectAction",
  657. "saveProjectAction",
  658. "saveAsProjectAction",
  659. "loadProjectAction",
  660. "undoAction",
  661. "redoAction",
  662. "deleteSelectionAction",
  663. "clearSelectionAction",
  664. "configurePlcAction",
  665. "configureRegisterCommentsAction",
  666. "disconnectPlcAction",
  667. "addButtonAction",
  668. "addIndicatorAction",
  669. "addNumericDisplayAction",
  670. "addNumericInputAction",
  671. "addProgressBarAction",
  672. "addLabelAction",
  673. "addPageJumpAction",
  674. "addAlarmListAction",
  675. "configureAlarmsAction",
  676. "deleteControlAction",
  677. "addRungAction",
  678. "insertHorizontalWireAction",
  679. "insertVerticalWireAction",
  680. "deleteHorizontalWireAction",
  681. "deleteVerticalWireAction",
  682. "parallelInsertAction",
  683. "addNormallyOpenAction",
  684. "addNormallyClosedAction",
  685. "addRisingEdgeAction",
  686. "addFallingEdgeAction",
  687. "addTimerContactAction",
  688. "addCounterContactAction",
  689. "addNormalCoilAction",
  690. "addSetCoilAction",
  691. "addResetCoilAction",
  692. "addTonAction",
  693. "addCtuAction",
  694. "addCtdAction",
  695. "addMoveAction",
  696. "addAddAction",
  697. "addSubAction",
  698. "addCompareAction",
  699. "editRungCommentAction",
  700. "deleteLogicAction",
  701. "editingModeAction",
  702. "offlineModeAction",
  703. "onlineModeAction"})
  704. {
  705. require(!requiredChild<QAction>(window, action_name)->icon().isNull(),
  706. std::string("UI action must have a semantic icon: ") + action_name);
  707. }
  708. require(!hmi_more_controls->icon().isNull()
  709. && !logic_contact_menu->icon().isNull()
  710. && !logic_output_menu->icon().isNull()
  711. && !logic_timer_counter_menu->icon().isNull()
  712. && !logic_data_menu->icon().isNull(),
  713. "every grouped toolbar menu must have an icon");
  714. require(editor_tabs->count() == 3
  715. && !editor_tabs->tabIcon(0).isNull()
  716. && !editor_tabs->tabIcon(1).isNull()
  717. && !editor_tabs->tabIcon(2).isNull(),
  718. "every editor workspace tab must have an icon");
  719. QToolButton *monitor_remove = free_monitor->findChild<QToolButton *>(
  720. QStringLiteral("removeButton"));
  721. QToolButton *monitor_clear = free_monitor->findChild<QToolButton *>(
  722. QStringLiteral("clearButton"));
  723. require(monitor_remove != nullptr && monitor_clear != nullptr
  724. && !monitor_remove->icon().isNull()
  725. && !monitor_clear->icon().isNull(),
  726. "free-monitor delete and clear commands must have icons");
  727. require(hmi_more_controls->menu() != nullptr
  728. && hmi_more_controls->menu()->actions().contains(add_progress_bar_action),
  729. "advanced HMI controls must remain reachable from the more-controls menu");
  730. require(logic_contact_menu->menu() != nullptr
  731. && logic_contact_menu->menu()->actions().contains(
  732. requiredChild<QAction>(window, "addRisingEdgeAction")),
  733. "edge and resource contacts must remain reachable from the contact menu");
  734. require(logic_output_menu->menu() != nullptr
  735. && logic_output_menu->menu()->actions().contains(
  736. requiredChild<QAction>(window, "addResetCoilAction")),
  737. "set/reset coils must remain reachable from the output menu");
  738. require(logic_timer_counter_menu->menu() != nullptr
  739. && logic_timer_counter_menu->menu()->actions().contains(
  740. requiredChild<QAction>(window, "addCtdAction")),
  741. "timer and counter instructions must remain reachable from their menu");
  742. require(logic_data_menu->menu() != nullptr
  743. && logic_data_menu->menu()->actions().contains(
  744. requiredChild<QAction>(window, "addSubAction")),
  745. "data instructions must remain reachable from the data menu");
  746. const qreal compact_scale = hmi_editor->transform().m11();
  747. window.resize(1600, 900);
  748. QApplication::processEvents();
  749. require(hmi_editor->transform().m11() > compact_scale,
  750. "HMI page must refit when the window becomes larger");
  751. require(editing_action->isChecked(), "editing action must be selected initially");
  752. require(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z)
  753. && redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y)
  754. && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete)
  755. && insert_horizontal_wire_action->shortcut()
  756. == QKeySequence(Qt::Key_F11)
  757. && insert_vertical_wire_action->shortcut()
  758. == QKeySequence(Qt::Key_F12)
  759. && delete_horizontal_wire_action->shortcut()
  760. == QKeySequence(Qt::SHIFT | Qt::Key_F11)
  761. && delete_vertical_wire_action->shortcut()
  762. == QKeySequence(Qt::SHIFT | Qt::Key_F12),
  763. "core editor actions must expose the agreed keyboard shortcuts");
  764. require(project_dock->isEnabled(), "project dock must be enabled while editing");
  765. require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
  766. const std::string default_logic_id = logic_editor_service.firstLogicId();
  767. project_tree->setCurrentItem(project_tree->topLevelItem(1)->child(0));
  768. QApplication::processEvents();
  769. const bool default_logic_enabled =
  770. logic_editor_service.findLogic(default_logic_id)->enabled;
  771. toggle_logic_enabled_action->trigger();
  772. require(undo_action->isEnabled()
  773. && logic_editor_service.findLogic(default_logic_id)->enabled
  774. != default_logic_enabled,
  775. "project-tree edits must immediately enable ladder undo");
  776. undo_action->trigger();
  777. require(logic_editor_service.findLogic(default_logic_id)->enabled
  778. == default_logic_enabled,
  779. "ladder undo must restore a project-tree edit");
  780. editor_tabs->setCurrentIndex(0);
  781. QApplication::processEvents();
  782. add_button_action->trigger();
  783. const std::string page_id = editor_service.firstPageId();
  784. require(editor_service.findPage(page_id)->controls.size() == 1,
  785. "adding a control must update the HMI page model");
  786. require(selection->text() == QStringLiteral("button-1(未绑定)"),
  787. "an unbound added control must be marked in the property panel");
  788. require(text_edit->text() == QStringLiteral("按钮"),
  789. "property panel must show the control text");
  790. require(button_operation->currentText() == QStringLiteral("瞬时 ON"),
  791. "new HMI buttons must default to momentary ON");
  792. text_edit->setFocus();
  793. text_edit->selectAll();
  794. QTest::keyClicks(text_edit, "modified");
  795. QTest::keyClick(text_edit, Qt::Key_Z, Qt::ControlModifier);
  796. require(text_edit->text() == QStringLiteral("按钮")
  797. && editor_service.findControl(page_id, "button-1") != nullptr,
  798. "Ctrl+Z in a property input must undo text without undoing the HMI model");
  799. const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
  800. require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
  801. "an unbound HMI control must not reserve an address label area");
  802. button_operation->setCurrentIndex(
  803. button_operation->findData(static_cast<int>(HmiButtonOperation::Toggle)));
  804. apply_properties->click();
  805. require(editor_service.findControl(page_id, "button-1")->buttonOperation
  806. == HmiButtonOperation::Toggle,
  807. "the property panel must update the HMI button operation");
  808. HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
  809. bound_button.binding = RegisterAddress{RegisterArea::M, 0};
  810. require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
  811. "binding an HMI button to M0 must succeed");
  812. hmi_editor->reloadPage();
  813. hmi_editor->selectControl(bound_button.id);
  814. const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
  815. require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
  816. "a bound HMI control must include its address label above the control body");
  817. delete_control_action->trigger();
  818. require(editor_service.findPage(page_id)->controls.empty(),
  819. "deleting a selected control must update the HMI page model");
  820. QAction *add_label_action = requiredChild<QAction>(window, "addLabelAction");
  821. add_label_action->trigger();
  822. add_label_action->trigger();
  823. add_label_action->trigger();
  824. hmi_editor->scene()->clearSelection();
  825. for (QGraphicsItem *item : hmi_editor->scene()->items())
  826. {
  827. if (item->zValue() >= 0.0)
  828. {
  829. item->setSelected(true);
  830. }
  831. }
  832. delete_selection_action->trigger();
  833. require(editor_service.findPage(page_id)->controls.empty(),
  834. "Delete must remove every selected HMI control in one operation");
  835. undo_action->trigger();
  836. require(editor_service.findPage(page_id)->controls.size() == 3U,
  837. "HMI undo must restore a batch deletion");
  838. redo_action->trigger();
  839. require(editor_service.findPage(page_id)->controls.empty(),
  840. "HMI redo must reapply a batch deletion");
  841. add_progress_bar_action->trigger();
  842. const HmiControl *default_progress = editor_service.findControl(
  843. page_id, "progress-bar-1");
  844. require(default_progress != nullptr
  845. && default_progress->progressBar.has_value()
  846. && progress_minimum->isVisible()
  847. && progress_maximum->isVisible()
  848. && progress_show_value->isVisible()
  849. && progress_minimum->value() == 0
  850. && progress_maximum->value() == 100
  851. && progress_show_value->isChecked(),
  852. "ProgressBar properties must expose the registered defaults");
  853. progress_minimum->setValue(-20);
  854. progress_maximum->setValue(80);
  855. progress_show_value->setChecked(false);
  856. binding_area->setCurrentIndex(binding_area->findData(1));
  857. binding_index->setValue(4);
  858. apply_properties->click();
  859. const HmiControl *configured_progress = editor_service.findControl(
  860. page_id, "progress-bar-1");
  861. require(configured_progress != nullptr
  862. && configured_progress->binding
  863. == RegisterAddress{RegisterArea::D, 4}
  864. && configured_progress->progressBar->minimumValue == -20
  865. && configured_progress->progressBar->maximumValue == 80
  866. && !configured_progress->progressBar->showValue,
  867. "the property panel must update ProgressBar binding and range settings");
  868. delete_control_action->trigger();
  869. require(editor_service.findPage(page_id)->controls.empty(),
  870. "deleting a ProgressBar must remove it from the HMI page");
  871. add_indicator_action->trigger();
  872. HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1");
  873. runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3};
  874. require(editor_service.updateControl(
  875. page_id, runtime_indicator.id, runtime_indicator).succeeded,
  876. "a configured HMI control must be available for the runtime projection");
  877. hmi_editor->reloadPage();
  878. add_normally_open_action->trigger();
  879. editor_tabs->setCurrentIndex(1);
  880. QApplication::processEvents();
  881. undo_action->trigger();
  882. require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
  883. ->rungs.front().condition.has_value() == false,
  884. "logic undo must use the ladder history when the ladder tab is active");
  885. redo_action->trigger();
  886. const LadderRung &restored_rung = logic_editor_service.findLogic(
  887. logic_editor_service.firstLogicId())->rungs.front();
  888. require(restored_rung.condition.has_value(),
  889. "logic redo must restore the ladder edit");
  890. std::vector<const LogicNode *> restored_nodes;
  891. collectConditionNodes(*restored_rung.condition, &restored_nodes);
  892. require(restored_nodes.size() == 1U,
  893. "logic redo must restore the original condition node");
  894. const std::string restored_node_id = restored_nodes.front()->id;
  895. logic_editor->selectNode(restored_node_id);
  896. insert_horizontal_wire_action->trigger();
  897. const LadderRung &wired_rung = logic_editor_service.findLogic(
  898. logic_editor_service.firstLogicId())->rungs.front();
  899. require(wired_rung.condition->kind == ConditionExpressionKind::Series
  900. && wired_rung.condition->children.at(1).kind
  901. == ConditionExpressionKind::Wire,
  902. "F11 must insert a selectable horizontal wire after the current condition");
  903. delete_horizontal_wire_action->trigger();
  904. require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
  905. ->rungs.front().condition->kind == ConditionExpressionKind::Node,
  906. "Shift+F11 must delete the selected horizontal wire");
  907. logic_editor->selectNode(restored_node_id);
  908. insert_horizontal_wire_action->trigger();
  909. add_normally_open_action->trigger();
  910. const LadderRung &replaced_wire_rung = logic_editor_service.findLogic(
  911. logic_editor_service.firstLogicId())->rungs.front();
  912. require(replaced_wire_rung.condition->kind == ConditionExpressionKind::Series
  913. && replaced_wire_rung.condition->children.at(1).kind
  914. == ConditionExpressionKind::Node,
  915. "adding a contact on a selected wire must replace the wire in place");
  916. undo_action->trigger();
  917. undo_action->trigger();
  918. logic_editor->selectNode(restored_node_id);
  919. insert_vertical_wire_action->trigger();
  920. const LadderRung &vertical_rung = logic_editor_service.findLogic(
  921. logic_editor_service.firstLogicId())->rungs.front();
  922. require(vertical_rung.condition->kind == ConditionExpressionKind::Parallel
  923. && vertical_rung.condition->children.at(1).kind
  924. == ConditionExpressionKind::Wire,
  925. "F12 must create a structured wire bypass with vertical connectors");
  926. logic_editor->scene()->clearSelection();
  927. for (QGraphicsItem *item : logic_editor->scene()->items())
  928. {
  929. if (qFuzzyCompare(item->zValue(), 2.5))
  930. {
  931. item->setSelected(true);
  932. }
  933. }
  934. delete_vertical_wire_action->trigger();
  935. require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
  936. ->rungs.front().condition->kind == ConditionExpressionKind::Node,
  937. "Shift+F12 must remove the selected vertical connection branch");
  938. logic_editor->selectNode(restored_node_id);
  939. parallel_insert_action->trigger();
  940. add_normal_coil_action->trigger();
  941. const ControlLogic *logic = logic_editor_service.findLogic(
  942. logic_editor_service.firstLogicId());
  943. require(logic != nullptr && logic->rungs.size() == 1,
  944. "logic actions must edit the default ladder rung");
  945. require(logic->rungs.front().condition.has_value()
  946. && logic->rungs.front().condition->kind
  947. == ConditionExpressionKind::Parallel
  948. && logic->rungs.front().condition->children.size() == 2U,
  949. "parallel branch action must create a parallel expression");
  950. require(logic->rungs.front().output.has_value(),
  951. "coil action must set the fixed ladder output");
  952. offline_action->trigger();
  953. require(mode_service.mode() == ApplicationMode::Editing,
  954. "unconfigured ladder nodes must block offline running");
  955. const LadderRung &rung = logic->rungs.front();
  956. std::vector<const LogicNode *> condition_nodes;
  957. collectConditionNodes(*rung.condition, &condition_nodes);
  958. for (const LogicNode *node : condition_nodes)
  959. {
  960. require(logic_editor_service.updateNodeConfig(
  961. logic_editor_service.firstLogicId(),
  962. node->id,
  963. ContactNodeConfig{
  964. RegisterAddress{RegisterArea::M, 0},
  965. ContactMode::NormallyOpen})
  966. .succeeded,
  967. "applying a contact configuration must complete the ladder node");
  968. }
  969. require(logic_editor_service.updateNodeConfig(
  970. logic_editor_service.firstLogicId(),
  971. rung.output->id,
  972. CoilNodeConfig{
  973. RegisterAddress{RegisterArea::M, 1},
  974. CoilMode::Normal})
  975. .succeeded,
  976. "applying a coil configuration must complete the ladder node");
  977. offline_action->trigger();
  978. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  979. "offline action must enter offline running");
  980. require(simulation_service.state() == SimulationState::Running,
  981. "offline action must start the actual simulation service");
  982. require(executor_status->text().contains(QStringLiteral("运行")),
  983. "executor status label must report the actual running state");
  984. require(!project_dock->isEnabled(),
  985. "project dock must be disabled while running");
  986. require(!properties_dock->isEnabled(),
  987. "properties dock must be disabled while running");
  988. require(!add_button_action->isEnabled(),
  989. "HMI add controls must be disabled while running");
  990. require(!add_progress_bar_action->isEnabled(),
  991. "ProgressBar creation must be disabled while running");
  992. require(!add_normally_open_action->isEnabled(),
  993. "logic add nodes must be disabled while running");
  994. require(!undo_action->isEnabled() && !redo_action->isEnabled()
  995. && !delete_selection_action->isEnabled(),
  996. "undo, redo and delete must be disabled while running");
  997. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  998. && runtime_logic->isVisible() && free_monitor->isVisible(),
  999. "offline running must show HMI, ladder trace and free monitor together");
  1000. auto *runtime_hmi_view = qobject_cast<HmiEditorWidget *>(runtime_hmi);
  1001. require(runtime_hmi_view != nullptr
  1002. && runtime_hmi_view->scene()->items().size() == 2,
  1003. "offline runtime HMI must project the configured page control");
  1004. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  1005. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  1006. QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
  1007. monitor_address->setText(QStringLiteral("M0"));
  1008. monitor_add->click();
  1009. repository.writeBit({RegisterArea::M, 0}, true);
  1010. auto *monitor_widget = requiredChild<FreeMonitorWidget>(window, "freeMonitorWidget");
  1011. monitor_widget->refreshValues(
  1012. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  1013. require(monitor_table->rowCount() == 1
  1014. && monitor_table->item(0, 2)->text() == QStringLiteral("ON"),
  1015. "offline free monitor must read the same virtual M/D repository as HMI");
  1016. online_action->trigger();
  1017. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  1018. "running modes must not switch directly through the UI");
  1019. require(offline_action->isChecked(),
  1020. "failed mode changes must restore the active action");
  1021. editing_action->trigger();
  1022. require(mode_service.mode() == ApplicationMode::Editing,
  1023. "editing action must return to editing");
  1024. require(simulation_service.state() == SimulationState::Stopped,
  1025. "editing action must stop the simulation service");
  1026. require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
  1027. "executor status label must report the actual stopped state");
  1028. require(project_dock->isEnabled(),
  1029. "project dock must be restored after returning to editing");
  1030. require(properties_dock->isEnabled(),
  1031. "properties dock must be restored after returning to editing");
  1032. require(add_button_action->isEnabled(),
  1033. "HMI add controls must be restored after returning to editing");
  1034. require(add_progress_bar_action->isEnabled(),
  1035. "ProgressBar creation must be restored after returning to editing");
  1036. require(add_normally_open_action->isEnabled(),
  1037. "logic add nodes must be restored after returning to editing");
  1038. require(!runtime_tab->isVisible(),
  1039. "returning to editing must hide the runtime monitor workspace");
  1040. online_action->trigger();
  1041. require(mode_service.mode() == ApplicationMode::Editing,
  1042. "online action must require an initial PLC read");
  1043. require(editing_action->isChecked(),
  1044. "rejected online running must restore the editing action");
  1045. }
  1046. void testEdgeTimerPropertyEditingAndParallelMenu()
  1047. {
  1048. TestProjectStorage storage;
  1049. ProjectService project_service(storage);
  1050. HmiEditorService editor_service(project_service);
  1051. LogicEditorService logic_editor_service(project_service);
  1052. VirtualRegisterRepository repository;
  1053. HmiRuntimeService runtime_service(repository);
  1054. AlarmEditorService alarm_editor_service(project_service);
  1055. AlarmService alarm_service(project_service, repository);
  1056. RegisterCommentService register_comment_service(project_service);
  1057. OfflineSimulationService simulation_service(repository);
  1058. RuntimeModeService mode_service(project_service, simulation_service);
  1059. RegisterMonitorService monitor_service(repository);
  1060. MainWindow window(
  1061. mode_service,
  1062. project_service,
  1063. editor_service,
  1064. logic_editor_service,
  1065. runtime_service,
  1066. alarm_editor_service,
  1067. alarm_service,
  1068. register_comment_service,
  1069. monitor_service);
  1070. window.show();
  1071. QApplication::processEvents();
  1072. QAction *parallel_action = requiredChild<QAction>(window, "parallelInsertAction");
  1073. require(parallel_action->menu() != nullptr,
  1074. "parallel insertion must expose its node type menu");
  1075. QStringList parallel_labels;
  1076. for (QAction *action : parallel_action->menu()->actions())
  1077. {
  1078. parallel_labels.push_back(action->text());
  1079. }
  1080. require(parallel_labels.contains(QStringLiteral("并联上升沿触点"))
  1081. && parallel_labels.contains(QStringLiteral("并联下降沿触点"))
  1082. && parallel_labels.contains(QStringLiteral("并联 T 触点")),
  1083. "parallel insertion must include edge and T contact choices");
  1084. QAction *rising_action = requiredChild<QAction>(window, "addRisingEdgeAction");
  1085. QAction *falling_action = requiredChild<QAction>(window, "addFallingEdgeAction");
  1086. QAction *timer_contact_action = requiredChild<QAction>(
  1087. window, "addTimerContactAction");
  1088. QAction *ton_action = requiredChild<QAction>(window, "addTonAction");
  1089. requiredChild<QAction>(window, "editRungCommentAction");
  1090. requiredChild<QAction>(window, "configureRegisterCommentsAction");
  1091. QSpinBox *address = requiredChild<QSpinBox>(window, "logicAddressSpinBox");
  1092. QSpinBox *preset = requiredChild<QSpinBox>(window, "logicPresetSpinBox");
  1093. QComboBox *mode = requiredChild<QComboBox>(window, "logicModeComboBox");
  1094. QPushButton *apply = requiredChild<QPushButton>(
  1095. window, "applyLogicPropertiesButton");
  1096. const std::string logic_id = logic_editor_service.firstLogicId();
  1097. rising_action->trigger();
  1098. address->setValue(8);
  1099. mode->setCurrentIndex(mode->findData(static_cast<int>(EdgeMode::Falling)));
  1100. apply->click();
  1101. const LogicNode *edge = logic_editor_service.findNode(logic_id, "edge-1");
  1102. require(edge != nullptr && edge->configured
  1103. && std::get<EdgeContactNodeConfig>(edge->config).address.index() == 8
  1104. && std::get<EdgeContactNodeConfig>(edge->config).mode
  1105. == EdgeMode::Falling,
  1106. "edge properties must update M address and edge mode");
  1107. timer_contact_action->trigger();
  1108. address->setValue(9);
  1109. mode->setCurrentIndex(mode->findData(
  1110. static_cast<int>(ContactMode::NormallyClosed)));
  1111. apply->click();
  1112. const LogicNode *timer_contact = logic_editor_service.findNode(
  1113. logic_id, "timer-contact-1");
  1114. require(timer_contact != nullptr && timer_contact->configured
  1115. && std::get<TimerContactNodeConfig>(timer_contact->config).address
  1116. == TimerAddress{9}
  1117. && std::get<TimerContactNodeConfig>(timer_contact->config).mode
  1118. == ContactMode::NormallyClosed,
  1119. "T contact properties must update T address and contact mode");
  1120. ton_action->trigger();
  1121. require(preset->isEnabled() && !mode->isEnabled(),
  1122. "TON properties must enable PT and disable irrelevant mode editing");
  1123. address->setValue(9);
  1124. preset->setValue(1234);
  1125. apply->click();
  1126. const LogicNode *ton = logic_editor_service.findNode(logic_id, "ton-1");
  1127. require(ton != nullptr && ton->configured
  1128. && std::get<TonNodeConfig>(ton->config).address == TimerAddress{9}
  1129. && std::get<TonNodeConfig>(ton->config).presetMs == 1234,
  1130. "TON properties must update T address and preset milliseconds");
  1131. falling_action->trigger();
  1132. const LogicNode *falling = logic_editor_service.findNode(logic_id, "edge-2");
  1133. require(falling != nullptr
  1134. && std::get<EdgeContactNodeConfig>(falling->config).mode
  1135. == EdgeMode::Falling,
  1136. "falling edge action must create a falling-edge condition");
  1137. }
  1138. void testPlcConfigurationUsesDialog()
  1139. {
  1140. PlcSerialConfiguration initial;
  1141. initial.portName = "COM17";
  1142. initial.serverAddress = 12;
  1143. initial.baudRate = 38400;
  1144. initial.dataBits = 7;
  1145. initial.parity = 3;
  1146. initial.stopBits = 2;
  1147. initial.responseTimeoutMs = 2500;
  1148. initial.retries = 4;
  1149. initial.pollIntervalMs = 350;
  1150. PlcConnectionDialog configuration_dialog(initial);
  1151. const PlcSerialConfiguration actual = configuration_dialog.configuration();
  1152. require(actual.portName == initial.portName,
  1153. "PLC dialog must preserve a manually entered serial port");
  1154. require(actual.serverAddress == initial.serverAddress
  1155. && actual.baudRate == initial.baudRate
  1156. && actual.dataBits == initial.dataBits
  1157. && actual.parity == initial.parity
  1158. && actual.stopBits == initial.stopBits,
  1159. "PLC dialog must preserve Modbus RTU serial parameters");
  1160. require(actual.responseTimeoutMs == initial.responseTimeoutMs
  1161. && actual.retries == initial.retries
  1162. && actual.pollIntervalMs == initial.pollIntervalMs,
  1163. "PLC dialog must preserve communication timing parameters");
  1164. TestProjectStorage storage;
  1165. ProjectService project_service(storage);
  1166. HmiEditorService editor_service(project_service);
  1167. LogicEditorService logic_editor_service(project_service);
  1168. VirtualRegisterRepository repository;
  1169. HmiRuntimeService runtime_service(repository);
  1170. AlarmEditorService alarm_editor_service(project_service);
  1171. AlarmService alarm_service(project_service, repository);
  1172. RegisterCommentService register_comment_service(project_service);
  1173. OfflineSimulationService simulation_service(repository);
  1174. RuntimeModeService mode_service(project_service, simulation_service);
  1175. RegisterMonitorService monitor_service(repository);
  1176. MainWindow window(
  1177. mode_service,
  1178. project_service,
  1179. editor_service,
  1180. logic_editor_service,
  1181. runtime_service,
  1182. alarm_editor_service,
  1183. alarm_service,
  1184. register_comment_service,
  1185. monitor_service);
  1186. require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
  1187. "serial configuration controls must not be embedded in the main window");
  1188. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  1189. bool dialog_opened = false;
  1190. QTimer::singleShot(
  1191. 0,
  1192. [&dialog_opened]
  1193. {
  1194. auto *dialog = qobject_cast<PlcConnectionDialog *>(
  1195. QApplication::activeModalWidget());
  1196. dialog_opened = dialog != nullptr;
  1197. if (dialog != nullptr)
  1198. {
  1199. dialog->reject();
  1200. }
  1201. });
  1202. configure_action->trigger();
  1203. require(dialog_opened,
  1204. "PLC configuration action must open the dedicated configuration dialog");
  1205. }
  1206. void testRepeatedPlcStatusNotificationsAreCoalesced()
  1207. {
  1208. TestProjectStorage storage;
  1209. ProjectService project_service(storage);
  1210. HmiEditorService editor_service(project_service);
  1211. LogicEditorService logic_editor_service(project_service);
  1212. VirtualRegisterRepository virtual_repository;
  1213. VirtualRegisterRepository plc_repository;
  1214. ActiveRegisterRepository active_repository(virtual_repository);
  1215. HmiRuntimeService runtime_service(active_repository);
  1216. AlarmEditorService alarm_editor_service(project_service);
  1217. AlarmService alarm_service(project_service, active_repository);
  1218. RegisterCommentService register_comment_service(project_service);
  1219. OfflineSimulationService simulation_service(virtual_repository);
  1220. RepeatedStatusGateway gateway;
  1221. RuntimeModeService mode_service(project_service, simulation_service);
  1222. RegisterMonitorService monitor_service(active_repository);
  1223. mode_service.configurePlc(
  1224. gateway, active_repository, virtual_repository, plc_repository);
  1225. MainWindow window(
  1226. mode_service,
  1227. project_service,
  1228. editor_service,
  1229. logic_editor_service,
  1230. runtime_service,
  1231. alarm_editor_service,
  1232. alarm_service,
  1233. register_comment_service,
  1234. monitor_service);
  1235. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  1236. const int initial_count = output->count();
  1237. const PlcCommunicationResult result = mode_service.connectPlc(
  1238. {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
  1239. require(result.succeeded, "PLC connection setup must succeed");
  1240. QApplication::processEvents();
  1241. const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
  1242. int matching_count = 0;
  1243. for (int index = initial_count; index < output->count(); ++index)
  1244. {
  1245. if (output->item(index)->text() == expected)
  1246. {
  1247. ++matching_count;
  1248. }
  1249. }
  1250. require(matching_count == 1,
  1251. "repeated PLC status callbacks must append one coalesced output message");
  1252. }
  1253. void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly()
  1254. {
  1255. TestProjectStorage storage;
  1256. ProjectService project_service(storage);
  1257. HmiEditorService editor_service(project_service);
  1258. LogicEditorService logic_editor_service(project_service);
  1259. VirtualRegisterRepository virtual_repository;
  1260. VirtualRegisterRepository plc_repository;
  1261. ActiveRegisterRepository active_repository(virtual_repository);
  1262. HmiRuntimeService runtime_service(active_repository);
  1263. AlarmEditorService alarm_editor_service(project_service);
  1264. AlarmService alarm_service(project_service, active_repository);
  1265. RegisterCommentService register_comment_service(project_service);
  1266. OfflineSimulationService simulation_service(virtual_repository);
  1267. OnlineReadyGateway gateway;
  1268. RuntimeModeService mode_service(project_service, simulation_service);
  1269. RegisterMonitorService monitor_service(active_repository);
  1270. mode_service.configurePlc(
  1271. gateway, active_repository, virtual_repository, plc_repository);
  1272. MainWindow window(
  1273. mode_service,
  1274. project_service,
  1275. editor_service,
  1276. logic_editor_service,
  1277. runtime_service,
  1278. alarm_editor_service,
  1279. alarm_service,
  1280. register_comment_service,
  1281. monitor_service);
  1282. window.resize(1200, 760);
  1283. window.show();
  1284. QApplication::processEvents();
  1285. require(mode_service.connectPlc(
  1286. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  1287. "online workspace test must connect the fake PLC gateway");
  1288. gateway.completeInitialRead();
  1289. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  1290. online_action->trigger();
  1291. QApplication::processEvents();
  1292. QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab");
  1293. QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
  1294. QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
  1295. QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
  1296. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  1297. "completed PLC initial read must allow online running");
  1298. require(runtime_tab->isVisible() && runtime_hmi->isVisible()
  1299. && free_monitor->isVisible(),
  1300. "online running must show HMI and free monitor together");
  1301. require(!runtime_logic->isVisible(),
  1302. "online running must not show a misleading local ladder runtime trace");
  1303. QLineEdit *monitor_address = requiredChild<QLineEdit>(window, "addressEdit");
  1304. QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
  1305. monitor_address->setText(QStringLiteral("D9"));
  1306. monitor_add->click();
  1307. require(std::find(
  1308. gateway.poll_addresses.begin(),
  1309. gateway.poll_addresses.end(),
  1310. RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(),
  1311. "online free monitor addresses must join the active PLC poll set immediately");
  1312. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  1313. QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction");
  1314. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  1315. gateway.timeoutCommunication(
  1316. "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线");
  1317. QApplication::processEvents();
  1318. require(mode_service.mode() == ApplicationMode::Editing,
  1319. "a PLC timeout must return online running to editing");
  1320. require(disconnect_action->isEnabled(),
  1321. "a timeout must keep disconnect available while the local port remains open");
  1322. require(configure_action->isEnabled()
  1323. && configure_action->text() == QStringLiteral("PLC 重新配置"),
  1324. "a timeout must keep PLC reconfiguration available");
  1325. gateway.recoverCommunication();
  1326. QApplication::processEvents();
  1327. require(mode_service.mode() == ApplicationMode::Editing,
  1328. "automatic communication recovery must remain in editing mode");
  1329. require(output->count() > 0
  1330. && output->item(output->count() - 1)->text().contains(
  1331. QStringLiteral("通信已恢复")),
  1332. "automatic recovery must be retained in the output log");
  1333. online_action->trigger();
  1334. require(mode_service.mode() == ApplicationMode::Editing,
  1335. "recovered communication must complete a fresh initial read before online mode");
  1336. gateway.completeInitialRead();
  1337. QApplication::processEvents();
  1338. online_action->trigger();
  1339. require(mode_service.mode() == ApplicationMode::OnlineRunning,
  1340. "users must be able to re-enter online mode after the recovered initial read");
  1341. gateway.loseSerialConnection(
  1342. "PLC 串口 COM9 连接已中断;请检查 USB 转串口是否被拔出或已经失效");
  1343. QApplication::processEvents();
  1344. require(mode_service.mode() == ApplicationMode::Editing,
  1345. "a communication fault must return the UI from online running to editing");
  1346. require(configure_action->isEnabled()
  1347. && configure_action->text() == QStringLiteral("PLC 配置"),
  1348. "a lost serial connection must expose direct configuration");
  1349. require(!disconnect_action->isEnabled(),
  1350. "a lost serial connection must disable redundant disconnect actions");
  1351. require(output->count() > 0
  1352. && output->item(output->count() - 1)->text().contains(
  1353. QStringLiteral("连接已中断")),
  1354. "communication fault details must remain in the output log");
  1355. online_action->trigger();
  1356. require(mode_service.mode() == ApplicationMode::Editing,
  1357. "faulted PLC state must not re-enter online running without a fresh read");
  1358. }
  1359. void testMultiPageAndLogicMainWindowIntegration()
  1360. {
  1361. TestProjectStorage storage;
  1362. ProjectService project_service(storage);
  1363. HmiEditorService editor_service(project_service);
  1364. LogicEditorService logic_editor_service(project_service);
  1365. const std::string main_page_id = editor_service.ensureDefaultPage().id;
  1366. const std::string settings_page_id = editor_service.addPage("Settings").id;
  1367. const std::string first_logic_id = logic_editor_service.ensureDefaultLogic().id;
  1368. const std::string second_logic_id =
  1369. logic_editor_service.addLogic("Safety logic").id;
  1370. const HmiEditorResult jump_result = editor_service.addControl(
  1371. main_page_id, HmiControlType::PageJump);
  1372. HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id);
  1373. jump.pageJump = HmiPageJumpConfig{settings_page_id};
  1374. require(editor_service.updateControl(
  1375. main_page_id, jump_result.id, jump).succeeded,
  1376. "the integration fixture must configure PageJump");
  1377. VirtualRegisterRepository repository;
  1378. HmiRuntimeService runtime_service(repository);
  1379. AlarmEditorService alarm_editor_service(project_service);
  1380. AlarmService alarm_service(project_service, repository);
  1381. RegisterCommentService register_comment_service(project_service);
  1382. OfflineSimulationService simulation_service(repository);
  1383. RuntimeModeService mode_service(project_service, simulation_service);
  1384. RegisterMonitorService monitor_service(repository);
  1385. HmiNavigationService navigation_service(project_service);
  1386. MainWindow window(
  1387. mode_service,
  1388. project_service,
  1389. editor_service,
  1390. logic_editor_service,
  1391. runtime_service,
  1392. alarm_editor_service,
  1393. alarm_service,
  1394. navigation_service,
  1395. register_comment_service,
  1396. monitor_service);
  1397. window.resize(1400, 820);
  1398. window.show();
  1399. QApplication::processEvents();
  1400. QTreeWidget *tree = requiredChild<QTreeWidget>(window, "projectTree");
  1401. require(tree->topLevelItemCount() == 2
  1402. && tree->topLevelItem(0)->childCount() == 2
  1403. && tree->topLevelItem(1)->childCount() == 2,
  1404. "the project tree must list every page and logic module");
  1405. tree->setCurrentItem(tree->topLevelItem(0)->child(1));
  1406. QApplication::processEvents();
  1407. require(window.currentHmiPageId() == settings_page_id,
  1408. "selecting a page tree node must change the current HMI page id");
  1409. QAction *add_label = requiredChild<QAction>(window, "addLabelAction");
  1410. add_label->trigger();
  1411. require(editor_service.findPage(settings_page_id)->controls.size() == 1U
  1412. && editor_service.findPage(main_page_id)->controls.size() == 1U,
  1413. "HMI toolbar actions must edit the selected page rather than the first page");
  1414. QComboBox *binding_area = requiredChild<QComboBox>(window, "bindingAreaComboBox");
  1415. QComboBox *target_page = requiredChild<QComboBox>(window, "targetPageComboBox");
  1416. require(!binding_area->isVisible() && !target_page->isVisible(),
  1417. "Label properties must hide both register binding and PageJump target fields");
  1418. tree->setCurrentItem(tree->topLevelItem(1)->child(1));
  1419. QApplication::processEvents();
  1420. require(window.currentLogicId() == second_logic_id,
  1421. "selecting a logic tree node must change the current logic id");
  1422. requiredChild<QAction>(window, "addRungAction")->trigger();
  1423. require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 2U
  1424. && logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U,
  1425. "logic toolbar actions must edit the selected logic module");
  1426. tree->setCurrentItem(tree->topLevelItem(0)->child(0));
  1427. QApplication::processEvents();
  1428. HmiEditorWidget *editor = requiredChild<HmiEditorWidget>(window, "hmiEditorWidget");
  1429. editor->selectControl(jump_result.id);
  1430. QApplication::processEvents();
  1431. require(target_page->isVisible() && !binding_area->isVisible()
  1432. && target_page->currentData().toString()
  1433. == QString::fromStdString(settings_page_id),
  1434. "PageJump properties must show a target-page selector without register fields");
  1435. requiredChild<QAction>(window, "offlineModeAction")->trigger();
  1436. QApplication::processEvents();
  1437. require(mode_service.mode() == ApplicationMode::OfflineRunning
  1438. && navigation_service.currentPageId() == main_page_id,
  1439. "runtime HMI navigation must start on the persisted initial page");
  1440. HmiEditorWidget *runtime_hmi = requiredChild<HmiEditorWidget>(
  1441. window, "runtimeHmiView");
  1442. QGraphicsItem *jump_item = nullptr;
  1443. for (QGraphicsItem *item : runtime_hmi->scene()->items())
  1444. {
  1445. if (item->zValue() >= 0.0)
  1446. {
  1447. jump_item = item;
  1448. break;
  1449. }
  1450. }
  1451. require(jump_item != nullptr, "runtime HMI must render the PageJump control");
  1452. const QPoint jump_center = runtime_hmi->mapFromScene(
  1453. jump_item->mapToScene(jump_item->boundingRect().center()));
  1454. QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton,
  1455. Qt::NoModifier, jump_center);
  1456. QApplication::processEvents();
  1457. require(navigation_service.currentPageId() == settings_page_id
  1458. && requiredChild<QLabel>(window, "runtimePageLabel")->text()
  1459. == QStringLiteral("Settings"),
  1460. "clicking PageJump at runtime must navigate locally to its target page");
  1461. QComboBox *logic_selector = requiredChild<QComboBox>(
  1462. window, "runtimeLogicComboBox");
  1463. require(logic_selector->count() == 2,
  1464. "runtime logic selector must list all enabled logic modules");
  1465. logic_selector->setCurrentIndex(
  1466. logic_selector->findData(QString::fromStdString(second_logic_id)));
  1467. QApplication::processEvents();
  1468. auto *runtime_monitor = requiredChild<RuntimeMonitorWidget>(
  1469. window, "runtimeMonitorWidget");
  1470. require(runtime_monitor->selectedLogicId() == second_logic_id,
  1471. "runtime logic selection must change only the visible trace projection");
  1472. requiredChild<QAction>(window, "editingModeAction")->trigger();
  1473. }
  1474. } // namespace
  1475. int main(int argc, char *argv[])
  1476. {
  1477. // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
  1478. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  1479. QApplication application(argc, argv);
  1480. try
  1481. {
  1482. testRuntimeButtonMouseInteraction();
  1483. testRuntimeProgressBarRendering();
  1484. testRuntimePageJumpDoesNotRequireRegisterWritePermission();
  1485. testRuntimeAlarmListInteraction();
  1486. testWindowTitleTracksUnsavedProjectChanges();
  1487. testModeActionsControlEditingAvailability();
  1488. testEdgeTimerPropertyEditingAndParallelMenu();
  1489. testPlcConfigurationUsesDialog();
  1490. testRepeatedPlcStatusNotificationsAreCoalesced();
  1491. testOnlineWorkspaceShowsHmiAndFreeMonitorOnly();
  1492. testMultiPageAndLogicMainWindowIntegration();
  1493. }
  1494. catch (const std::exception &error)
  1495. {
  1496. std::cerr << "main window tests failed: " << error.what() << '\n';
  1497. return 1;
  1498. }
  1499. std::cout << "main window tests passed\n";
  1500. return 0;
  1501. }