综合平台编程器项目的远程存储
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

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