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

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