综合平台编程器项目的远程存储
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.
 
 
 
 

1406 lines
56 KiB

  1. #include "domain/virtual_register_repository.h"
  2. #include "services/alarm_editor_service.h"
  3. #include "services/alarm_service.h"
  4. #include "services/hmi_editor_service.h"
  5. #include "services/hmi_navigation_service.h"
  6. #include "services/hmi_runtime_service.h"
  7. #include "services/logic_editor_service.h"
  8. #include "services/offline_simulation_service.h"
  9. #include "services/project_service.h"
  10. #include "services/register_monitor_service.h"
  11. #include "services/runtime_mode_service.h"
  12. #include "support/test_support.h"
  13. #include "ui/alarm_configuration_dialog.h"
  14. #include "ui/free_monitor_widget.h"
  15. #include "ui/hmi_editor_widget.h"
  16. #include "ui/logic_editor_widget.h"
  17. #include "ui/property_panel_controller.h"
  18. #include "ui/runtime_monitor_widget.h"
  19. #include "ui/runtime_panel_controller.h"
  20. #include "ui_main_window.h"
  21. #include <QApplication>
  22. #include <QCoreApplication>
  23. #include <QEventLoop>
  24. #include <QGraphicsLineItem>
  25. #include <QGraphicsRectItem>
  26. #include <QGraphicsScene>
  27. #include <QGraphicsSimpleTextItem>
  28. #include <QImage>
  29. #include <QKeyEvent>
  30. #include <QLabel>
  31. #include <QLineEdit>
  32. #include <QMainWindow>
  33. #include <QComboBox>
  34. #include <QMouseEvent>
  35. #include <QPainter>
  36. #include <QScrollBar>
  37. #include <QSpinBox>
  38. #include <QWidget>
  39. #include <algorithm>
  40. #include <iostream>
  41. #include <stdexcept>
  42. #include <string>
  43. #include <vector>
  44. namespace {
  45. LogicEditorResult setConditionAtColumn(
  46. LogicEditorService &editor,
  47. const std::string &logic_id,
  48. const std::string &rung_id,
  49. int column,
  50. const LogicNodeConfig &config,
  51. bool configured)
  52. {
  53. return editor.applyConditionAndAdvance(
  54. logic_id, {rung_id, column, false}, config, configured).edit;
  55. }
  56. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  57. using TestSupport::require;
  58. ControlLogic makeAlwaysOnLogic()
  59. {
  60. ControlLogic logic;
  61. logic.id = "queued-trace-logic";
  62. logic.name = "Queued trace logic";
  63. LadderRung rung;
  64. rung.id = "always-on-rung";
  65. rung.name = "Always on";
  66. for (int column = 0;
  67. column < ProjectLimits::kMaximumConditionColumns;
  68. ++column)
  69. {
  70. rung.cells.push_back({
  71. "always-on-cell-" + std::to_string(column),
  72. LadderCellKind::Wire,
  73. std::nullopt});
  74. }
  75. LogicNode output;
  76. output.id = "always-on-output";
  77. output.config = CoilNodeConfig{
  78. RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal};
  79. rung.output = output;
  80. logic.rungs.push_back(rung);
  81. return logic;
  82. }
  83. void testMonitorOffersAllNumericTypes()
  84. {
  85. VirtualRegisterRepository repository;
  86. RegisterMonitorService service(repository);
  87. FreeMonitorWidget widget(service);
  88. QComboBox *type_combo = widget.findChild<QComboBox *>(
  89. QStringLiteral("dataTypeComboBox"));
  90. require(type_combo != nullptr
  91. && type_combo->count() == 4
  92. && type_combo->itemData(0).toInt()
  93. == static_cast<int>(RegisterDataType::Int16)
  94. && type_combo->itemData(1).toInt()
  95. == static_cast<int>(RegisterDataType::Int32)
  96. && type_combo->itemData(2).toInt()
  97. == static_cast<int>(RegisterDataType::Float32)
  98. && type_combo->itemData(3).toInt()
  99. == static_cast<int>(RegisterDataType::Float64)
  100. && type_combo->itemText(3)
  101. == QStringLiteral("Double (Float64)"),
  102. "shared data/free monitor UI must expose all four numeric types");
  103. }
  104. void testAlarmConfigurationOffersMOnAndMOff()
  105. {
  106. TestProjectStorage storage;
  107. ProjectService project_service(storage, defaultProjectLimitSettings());
  108. AlarmEditorService alarm_editor_service(project_service);
  109. AlarmConfigurationDialog dialog(alarm_editor_service);
  110. QComboBox *area_combo = dialog.findChild<QComboBox *>(
  111. QStringLiteral("areaComboBox"));
  112. QComboBox *condition_combo = dialog.findChild<QComboBox *>(
  113. QStringLiteral("conditionComboBox"));
  114. require(area_combo != nullptr && condition_combo != nullptr,
  115. "alarm configuration must expose its area and condition inputs");
  116. require(area_combo->currentData().toInt()
  117. == static_cast<int>(RegisterArea::M)
  118. && condition_combo->count() == 2
  119. && condition_combo->findData(
  120. static_cast<int>(AlarmCondition::MOn)) >= 0
  121. && condition_combo->findData(
  122. static_cast<int>(AlarmCondition::MOff)) >= 0
  123. && condition_combo->findText(QStringLiteral("M 为 ON")) >= 0
  124. && condition_combo->findText(QStringLiteral("M 为 OFF")) >= 0,
  125. "M alarm configuration must offer both ON and OFF conditions");
  126. }
  127. void testAlarmListKeepsFixedGeometryWhileRecordsChange()
  128. {
  129. TestProjectStorage storage;
  130. ProjectService project_service(storage, defaultProjectLimitSettings());
  131. VirtualRegisterRepository virtual_repository;
  132. HmiEditorService hmi_editor_service(project_service, HmiDefaultSettings{});
  133. HmiRuntimeService hmi_runtime_service(virtual_repository);
  134. AlarmService alarm_service(project_service, virtual_repository);
  135. const HmiEditorResult page = hmi_editor_service.ensureDefaultPage();
  136. const HmiEditorResult alarm_list = hmi_editor_service.addControl(
  137. page.id, HmiControlType::AlarmList);
  138. require(page.succeeded && alarm_list.succeeded,
  139. "alarm-list fixture must create a page and control");
  140. project_service.editProject().alarmDefinitions.push_back({
  141. "fixed-alarm",
  142. RegisterAddress{RegisterArea::M, 0},
  143. AlarmCondition::MOn,
  144. 0,
  145. "Fixed alarm"});
  146. HmiEditorWidget widget(
  147. hmi_editor_service, hmi_runtime_service, alarm_service);
  148. widget.setPageId(page.id);
  149. widget.setEditingEnabled(false);
  150. widget.setRuntimeActive(true);
  151. widget.refreshRuntimeValues();
  152. QGraphicsItem *alarm_item = nullptr;
  153. for (QGraphicsItem *item : widget.scene()->items())
  154. {
  155. if (dynamic_cast<QGraphicsRectItem *>(item) == nullptr)
  156. {
  157. alarm_item = item;
  158. break;
  159. }
  160. }
  161. const HmiControl *control = hmi_editor_service.findControl(
  162. page.id, alarm_list.id);
  163. require(alarm_item != nullptr && control != nullptr,
  164. "runtime HMI scene must contain the alarm-list item");
  165. const QRectF fixed_bounds = alarm_item->boundingRect();
  166. require(alarm_item->isVisible()
  167. && qFuzzyCompare(
  168. fixed_bounds.width() + 1.0,
  169. static_cast<qreal>(control->bounds.width) + 1.0)
  170. && qFuzzyCompare(
  171. fixed_bounds.height() + 1.0,
  172. static_cast<qreal>(control->bounds.height) + 1.0),
  173. "an empty runtime alarm list must remain visible at its configured size");
  174. require(virtual_repository.writeBit(
  175. RegisterAddress{RegisterArea::M, 0}, true).succeeded,
  176. "alarm-list fixture must activate M0");
  177. alarm_service.refresh();
  178. widget.refreshRuntimeValues();
  179. require(alarm_service.records().size() == 1U
  180. && alarm_item->isVisible()
  181. && alarm_item->boundingRect() == fixed_bounds,
  182. "adding an alarm row must not resize or hide the alarm control");
  183. require(virtual_repository.writeBit(
  184. RegisterAddress{RegisterArea::M, 0}, false).succeeded,
  185. "alarm-list fixture must restore M0");
  186. alarm_service.refresh();
  187. widget.refreshRuntimeValues();
  188. require(alarm_service.records().empty()
  189. && alarm_item->isVisible()
  190. && alarm_item->boundingRect() == fixed_bounds,
  191. "removing the last alarm row must keep the fixed alarm control visible");
  192. }
  193. void testHmiPropertyPanelInfersFixedBindingAreas()
  194. {
  195. TestProjectStorage storage;
  196. ProjectService project_service(storage, defaultProjectLimitSettings());
  197. VirtualRegisterRepository repository;
  198. HmiDefaultSettings hmi_defaults;
  199. HmiEditorService hmi_editor_service(project_service, hmi_defaults);
  200. HmiRuntimeService hmi_runtime_service(repository);
  201. AlarmService alarm_service(project_service, repository);
  202. LogicEditorService logic_editor_service(project_service);
  203. const HmiEditorResult page = hmi_editor_service.ensureDefaultPage();
  204. const HmiEditorResult button = hmi_editor_service.addControl(
  205. page.id, HmiControlType::Button);
  206. const HmiEditorResult second_button = hmi_editor_service.addControl(
  207. page.id, HmiControlType::Button);
  208. const HmiEditorResult display = hmi_editor_service.addControl(
  209. page.id, HmiControlType::NumericDisplay);
  210. const HmiEditorResult second_display = hmi_editor_service.addControl(
  211. page.id, HmiControlType::NumericDisplay);
  212. require(page.succeeded && button.succeeded && second_button.succeeded
  213. && display.succeeded && second_display.succeeded,
  214. "property-panel fixture must create its page and controls");
  215. const HmiControl *default_button = hmi_editor_service.findControl(
  216. page.id, button.id);
  217. const HmiControl *default_second_button = hmi_editor_service.findControl(
  218. page.id, second_button.id);
  219. const HmiControl *default_display = hmi_editor_service.findControl(
  220. page.id, display.id);
  221. const HmiControl *default_second_display = hmi_editor_service.findControl(
  222. page.id, second_display.id);
  223. require(default_button != nullptr && default_second_button != nullptr
  224. && default_display != nullptr && default_second_display != nullptr
  225. && default_button->binding
  226. == RegisterAddress{RegisterArea::M, 0}
  227. && default_second_button->binding
  228. == RegisterAddress{RegisterArea::M, 1}
  229. && default_display->binding
  230. == RegisterAddress{RegisterArea::D, 0}
  231. && default_second_display->binding
  232. == RegisterAddress{RegisterArea::D, 1},
  233. "new HMI controls must receive sequential default M/D addresses");
  234. QMainWindow parent;
  235. Ui::MainWindow ui;
  236. ui.setupUi(&parent);
  237. HmiEditorWidget hmi_editor_widget(
  238. hmi_editor_service, hmi_runtime_service, alarm_service, &parent);
  239. LogicEditorWidget logic_editor_widget(logic_editor_service, &parent);
  240. hmi_editor_widget.setPageId(page.id);
  241. std::string selected_control_id;
  242. std::string selected_logic_node_id;
  243. PropertyPanelController controller(
  244. parent,
  245. ui,
  246. project_service,
  247. hmi_editor_service,
  248. logic_editor_service,
  249. hmi_defaults,
  250. [&page] { return page.id; },
  251. [] { return std::string{}; },
  252. selected_control_id,
  253. selected_logic_node_id,
  254. [] {},
  255. [](const QString &, const QString &message, bool succeeded)
  256. {
  257. require(succeeded, message.toStdString());
  258. },
  259. [](const QString &, int) {});
  260. controller.configure();
  261. controller.bindEditorWidgets(hmi_editor_widget, logic_editor_widget);
  262. controller.showControlProperties(button.id);
  263. require(ui.bindingAreaLabel->text()
  264. == QStringLiteral("绑定地址(M)")
  265. && ui.bindingIndexSpinBox->isEnabled()
  266. && ui.bindingIndexSpinBox->value() == 0,
  267. "button properties must show the fixed M address");
  268. ui.bindingIndexSpinBox->setValue(17);
  269. controller.applySelectedControlProperties();
  270. const HmiControl *updated_button = hmi_editor_service.findControl(
  271. page.id, button.id);
  272. require(updated_button != nullptr
  273. && updated_button->binding
  274. == RegisterAddress{RegisterArea::M, 17},
  275. "button property submission must infer the M area");
  276. controller.showControlProperties(display.id);
  277. require(ui.bindingAreaLabel->text()
  278. == QStringLiteral("绑定地址(D)")
  279. && ui.bindingIndexSpinBox->isEnabled()
  280. && ui.bindingIndexSpinBox->value() == 0,
  281. "numeric properties must show the fixed D address");
  282. ui.bindingIndexSpinBox->setValue(23);
  283. controller.applySelectedControlProperties();
  284. const HmiControl *updated_display = hmi_editor_service.findControl(
  285. page.id, display.id);
  286. require(updated_display != nullptr
  287. && updated_display->binding
  288. == RegisterAddress{RegisterArea::D, 23},
  289. "numeric property submission must infer the D area");
  290. }
  291. void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing()
  292. {
  293. TestProjectStorage storage;
  294. ProjectService project_service(storage, defaultProjectLimitSettings());
  295. VirtualRegisterRepository virtual_repository;
  296. OfflineSimulationService simulation_service(virtual_repository);
  297. OnlineLogicMonitorService online_monitor_service(virtual_repository);
  298. LogicEditorService logic_editor_service(project_service);
  299. RuntimeModeService runtime_mode_service(
  300. project_service,
  301. logic_editor_service,
  302. simulation_service,
  303. online_monitor_service);
  304. HmiEditorService hmi_editor_service(project_service, HmiDefaultSettings{});
  305. HmiRuntimeService hmi_runtime_service(virtual_repository);
  306. HmiNavigationService hmi_navigation_service(project_service);
  307. AlarmService alarm_service(project_service, virtual_repository);
  308. RegisterMonitorService register_monitor_service(virtual_repository);
  309. const ControlLogic logic = makeAlwaysOnLogic();
  310. project_service.editProject().controlLogics.push_back(logic);
  311. QWidget parent;
  312. HmiEditorWidget hmi_editor_widget(
  313. hmi_editor_service, hmi_runtime_service, alarm_service, &parent);
  314. LogicEditorWidget logic_editor_widget(logic_editor_service, &parent);
  315. QLabel executor_status_label(&parent);
  316. std::string current_logic_id = logic.id;
  317. logic_editor_widget.setLogicId(current_logic_id);
  318. RuntimePanelController controller(
  319. parent,
  320. runtime_mode_service,
  321. project_service,
  322. hmi_editor_service,
  323. hmi_runtime_service,
  324. logic_editor_service,
  325. hmi_navigation_service,
  326. alarm_service,
  327. register_monitor_service,
  328. hmi_editor_widget,
  329. logic_editor_widget,
  330. executor_status_label,
  331. [&current_logic_id] { return current_logic_id; },
  332. [&current_logic_id](const std::string &logic_id)
  333. {
  334. current_logic_id = logic_id;
  335. },
  336. [](const std::string &) {},
  337. [](const QString &, int) {},
  338. [](const QString &) {},
  339. [] {});
  340. controller.configure();
  341. require(runtime_mode_service.enterOfflineRunning().succeeded,
  342. "offline simulation must start for queued trace regression");
  343. controller.enterRuntime(
  344. {}, logic.id, runtime_mode_service.mode(),
  345. runtime_mode_service.plcConnectionState());
  346. QLabel *logic_label = controller.runtimeMonitorWidget()
  347. ->findChild<QLabel *>(QStringLiteral("logicLabel"));
  348. QLabel *notice_label = controller.runtimeMonitorWidget()
  349. ->findChild<QLabel *>(QStringLiteral("logicNoticeLabel"));
  350. require(logic_label != nullptr && notice_label != nullptr,
  351. "runtime monitor must expose its local trace labels");
  352. require(logic_label->text() == QStringLiteral("梯形图运行状态")
  353. && !notice_label->isVisible(),
  354. "offline runtime must keep the normal ladder trace heading");
  355. controller.runtimeMonitorWidget()->setMode(
  356. ApplicationMode::OnlineRunning, PlcConnectionState::Connected);
  357. require(logic_label->text() == QStringLiteral("本地推算轨迹")
  358. && notice_label->isVisible()
  359. && notice_label->text().contains(QStringLiteral("不写入 PLC 程序或 M/D"))
  360. && notice_label->text().contains(QStringLiteral("HMI 和自由监控仍可写 PLC")),
  361. "online runtime must explain the read-only local trace boundary");
  362. controller.runtimeMonitorWidget()->setMode(
  363. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  364. require(simulation_service.executeOnce().succeeded,
  365. "offline simulation must produce a trace before editing");
  366. require(simulation_service.traceSnapshot()
  367. .forLogic(logic.id).rungValues.at("always-on-rung"),
  368. "the queued trace must contain an energized rung");
  369. QCoreApplication::processEvents(QEventLoop::AllEvents);
  370. LogicEditorWidget *runtime_logic_view = controller.runtimeMonitorWidget()
  371. ->findChild<LogicEditorWidget *>(QStringLiteral("runtimeLogicView"));
  372. bool found_active_runtime_output = false;
  373. require(runtime_logic_view != nullptr,
  374. "runtime monitor must own the ladder trace view");
  375. for (QGraphicsItem *item : runtime_logic_view->scene()->items())
  376. {
  377. if (item->data(0).toString() == QStringLiteral("output")
  378. && item->data(1).toString()
  379. == QStringLiteral("always-on-rung"))
  380. {
  381. found_active_runtime_output = item->data(3).toBool()
  382. && item->data(4).toBool();
  383. }
  384. }
  385. require(found_active_runtime_output,
  386. "the full executor snapshot must reach the runtime ladder exactly once");
  387. require(simulation_service.executeOnce().succeeded,
  388. "a second scan must queue the stale-trace regression event");
  389. require(runtime_mode_service.enterEditing().succeeded,
  390. "offline simulation must return to editing before queued delivery");
  391. controller.leaveRuntime(
  392. runtime_mode_service.mode(), runtime_mode_service.plcConnectionState());
  393. logic_editor_widget.clearRuntimeTrace();
  394. const auto runtimeOutputIsActive = [runtime_logic_view]
  395. {
  396. for (QGraphicsItem *item : runtime_logic_view->scene()->items())
  397. {
  398. if (item->data(0).toString() == QStringLiteral("output")
  399. && item->data(1).toString()
  400. == QStringLiteral("always-on-rung"))
  401. {
  402. return item->data(3).toBool() && item->data(4).toBool();
  403. }
  404. }
  405. return false;
  406. };
  407. require(!runtimeOutputIsActive(),
  408. "editing transition must initially clear the runtime trace");
  409. QCoreApplication::processEvents(QEventLoop::AllEvents);
  410. require(!runtimeOutputIsActive(),
  411. "a queued offline scan must not restore the trace after returning to editing");
  412. }
  413. void testLogicEditorGridSelectionAndDeletion()
  414. {
  415. TestProjectStorage storage;
  416. ProjectService project_service(storage, defaultProjectLimitSettings());
  417. LogicEditorService logic_editor_service(project_service);
  418. const std::string logic_id = logic_editor_service.ensureDefaultLogic().id;
  419. LogicEditorWidget widget(logic_editor_service);
  420. widget.setLogicId(logic_id);
  421. require(widget.scene()->items().isEmpty(),
  422. "an empty logic must not draw standalone power rails");
  423. require((widget.alignment() & Qt::AlignLeft) != 0
  424. && (widget.alignment() & Qt::AlignTop) != 0,
  425. "the ladder canvas must start at its top-left origin");
  426. const std::string rung_id = logic_editor_service.addRung(logic_id).id;
  427. require(!rung_id.empty(), "the grid regression fixture must create a row");
  428. require(logic_editor_service.setHorizontalWireRange(
  429. logic_id, rung_id, 4, 4, true).succeeded,
  430. "the grid regression fixture must draw one horizontal cell");
  431. widget.reloadLogic();
  432. widget.resize(1200, 400);
  433. widget.show();
  434. QCoreApplication::processEvents(QEventLoop::AllEvents);
  435. constexpr qreal left_bus = 60.0;
  436. constexpr qreal cell_width = 96.0;
  437. constexpr qreal output_width = 224.0;
  438. constexpr qreal first_grid_top = 46.0;
  439. constexpr qreal row_height = 78.0;
  440. const auto clickScene = [&widget](const QPointF &position,
  441. Qt::KeyboardModifiers modifiers = Qt::NoModifier)
  442. {
  443. const QPoint point = widget.mapFromScene(position);
  444. QMouseEvent press(
  445. QEvent::MouseButtonPress,
  446. QPointF(point),
  447. Qt::LeftButton,
  448. Qt::LeftButton,
  449. modifiers);
  450. QApplication::sendEvent(widget.viewport(), &press);
  451. QMouseEvent release(
  452. QEvent::MouseButtonRelease,
  453. QPointF(point),
  454. Qt::LeftButton,
  455. Qt::NoButton,
  456. modifiers);
  457. QApplication::sendEvent(widget.viewport(), &release);
  458. };
  459. const auto dragScene = [&widget](
  460. const QPointF &from,
  461. const QPointF &to,
  462. Qt::KeyboardModifiers modifiers = Qt::NoModifier)
  463. {
  464. const QPoint from_point = widget.mapFromScene(from);
  465. const QPoint to_point = widget.mapFromScene(to);
  466. QMouseEvent press(
  467. QEvent::MouseButtonPress,
  468. QPointF(from_point),
  469. Qt::LeftButton,
  470. Qt::LeftButton,
  471. modifiers);
  472. QApplication::sendEvent(widget.viewport(), &press);
  473. QMouseEvent move(
  474. QEvent::MouseMove,
  475. QPointF(to_point),
  476. Qt::NoButton,
  477. Qt::LeftButton,
  478. modifiers);
  479. QApplication::sendEvent(widget.viewport(), &move);
  480. QMouseEvent release(
  481. QEvent::MouseButtonRelease,
  482. QPointF(to_point),
  483. Qt::LeftButton,
  484. Qt::NoButton,
  485. modifiers);
  486. QApplication::sendEvent(widget.viewport(), &release);
  487. };
  488. clickScene(QPointF(
  489. left_bus + 4.0 * cell_width + cell_width / 2.0,
  490. first_grid_top + row_height / 2.0));
  491. require(widget.selectedRungId() == rung_id,
  492. "clicking a grid cell must select its row");
  493. QString delete_error;
  494. QObject::connect(
  495. &widget,
  496. &LogicEditorWidget::editorError,
  497. [&delete_error](const QString &message) { delete_error = message; });
  498. const LogicEditorResult deleted = widget.deleteSelected();
  499. require(deleted.succeeded,
  500. "Delete on a selected horizontal cell must succeed: "
  501. + delete_error.toStdString());
  502. const LadderRung *rung = logic_editor_service.findRung(logic_id, rung_id);
  503. require(rung != nullptr && rung->cells.size() == 10U
  504. && rung->cells[4].kind == LadderCellKind::Gap,
  505. "Delete on one horizontal cell must preserve the row and clear only that cell");
  506. widget.clearSelection();
  507. require(widget.selectedRungId().empty(),
  508. "clearing the selection must not expose the first row as selected");
  509. require(!widget.addHorizontalWire().succeeded,
  510. "the horizontal-wire command must require an explicitly selected cell");
  511. require(logic_editor_service.setHorizontalWireRange(
  512. logic_id, rung_id, 3, 3, true).succeeded,
  513. "fixture must restore a wire for precise hit testing");
  514. widget.reloadLogic();
  515. clickScene(QPointF(
  516. left_bus + 3.0 * cell_width,
  517. first_grid_top + row_height / 2.0));
  518. require(!widget.deleteSelected().succeeded,
  519. "Delete on a column boundary must not delete an adjacent cell");
  520. require(logic_editor_service.findRung(logic_id, rung_id)->cells[3].kind
  521. == LadderCellKind::Wire,
  522. "a boundary selection must preserve the adjacent horizontal wire");
  523. clickScene(QPointF(
  524. left_bus + 10.0 * cell_width + output_width / 2.0,
  525. first_grid_top + row_height / 2.0));
  526. require(!widget.deleteSelected().succeeded
  527. && logic_editor_service.findLogic(logic_id)->rungs.size() == 1U,
  528. "Delete on an empty output slot must never delete the whole row");
  529. clickScene(QPointF(
  530. left_bus + 2.0 * cell_width + cell_width / 2.0,
  531. first_grid_top + row_height / 2.0));
  532. require(widget.addHorizontalWire().succeeded
  533. && logic_editor_service.findRung(logic_id, rung_id)->cells[2].kind
  534. == LadderCellKind::Wire,
  535. "the horizontal-wire command must act on an explicitly selected cell");
  536. const LogicEditorResult first_node = setConditionAtColumn(logic_editor_service,
  537. logic_id,
  538. rung_id,
  539. 0,
  540. ContactNodeConfig{
  541. RegisterAddress{RegisterArea::M, 0},
  542. ContactMode::NormallyOpen},
  543. true);
  544. const LogicEditorResult second_node = setConditionAtColumn(logic_editor_service,
  545. logic_id,
  546. rung_id,
  547. 1,
  548. ContactNodeConfig{
  549. RegisterAddress{RegisterArea::M, 1},
  550. ContactMode::NormallyOpen},
  551. true);
  552. require(first_node.succeeded && second_node.succeeded,
  553. "fixture must create adjacent conditions for multi-selection");
  554. widget.reloadLogic();
  555. dragScene(
  556. QPointF(left_bus + 5.0, first_grid_top + 5.0),
  557. QPointF(
  558. left_bus + 2.0 * cell_width - 5.0,
  559. first_grid_top + row_height - 5.0));
  560. require(widget.selectedNodeIds().size() == 2U,
  561. "mouse drag must select multiple conditions in the same row");
  562. widget.clearSelection();
  563. clickScene(QPointF(
  564. left_bus + cell_width / 2.0,
  565. first_grid_top + row_height / 2.0));
  566. dragScene(
  567. QPointF(left_bus + cell_width + 5.0, first_grid_top + 5.0),
  568. QPointF(
  569. left_bus + 2.0 * cell_width - 5.0,
  570. first_grid_top + row_height - 5.0),
  571. Qt::ControlModifier);
  572. require(widget.selectedNodeIds().size() == 2U,
  573. "Ctrl-drag must append objects to the existing selection");
  574. require(widget.addParallelBranch(ContactNodeConfig{
  575. RegisterAddress{RegisterArea::M, 2},
  576. ContactMode::NormallyOpen}).succeeded
  577. && logic_editor_service.findLogic(logic_id)->rungs.size() == 2U,
  578. "parallel insertion must use the explicitly selected condition range");
  579. const LogicEditorResult output = logic_editor_service.setOutput(
  580. logic_id,
  581. rung_id,
  582. CoilNodeConfig{
  583. RegisterAddress{RegisterArea::M, 3}, CoilMode::Normal},
  584. true);
  585. require(output.succeeded,
  586. "syntax-location fixture must create an output instruction");
  587. widget.reloadLogic();
  588. widget.focusSyntaxLocation(
  589. rung_id, ProjectLimits::kMaximumLadderColumns);
  590. require(
  591. widget.selectedRungId() == rung_id
  592. && widget.selectedNodeId() == output.id,
  593. "a syntax error at column 11 must focus the output slot");
  594. widget.focusSyntaxLocation(rung_id, 1);
  595. require(
  596. widget.selectedRungId() == rung_id
  597. && widget.selectedNodeId() == first_node.id,
  598. "a syntax error in the condition area must focus its one-based column");
  599. }
  600. void testLadderLayoutAndDragDeletion()
  601. {
  602. TestProjectStorage storage;
  603. ProjectService project_service(storage, defaultProjectLimitSettings());
  604. LogicEditorService editor(project_service);
  605. const std::string logic_id = editor.ensureDefaultLogic().id;
  606. const std::string upper = editor.addRung(logic_id).id;
  607. const std::string lower = editor.addRung(logic_id).id;
  608. require(
  609. setConditionAtColumn(editor,
  610. logic_id,
  611. upper,
  612. 0,
  613. ContactNodeConfig{
  614. RegisterAddress{RegisterArea::M, 10},
  615. ContactMode::NormallyClosed},
  616. true).succeeded
  617. && editor.setHorizontalWireRange(
  618. logic_id, upper, 1, 1, true).succeeded
  619. && editor.setOutput(
  620. logic_id,
  621. upper,
  622. MoveNodeConfig{
  623. WordOperand{
  624. WordOperandKind::Register,
  625. RegisterAddress{RegisterArea::D, 10},
  626. 0},
  627. RegisterAddress{RegisterArea::D, 20}},
  628. true).succeeded
  629. && editor.setHorizontalWireRange(
  630. logic_id, lower, 0, 1, true).succeeded
  631. && editor.setVerticalConnection(
  632. logic_id, upper, lower, 2, true).succeeded
  633. && editor.updateNetworkComment(
  634. logic_id, lower, "主网络注释").succeeded,
  635. "layout fixture must create a connected two-row network");
  636. require(
  637. editor.findNetworkHeadRung(logic_id, lower)->id == upper
  638. && editor.findRung(logic_id, upper)->comment == "主网络注释"
  639. && editor.findRung(logic_id, lower)->comment.empty(),
  640. "editing a branch row must store the comment only on the network head");
  641. editor.clearHistory();
  642. LogicEditorWidget widget(editor);
  643. widget.setLogicId(logic_id);
  644. widget.resize(1320, 360);
  645. widget.show();
  646. QCoreApplication::processEvents(QEventLoop::AllEvents);
  647. bool found_head_comment = false;
  648. for (QGraphicsItem *item : widget.scene()->items())
  649. {
  650. auto *text = dynamic_cast<QGraphicsSimpleTextItem *>(item);
  651. if (text == nullptr)
  652. {
  653. continue;
  654. }
  655. found_head_comment = found_head_comment
  656. || text->text() == QStringLiteral("主网络注释");
  657. }
  658. require(found_head_comment,
  659. "the comment edited from a branch must render above the network head");
  660. constexpr qreal left_bus = 60.0;
  661. constexpr qreal cell_width = 96.0;
  662. constexpr qreal right_bus = left_bus + 10.0 * cell_width + 224.0;
  663. constexpr qreal first_grid_top = 46.0;
  664. constexpr qreal second_grid_bottom = first_grid_top + 2.0 * 78.0;
  665. QImage grid_image(1320, 360, QImage::Format_ARGB32_Premultiplied);
  666. grid_image.fill(Qt::transparent);
  667. {
  668. QPainter painter(&grid_image);
  669. widget.scene()->render(
  670. &painter,
  671. QRectF(0.0, 0.0, 1320.0, 360.0),
  672. QRectF(0.0, 0.0, 1320.0, 360.0),
  673. Qt::IgnoreAspectRatio);
  674. }
  675. const auto background_at = [&grid_image, first_grid_top](qreal x)
  676. {
  677. return grid_image.pixelColor(
  678. qRound(x), qRound(first_grid_top + 68.0));
  679. };
  680. const QColor node_background = background_at(
  681. left_bus + cell_width - 10.0);
  682. const QColor wire_background = background_at(
  683. left_bus + 2.0 * cell_width - 10.0);
  684. const QColor gap_background = background_at(
  685. left_bus + 3.0 * cell_width - 10.0);
  686. const QColor output_background = background_at(right_bus - 10.0);
  687. require(
  688. node_background == QColor(QStringLiteral("#ffffff"))
  689. && node_background == wire_background
  690. && wire_background == gap_background
  691. && gap_background == output_background,
  692. "node, wire, gap, and output cells must share one grid background");
  693. bool found_left_rail = false;
  694. bool found_right_rail = false;
  695. for (QGraphicsItem *item : widget.scene()->items())
  696. {
  697. auto *line_item = dynamic_cast<QGraphicsLineItem *>(item);
  698. if (line_item == nullptr || line_item->data(0).isValid())
  699. {
  700. continue;
  701. }
  702. const QLineF line = line_item->line();
  703. const bool exact_span = qFuzzyCompare(
  704. line.y1() + 1.0, first_grid_top + 1.0)
  705. && qFuzzyCompare(
  706. line.y2() + 1.0, second_grid_bottom + 1.0);
  707. found_left_rail = found_left_rail
  708. || (exact_span && qFuzzyCompare(
  709. line.x1() + 1.0, left_bus + 1.0));
  710. found_right_rail = found_right_rail
  711. || (exact_span && qFuzzyCompare(
  712. line.x1() + 1.0, right_bus + 1.0));
  713. }
  714. require(found_left_rail && found_right_rail,
  715. "both rails must share the exact first-to-last row span");
  716. const QPoint from = widget.mapFromScene(
  717. QPointF(left_bus + 4.0, first_grid_top + 4.0));
  718. const QPoint to = widget.mapFromScene(
  719. QPointF(right_bus - 4.0, second_grid_bottom - 4.0));
  720. QMouseEvent press(
  721. QEvent::MouseButtonPress,
  722. QPointF(from),
  723. Qt::LeftButton,
  724. Qt::LeftButton,
  725. Qt::NoModifier);
  726. QApplication::sendEvent(widget.viewport(), &press);
  727. QMouseEvent move(
  728. QEvent::MouseMove,
  729. QPointF(to),
  730. Qt::NoButton,
  731. Qt::LeftButton,
  732. Qt::NoModifier);
  733. QApplication::sendEvent(widget.viewport(), &move);
  734. QMouseEvent release(
  735. QEvent::MouseButtonRelease,
  736. QPointF(to),
  737. Qt::LeftButton,
  738. Qt::NoButton,
  739. Qt::NoModifier);
  740. QApplication::sendEvent(widget.viewport(), &release);
  741. require(widget.selectedNodeIds().size() == 2U,
  742. "drag selection must include the condition and output instruction");
  743. const QList<QGraphicsItem *> selected_scene_items = widget.scene()->items();
  744. require(
  745. std::any_of(
  746. selected_scene_items.cbegin(),
  747. selected_scene_items.cend(),
  748. [](QGraphicsItem *item) { return item->zValue() == 100.0; }),
  749. "selected objects must be painted by the highest selection layer");
  750. require(widget.deleteSelected().succeeded,
  751. "Delete must submit the complete drag selection once");
  752. const LadderRung *upper_rung = editor.findRung(logic_id, upper);
  753. const LadderRung *lower_rung = editor.findRung(logic_id, lower);
  754. require(
  755. upper_rung->cells[0].kind == LadderCellKind::Gap
  756. && upper_rung->cells[1].kind == LadderCellKind::Gap
  757. && !upper_rung->output.has_value()
  758. && lower_rung->cells[0].kind == LadderCellKind::Gap
  759. && lower_rung->cells[1].kind == LadderCellKind::Gap
  760. && editor.findLogic(logic_id)->verticalConnections.empty(),
  761. "drag deletion must clear cells, output, and vertical connection together");
  762. require(editor.undo().succeeded,
  763. "one undo must restore the complete drag deletion");
  764. upper_rung = editor.findRung(logic_id, upper);
  765. require(
  766. upper_rung->cells[0].kind == LadderCellKind::Node
  767. && upper_rung->cells[1].kind == LadderCellKind::Wire
  768. && upper_rung->output.has_value()
  769. && editor.findLogic(logic_id)->verticalConnections.size() == 1U,
  770. "one undo must restore every object removed by the drag selection");
  771. }
  772. void testWireGesturePreviewKeepsAtomicCommit()
  773. {
  774. TestProjectStorage storage;
  775. ProjectService project_service(storage, defaultProjectLimitSettings());
  776. LogicEditorService editor(project_service);
  777. const std::string logic_id = editor.ensureDefaultLogic().id;
  778. const std::string first = editor.addRung(logic_id).id;
  779. const std::string second = editor.addRung(logic_id).id;
  780. editor.clearHistory();
  781. project_service.restoreModifiedState(false);
  782. LogicEditorWidget widget(editor);
  783. widget.setLogicId(logic_id);
  784. widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Draw);
  785. widget.resize(1200, 360);
  786. widget.show();
  787. QCoreApplication::processEvents(QEventLoop::AllEvents);
  788. constexpr qreal left_bus = 60.0;
  789. constexpr qreal cell_width = 96.0;
  790. constexpr qreal first_row_center = 91.0;
  791. constexpr qreal second_row_center = 197.0;
  792. const QPoint first_cell = widget.mapFromScene(QPointF(
  793. left_bus + cell_width / 2.0, first_row_center));
  794. const QPoint fourth_cell = widget.mapFromScene(QPointF(
  795. left_bus + 3.0 * cell_width + cell_width / 2.0,
  796. first_row_center));
  797. const QPoint invalid_target = widget.mapFromScene(QPointF(
  798. left_bus + 3.0 * cell_width + cell_width / 2.0,
  799. second_row_center));
  800. const auto press = [&widget](const QPoint &point)
  801. {
  802. QMouseEvent event(
  803. QEvent::MouseButtonPress,
  804. QPointF(point),
  805. Qt::LeftButton,
  806. Qt::LeftButton,
  807. Qt::NoModifier);
  808. QApplication::sendEvent(widget.viewport(), &event);
  809. };
  810. const auto move = [&widget](const QPoint &point)
  811. {
  812. QMouseEvent event(
  813. QEvent::MouseMove,
  814. QPointF(point),
  815. Qt::NoButton,
  816. Qt::LeftButton,
  817. Qt::NoModifier);
  818. QApplication::sendEvent(widget.viewport(), &event);
  819. QCoreApplication::processEvents(QEventLoop::AllEvents);
  820. };
  821. const auto release = [&widget](const QPoint &point)
  822. {
  823. QMouseEvent event(
  824. QEvent::MouseButtonRelease,
  825. QPointF(point),
  826. Qt::LeftButton,
  827. Qt::NoButton,
  828. Qt::NoModifier);
  829. QApplication::sendEvent(widget.viewport(), &event);
  830. QCoreApplication::processEvents(QEventLoop::AllEvents);
  831. };
  832. const auto countPreviewPixels = [&widget](bool invalid)
  833. {
  834. const QImage image = widget.viewport()->grab().toImage();
  835. int count = 0;
  836. for (int y = 0; y < image.height(); ++y)
  837. {
  838. for (int x = 0; x < image.width(); ++x)
  839. {
  840. const QColor color = image.pixelColor(x, y);
  841. const bool matches = invalid
  842. ? color.red() > color.green() + 45
  843. && color.red() > color.blue() + 25
  844. : color.blue() > color.red() + 50
  845. && color.blue() > color.green() + 20;
  846. count += matches ? 1 : 0;
  847. }
  848. }
  849. return count;
  850. };
  851. QString error_message;
  852. QObject::connect(
  853. &widget,
  854. &LogicEditorWidget::editorError,
  855. [&error_message](const QString &message) { error_message = message; });
  856. press(first_cell);
  857. move(fourth_cell);
  858. require(countPreviewPixels(false) > 20,
  859. "a legal drag must paint a visible temporary wire preview");
  860. const LadderRung *first_rung = editor.findRung(logic_id, first);
  861. require(
  862. std::all_of(
  863. first_rung->cells.cbegin(),
  864. first_rung->cells.cend(),
  865. [](const LadderCell &cell)
  866. {
  867. return cell.kind == LadderCellKind::Gap;
  868. })
  869. && !project_service.isModified()
  870. && !editor.canUndo(),
  871. "the temporary preview must not modify the project or history");
  872. move(invalid_target);
  873. require(countPreviewPixels(true) > 20,
  874. "an invalid drag direction must switch the preview to an error color");
  875. release(invalid_target);
  876. first_rung = editor.findRung(logic_id, first);
  877. require(
  878. !error_message.isEmpty()
  879. && std::all_of(
  880. first_rung->cells.cbegin(),
  881. first_rung->cells.cend(),
  882. [](const LadderCell &cell)
  883. {
  884. return cell.kind == LadderCellKind::Gap;
  885. })
  886. && editor.findRung(logic_id, second) != nullptr
  887. && !project_service.isModified()
  888. && !editor.canUndo(),
  889. "releasing an invalid gesture must discard its complete preview");
  890. error_message.clear();
  891. press(first_cell);
  892. move(fourth_cell);
  893. require(!project_service.isModified() && !editor.canUndo(),
  894. "a legal gesture must remain temporary until mouse release");
  895. release(fourth_cell);
  896. first_rung = editor.findRung(logic_id, first);
  897. require(
  898. error_message.isEmpty()
  899. && first_rung->cells[0].kind == LadderCellKind::Wire
  900. && first_rung->cells[1].kind == LadderCellKind::Wire
  901. && first_rung->cells[2].kind == LadderCellKind::Wire
  902. && first_rung->cells[3].kind == LadderCellKind::Wire
  903. && project_service.isModified()
  904. && editor.canUndo(),
  905. "releasing a legal gesture must commit its complete wire range once");
  906. require(editor.undo().succeeded && !editor.canUndo(),
  907. "one undo must remove the complete committed gesture");
  908. first_rung = editor.findRung(logic_id, first);
  909. require(
  910. std::all_of(
  911. first_rung->cells.cbegin(),
  912. first_rung->cells.cend(),
  913. [](const LadderCell &cell)
  914. {
  915. return cell.kind == LadderCellKind::Gap;
  916. }),
  917. "undo must restore every cell changed by the gesture");
  918. }
  919. void testEscapeExitsMouseWireMode()
  920. {
  921. TestProjectStorage storage;
  922. ProjectService project_service(storage, defaultProjectLimitSettings());
  923. LogicEditorService editor(project_service);
  924. LogicEditorWidget widget(editor);
  925. const auto press_escape = [&widget]
  926. {
  927. QKeyEvent event(
  928. QEvent::KeyPress,
  929. Qt::Key_Escape,
  930. Qt::NoModifier);
  931. QApplication::sendEvent(&widget, &event);
  932. require(event.isAccepted(), "escape must be consumed by the logic editor");
  933. };
  934. widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Draw);
  935. press_escape();
  936. require(
  937. widget.mouseWireMode() == LogicEditorWidget::MouseWireMode::Select,
  938. "escape must exit mouse draw mode");
  939. widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Erase);
  940. press_escape();
  941. require(
  942. widget.mouseWireMode() == LogicEditorWidget::MouseWireMode::Select,
  943. "escape must exit mouse erase mode");
  944. }
  945. void testCursorAdvanceAndInlineCommandInput()
  946. {
  947. TestProjectStorage storage;
  948. ProjectService project_service(storage, defaultProjectLimitSettings());
  949. LogicEditorService editor(project_service);
  950. const std::string logic_id = editor.ensureDefaultLogic().id;
  951. LogicEditorWidget widget(editor);
  952. widget.setLogicId(logic_id);
  953. widget.resize(1320, 440);
  954. widget.show();
  955. QCoreApplication::processEvents(QEventLoop::AllEvents);
  956. for (int column = 0;
  957. column < ProjectLimits::kMaximumConditionColumns;
  958. ++column)
  959. {
  960. require(widget.addHorizontalWire().succeeded,
  961. "repeated toolbar wire input must advance through all ten cells");
  962. }
  963. const ControlLogic *logic = editor.findLogic(logic_id);
  964. require(logic != nullptr && logic->rungs.size() == 1U,
  965. "the first wire on empty logic must atomically create one row");
  966. for (const LadderCell &cell : logic->rungs.front().cells)
  967. {
  968. require(cell.kind == LadderCellKind::Wire,
  969. "ten repeated wire actions must fill ten distinct cells");
  970. }
  971. require(widget.setOutput(
  972. CoilNodeConfig{
  973. RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal},
  974. true).succeeded,
  975. "the output action must succeed after the tenth cell");
  976. logic = editor.findLogic(logic_id);
  977. require(logic->rungs.size() == 2U
  978. && logic->rungs.front().output.has_value(),
  979. "the output action must append the next empty row atomically");
  980. require(widget.addHorizontalWire().succeeded
  981. && editor.findLogic(logic_id)->rungs[1].cells[0].kind
  982. == LadderCellKind::Wire,
  983. "the toolbar cursor must continue at the appended row first cell");
  984. constexpr qreal left_bus = 60.0;
  985. constexpr qreal cell_width = 96.0;
  986. constexpr qreal output_width = 224.0;
  987. constexpr qreal second_row_center = 197.0;
  988. const auto double_click = [&widget](const QPointF &scene_point)
  989. {
  990. const QPoint point = widget.mapFromScene(scene_point);
  991. QMouseEvent event(
  992. QEvent::MouseButtonDblClick,
  993. QPointF(point),
  994. Qt::LeftButton,
  995. Qt::LeftButton,
  996. Qt::NoModifier);
  997. QApplication::sendEvent(widget.viewport(), &event);
  998. QCoreApplication::processEvents(QEventLoop::AllEvents);
  999. };
  1000. const auto press_enter = [](QLineEdit *input)
  1001. {
  1002. QKeyEvent event(
  1003. QEvent::KeyPress,
  1004. Qt::Key_Return,
  1005. Qt::NoModifier);
  1006. QApplication::sendEvent(input, &event);
  1007. QCoreApplication::processEvents(QEventLoop::AllEvents);
  1008. };
  1009. double_click(QPointF(
  1010. left_bus + 1.5 * cell_width,
  1011. second_row_center));
  1012. QLineEdit *input = widget.findChild<QLineEdit *>(
  1013. QStringLiteral("logicCommandInput"));
  1014. require(input != nullptr && input->isVisible()
  1015. && input->completer() != nullptr,
  1016. "double-clicking a cell must open the inline command editor with completion");
  1017. const int first_input_left = input->geometry().left();
  1018. input->setText(QStringLiteral("LD M4"));
  1019. press_enter(input);
  1020. const LadderRung &second = editor.findLogic(logic_id)->rungs[1];
  1021. require(second.cells[1].node.has_value()
  1022. && input->isVisible()
  1023. && input->geometry().left() > first_input_left,
  1024. "a committed inline condition must move the editor one cell right");
  1025. double_click(QPointF(
  1026. left_bus + ProjectLimits::kMaximumConditionColumns * cell_width
  1027. + output_width / 2.0,
  1028. second_row_center));
  1029. input->setText(QStringLiteral("OUT M61"));
  1030. press_enter(input);
  1031. logic = editor.findLogic(logic_id);
  1032. require(logic->rungs.size() == 3U
  1033. && logic->rungs[1].output.has_value()
  1034. && input->isVisible(),
  1035. "an inline output must append a row and keep continuous input active");
  1036. }
  1037. void testVerticalWireShortcutAdvancesDownward()
  1038. {
  1039. TestProjectStorage storage;
  1040. ProjectService project_service(storage, defaultProjectLimitSettings());
  1041. LogicEditorService editor(project_service);
  1042. const std::string logic_id = editor.ensureDefaultLogic().id;
  1043. std::vector<std::string> rung_ids;
  1044. for (int row = 0; row < 4; ++row)
  1045. {
  1046. rung_ids.push_back(editor.addRung(logic_id).id);
  1047. }
  1048. editor.clearHistory();
  1049. LogicEditorWidget widget(editor);
  1050. widget.setLogicId(logic_id);
  1051. widget.resize(1320, 240);
  1052. widget.show();
  1053. QCoreApplication::processEvents(QEventLoop::AllEvents);
  1054. constexpr int boundary = 4;
  1055. constexpr qreal left_bus = 60.0;
  1056. constexpr qreal cell_width = 96.0;
  1057. constexpr qreal first_grid_top = 46.0;
  1058. constexpr qreal row_height = 78.0;
  1059. const QPoint point = widget.mapFromScene(QPointF(
  1060. left_bus + (static_cast<qreal>(boundary) + 0.5) * cell_width,
  1061. first_grid_top + row_height / 2.0));
  1062. QMouseEvent press(
  1063. QEvent::MouseButtonPress,
  1064. QPointF(point),
  1065. Qt::LeftButton,
  1066. Qt::LeftButton,
  1067. Qt::NoModifier);
  1068. QApplication::sendEvent(widget.viewport(), &press);
  1069. QMouseEvent release(
  1070. QEvent::MouseButtonRelease,
  1071. QPointF(point),
  1072. Qt::LeftButton,
  1073. Qt::NoButton,
  1074. Qt::NoModifier);
  1075. QApplication::sendEvent(widget.viewport(), &release);
  1076. require(widget.selectedRungId() == rung_ids.front(),
  1077. "the vertical shortcut fixture must select the first row");
  1078. for (std::size_t row = 0U; row + 1U < rung_ids.size(); ++row)
  1079. {
  1080. const LogicEditorResult result = widget.addVerticalWire();
  1081. require(
  1082. result.succeeded
  1083. && widget.selectedRungId() == rung_ids[row + 1U],
  1084. "each vertical shortcut must advance to the next row");
  1085. const ControlLogic *logic = editor.findLogic(logic_id);
  1086. const bool connected = std::any_of(
  1087. logic->verticalConnections.cbegin(),
  1088. logic->verticalConnections.cend(),
  1089. [&rung_ids, row, boundary](const VerticalConnection &connection)
  1090. {
  1091. return connection.upperRungId == rung_ids[row]
  1092. && connection.lowerRungId == rung_ids[row + 1U]
  1093. && connection.columnBoundary == boundary;
  1094. });
  1095. require(connected,
  1096. "repeated vertical shortcuts must stay on one column boundary");
  1097. }
  1098. require(
  1099. editor.findLogic(logic_id)->verticalConnections.size() == 3U
  1100. && widget.verticalScrollBar()->value() > 0,
  1101. "the vertical shortcut must create three segments and keep the target visible");
  1102. const LogicEditorResult at_last_row = widget.addVerticalWire();
  1103. require(
  1104. !at_last_row.succeeded
  1105. && at_last_row.message.find("末行") != std::string::npos
  1106. && editor.findLogic(logic_id)->verticalConnections.size() == 3U
  1107. && widget.selectedRungId() == rung_ids.back(),
  1108. "the vertical shortcut must stop cleanly at the last existing row");
  1109. for (int remaining = 2; remaining >= 0; --remaining)
  1110. {
  1111. require(
  1112. editor.undo().succeeded
  1113. && editor.findLogic(logic_id)->verticalConnections.size()
  1114. == static_cast<std::size_t>(remaining),
  1115. "each undo must remove exactly one shortcut-created vertical segment");
  1116. }
  1117. require(!editor.canUndo(),
  1118. "three vertical shortcuts must create exactly three history entries");
  1119. }
  1120. void testSegmentLevelTraceProjection()
  1121. {
  1122. TestProjectStorage storage;
  1123. ProjectService project_service(storage, defaultProjectLimitSettings());
  1124. LogicEditorService editor(project_service);
  1125. const std::string logic_id = editor.ensureDefaultLogic().id;
  1126. const std::string upper = editor.addRung(logic_id).id;
  1127. const std::string lower = editor.addRung(logic_id).id;
  1128. const LogicEditorResult condition = setConditionAtColumn(editor,
  1129. logic_id,
  1130. upper,
  1131. 0,
  1132. ContactNodeConfig{
  1133. RegisterAddress{RegisterArea::M, 0},
  1134. ContactMode::NormallyOpen},
  1135. true);
  1136. const LogicEditorResult output = editor.setOutput(
  1137. logic_id,
  1138. upper,
  1139. CoilNodeConfig{
  1140. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
  1141. true);
  1142. require(condition.succeeded && output.succeeded
  1143. && editor.setVerticalConnection(
  1144. logic_id, upper, lower, 0, true).succeeded,
  1145. "the segment trace fixture must create a contact, output and vertical edge");
  1146. const ControlLogic *logic = editor.findLogic(logic_id);
  1147. const std::string cell_id = logic->rungs.front().cells.front().id;
  1148. const std::string vertical_id = logic->verticalConnections.front().id;
  1149. LogicTraceSnapshot trace;
  1150. LogicTraceValues &values = trace.logicValues[logic_id];
  1151. values.cellInputPowerValues[cell_id] = true;
  1152. values.cellPowerValues[cell_id] = false;
  1153. values.rungValues[upper] = false;
  1154. values.nodeValues[output.id] = false;
  1155. LogicEditorWidget widget(editor);
  1156. widget.setLogicId(logic_id);
  1157. widget.resize(1320, 360);
  1158. widget.show();
  1159. widget.setRuntimeTrace(trace);
  1160. QCoreApplication::processEvents(QEventLoop::AllEvents);
  1161. bool found_split_contact = false;
  1162. bool found_inactive_output = false;
  1163. bool found_inactive_vertical = false;
  1164. for (QGraphicsItem *item : widget.scene()->items())
  1165. {
  1166. const QString type = item->data(0).toString();
  1167. if (type == QStringLiteral("cell")
  1168. && item->data(1).toString().toStdString() == upper
  1169. && item->data(2).toInt() == 0)
  1170. {
  1171. found_split_contact = item->data(6).toBool()
  1172. && !item->data(7).toBool();
  1173. }
  1174. else if (type == QStringLiteral("output")
  1175. && item->data(1).toString().toStdString() == upper)
  1176. {
  1177. found_inactive_output = !item->data(3).toBool()
  1178. && !item->data(4).toBool();
  1179. }
  1180. else if (type == QStringLiteral("vertical")
  1181. && item->data(1).toString().toStdString() == vertical_id)
  1182. {
  1183. found_inactive_vertical = !item->data(5).toBool();
  1184. }
  1185. }
  1186. require(found_split_contact && found_inactive_output
  1187. && found_inactive_vertical,
  1188. "trace projection must keep left/right contact power and inactive verticals separate");
  1189. values.cellPowerValues[cell_id] = true;
  1190. values.rungValues[upper] = true;
  1191. values.nodeValues[output.id] = true;
  1192. values.verticalConnectionValues[vertical_id] = true;
  1193. widget.setRuntimeTrace(trace);
  1194. bool found_active_output = false;
  1195. bool found_active_vertical = false;
  1196. for (QGraphicsItem *item : widget.scene()->items())
  1197. {
  1198. const QString type = item->data(0).toString();
  1199. if (type == QStringLiteral("output")
  1200. && item->data(1).toString().toStdString() == upper)
  1201. {
  1202. found_active_output = item->data(3).toBool()
  1203. && item->data(4).toBool();
  1204. }
  1205. else if (type == QStringLiteral("vertical")
  1206. && item->data(1).toString().toStdString() == vertical_id)
  1207. {
  1208. found_active_vertical = item->data(5).toBool();
  1209. }
  1210. }
  1211. require(found_active_output && found_active_vertical,
  1212. "explicitly energized output and vertical segments must project as active");
  1213. }
  1214. void testLogicClipboardUsesExplicitObjectAndRowSelection()
  1215. {
  1216. TestProjectStorage storage;
  1217. ProjectService project_service(storage, defaultProjectLimitSettings());
  1218. LogicEditorService editor(project_service);
  1219. const std::string logic_id = editor.ensureDefaultLogic().id;
  1220. const std::string source = editor.addRung(logic_id).id;
  1221. const std::string target = editor.addRung(logic_id).id;
  1222. require(
  1223. editor.setHorizontalWireRange(
  1224. logic_id, source, 0, 0, true).succeeded,
  1225. "clipboard fixture must create one source wire");
  1226. editor.clearHistory();
  1227. LogicEditorWidget widget(editor);
  1228. widget.setLogicId(logic_id);
  1229. widget.resize(1320, 360);
  1230. widget.show();
  1231. QCoreApplication::processEvents(QEventLoop::AllEvents);
  1232. const auto find_item = [&widget](
  1233. const QString &type,
  1234. const std::string &rung_id,
  1235. int column) -> QGraphicsItem *
  1236. {
  1237. const QList<QGraphicsItem *> items = widget.scene()->items();
  1238. const auto found = std::find_if(
  1239. items.cbegin(), items.cend(),
  1240. [&type, &rung_id, column](QGraphicsItem *item)
  1241. {
  1242. return item->data(0).toString() == type
  1243. && item->data(1).toString().toStdString() == rung_id
  1244. && (column < 0 || item->data(2).toInt() == column);
  1245. });
  1246. return found == items.cend() ? nullptr : *found;
  1247. };
  1248. const auto click_item = [&widget](QGraphicsItem *item)
  1249. {
  1250. require(item != nullptr, "clipboard test target item must exist");
  1251. const QPoint point = widget.mapFromScene(
  1252. item->sceneBoundingRect().center());
  1253. QMouseEvent press(
  1254. QEvent::MouseButtonPress,
  1255. QPointF(point),
  1256. Qt::LeftButton,
  1257. Qt::LeftButton,
  1258. Qt::NoModifier);
  1259. QApplication::sendEvent(widget.viewport(), &press);
  1260. QMouseEvent release(
  1261. QEvent::MouseButtonRelease,
  1262. QPointF(point),
  1263. Qt::LeftButton,
  1264. Qt::NoButton,
  1265. Qt::NoModifier);
  1266. QApplication::sendEvent(widget.viewport(), &release);
  1267. };
  1268. click_item(find_item(QStringLiteral("cell"), source, 0));
  1269. const LogicClipboardCopyResult wire_copy = widget.copySelection();
  1270. require(
  1271. widget.hasCopyableSelection()
  1272. && wire_copy.copy.succeeded
  1273. && wire_copy.fragment.mode == LogicClipboardMode::GridObjects
  1274. && wire_copy.fragment.cells.size() == 1U
  1275. && wire_copy.fragment.cells.front().kind == LadderCellKind::Wire
  1276. && wire_copy.fragment.rows.empty(),
  1277. "clicking one wire must copy one grid object instead of its whole row");
  1278. click_item(find_item(QStringLiteral("cell"), target, 0));
  1279. const LogicClipboardPasteResult pasted = widget.pasteClipboard(
  1280. wire_copy.fragment);
  1281. require(
  1282. pasted.edit.succeeded
  1283. && editor.findLogic(logic_id)->rungs.size() == 2U
  1284. && editor.findCell(logic_id, target, 0)->kind
  1285. == LadderCellKind::Wire,
  1286. "widget paste must place one wire without creating or copying a row");
  1287. const LogicClipboardCopyResult pasted_selection = widget.copySelection();
  1288. require(
  1289. pasted_selection.copy.succeeded
  1290. && pasted_selection.fragment.cells.size() == 1U
  1291. && pasted_selection.fragment.cells.front().kind
  1292. == LadderCellKind::Wire,
  1293. "a successful paste must select the newly pasted wire");
  1294. click_item(find_item(QStringLiteral("rowHeader"), source, -1));
  1295. const LogicClipboardCopyResult row_copy = widget.copySelection();
  1296. require(
  1297. row_copy.copy.succeeded
  1298. && row_copy.fragment.mode == LogicClipboardMode::WholeRows
  1299. && row_copy.fragment.rows.size() == 1U,
  1300. "only clicking the left row header may create a whole-row clipboard");
  1301. }
  1302. } // namespace
  1303. int main(int argc, char *argv[])
  1304. {
  1305. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  1306. QApplication application(argc, argv);
  1307. return TestSupport::runTestSuite("runtime panel controller tests", {
  1308. {"testAlarmConfigurationOffersMOnAndMOff", testAlarmConfigurationOffersMOnAndMOff},
  1309. {"testMonitorOffersAllNumericTypes", testMonitorOffersAllNumericTypes},
  1310. {"testAlarmListKeepsFixedGeometryWhileRecordsChange", testAlarmListKeepsFixedGeometryWhileRecordsChange},
  1311. {"testHmiPropertyPanelInfersFixedBindingAreas", testHmiPropertyPanelInfersFixedBindingAreas},
  1312. {"testQueuedOfflineTraceIsIgnoredAfterReturningToEditing", testQueuedOfflineTraceIsIgnoredAfterReturningToEditing},
  1313. {"testLogicEditorGridSelectionAndDeletion", testLogicEditorGridSelectionAndDeletion},
  1314. {"testLadderLayoutAndDragDeletion", testLadderLayoutAndDragDeletion},
  1315. {"testWireGesturePreviewKeepsAtomicCommit", testWireGesturePreviewKeepsAtomicCommit},
  1316. {"testEscapeExitsMouseWireMode", testEscapeExitsMouseWireMode},
  1317. {"testCursorAdvanceAndInlineCommandInput", testCursorAdvanceAndInlineCommandInput},
  1318. {"testVerticalWireShortcutAdvancesDownward", testVerticalWireShortcutAdvancesDownward},
  1319. {"testSegmentLevelTraceProjection", testSegmentLevelTraceProjection},
  1320. {"testLogicClipboardUsesExplicitObjectAndRowSelection", testLogicClipboardUsesExplicitObjectAndRowSelection},
  1321. });
  1322. }