综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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