综合平台编程器项目的远程存储
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

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