综合平台编程器项目的远程存储
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

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