综合平台编程器项目的远程存储
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

2161 Zeilen
96 KiB

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