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

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