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

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