综合平台编程器项目的远程存储
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1920 lines
85 KiB

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