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

876 lines
34 KiB

  1. #include "domain/virtual_register_repository.h"
  2. #include "services/alarm_service.h"
  3. #include "services/hmi_editor_service.h"
  4. #include "services/hmi_navigation_service.h"
  5. #include "services/hmi_runtime_service.h"
  6. #include "services/logic_editor_service.h"
  7. #include "services/offline_simulation_service.h"
  8. #include "services/project_service.h"
  9. #include "services/register_monitor_service.h"
  10. #include "services/runtime_mode_service.h"
  11. #include "support/test_support.h"
  12. #include "ui/hmi_editor_widget.h"
  13. #include "ui/logic_editor_widget.h"
  14. #include "ui/runtime_monitor_widget.h"
  15. #include "ui/runtime_panel_controller.h"
  16. #include <QApplication>
  17. #include <QCoreApplication>
  18. #include <QEventLoop>
  19. #include <QGraphicsLineItem>
  20. #include <QGraphicsScene>
  21. #include <QGraphicsSimpleTextItem>
  22. #include <QImage>
  23. #include <QKeyEvent>
  24. #include <QLabel>
  25. #include <QLineEdit>
  26. #include <QMouseEvent>
  27. #include <QPainter>
  28. #include <QWidget>
  29. #include <algorithm>
  30. #include <iostream>
  31. #include <stdexcept>
  32. #include <string>
  33. namespace {
  34. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  35. using TestSupport::require;
  36. ControlLogic makeAlwaysOnLogic()
  37. {
  38. ControlLogic logic;
  39. logic.id = "queued-trace-logic";
  40. logic.name = "Queued trace logic";
  41. LadderRung rung;
  42. rung.id = "always-on-rung";
  43. rung.name = "Always on";
  44. for (int column = 0;
  45. column < ProjectLimits::kMaximumConditionColumns;
  46. ++column)
  47. {
  48. rung.cells.push_back({
  49. "always-on-cell-" + std::to_string(column),
  50. LadderCellKind::Wire,
  51. std::nullopt});
  52. }
  53. LogicNode output;
  54. output.id = "always-on-output";
  55. output.config = CoilNodeConfig{
  56. RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal};
  57. rung.output = output;
  58. logic.rungs.push_back(rung);
  59. return logic;
  60. }
  61. void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing()
  62. {
  63. TestProjectStorage storage;
  64. ProjectService project_service(storage);
  65. VirtualRegisterRepository virtual_repository;
  66. OfflineSimulationService simulation_service(virtual_repository);
  67. OnlineLogicMonitorService online_monitor_service(virtual_repository);
  68. LogicEditorService logic_editor_service(project_service);
  69. RuntimeModeService runtime_mode_service(
  70. project_service,
  71. logic_editor_service,
  72. simulation_service,
  73. online_monitor_service);
  74. HmiEditorService hmi_editor_service(project_service);
  75. HmiRuntimeService hmi_runtime_service(virtual_repository);
  76. HmiNavigationService hmi_navigation_service(project_service);
  77. AlarmService alarm_service(project_service, virtual_repository);
  78. RegisterMonitorService register_monitor_service(virtual_repository);
  79. const ControlLogic logic = makeAlwaysOnLogic();
  80. project_service.editProject().controlLogics.push_back(logic);
  81. QWidget parent;
  82. HmiEditorWidget hmi_editor_widget(
  83. hmi_editor_service, hmi_runtime_service, alarm_service, &parent);
  84. LogicEditorWidget logic_editor_widget(logic_editor_service, &parent);
  85. QLabel executor_status_label(&parent);
  86. std::string current_logic_id = logic.id;
  87. logic_editor_widget.setLogicId(current_logic_id);
  88. RuntimePanelController controller(
  89. parent,
  90. runtime_mode_service,
  91. project_service,
  92. hmi_editor_service,
  93. hmi_runtime_service,
  94. logic_editor_service,
  95. hmi_navigation_service,
  96. alarm_service,
  97. register_monitor_service,
  98. hmi_editor_widget,
  99. logic_editor_widget,
  100. executor_status_label,
  101. [&current_logic_id] { return current_logic_id; },
  102. [&current_logic_id](const std::string &logic_id)
  103. {
  104. current_logic_id = logic_id;
  105. },
  106. [](const std::string &) {},
  107. [](const QString &, int) {},
  108. [](const QString &) {},
  109. [] {});
  110. controller.configure();
  111. require(runtime_mode_service.enterOfflineRunning().succeeded,
  112. "offline simulation must start for queued trace regression");
  113. controller.enterRuntime(
  114. {}, logic.id, runtime_mode_service.mode(),
  115. runtime_mode_service.plcConnectionState());
  116. QLabel *logic_label = controller.runtimeMonitorWidget()
  117. ->findChild<QLabel *>(QStringLiteral("logicLabel"));
  118. QLabel *notice_label = controller.runtimeMonitorWidget()
  119. ->findChild<QLabel *>(QStringLiteral("logicNoticeLabel"));
  120. require(logic_label != nullptr && notice_label != nullptr,
  121. "runtime monitor must expose its local trace labels");
  122. require(logic_label->text() == QStringLiteral("梯形图运行状态")
  123. && !notice_label->isVisible(),
  124. "offline runtime must keep the normal ladder trace heading");
  125. controller.runtimeMonitorWidget()->setMode(
  126. ApplicationMode::OnlineRunning, PlcConnectionState::Connected);
  127. require(logic_label->text() == QStringLiteral("本地推算轨迹")
  128. && notice_label->isVisible()
  129. && notice_label->text().contains(QStringLiteral("不写入 PLC 程序或 M/D"))
  130. && notice_label->text().contains(QStringLiteral("HMI 和自由监控仍可写 PLC")),
  131. "online runtime must explain the read-only local trace boundary");
  132. controller.runtimeMonitorWidget()->setMode(
  133. ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected);
  134. require(simulation_service.executeOnce().succeeded,
  135. "offline simulation must produce a trace before editing");
  136. require(simulation_service.traceSnapshot()
  137. .forLogic(logic.id).rungValues.at("always-on-rung"),
  138. "the queued trace must contain an energized rung");
  139. QCoreApplication::processEvents(QEventLoop::AllEvents);
  140. LogicEditorWidget *runtime_logic_view = controller.runtimeMonitorWidget()
  141. ->findChild<LogicEditorWidget *>(QStringLiteral("runtimeLogicView"));
  142. bool found_active_runtime_output = false;
  143. require(runtime_logic_view != nullptr,
  144. "runtime monitor must own the ladder trace view");
  145. for (QGraphicsItem *item : runtime_logic_view->scene()->items())
  146. {
  147. if (item->data(0).toString() == QStringLiteral("output")
  148. && item->data(1).toString()
  149. == QStringLiteral("always-on-rung"))
  150. {
  151. found_active_runtime_output = item->data(3).toBool()
  152. && item->data(4).toBool();
  153. }
  154. }
  155. require(found_active_runtime_output,
  156. "the full executor snapshot must reach the runtime ladder exactly once");
  157. require(simulation_service.executeOnce().succeeded,
  158. "a second scan must queue the stale-trace regression event");
  159. require(runtime_mode_service.enterEditing().succeeded,
  160. "offline simulation must return to editing before queued delivery");
  161. controller.leaveRuntime(
  162. runtime_mode_service.mode(), runtime_mode_service.plcConnectionState());
  163. logic_editor_widget.clearRuntimeTrace();
  164. require(!logic_editor_widget.runtimeTraceEnabled(),
  165. "editing transition must initially clear the runtime trace");
  166. QCoreApplication::processEvents(QEventLoop::AllEvents);
  167. require(!logic_editor_widget.runtimeTraceEnabled(),
  168. "a queued offline scan must not restore the trace after returning to editing");
  169. }
  170. void testLogicEditorGridSelectionAndDeletion()
  171. {
  172. TestProjectStorage storage;
  173. ProjectService project_service(storage);
  174. LogicEditorService logic_editor_service(project_service);
  175. const std::string logic_id = logic_editor_service.ensureDefaultLogic().id;
  176. LogicEditorWidget widget(logic_editor_service);
  177. widget.setLogicId(logic_id);
  178. require(widget.scene()->items().isEmpty(),
  179. "an empty logic must not draw standalone power rails");
  180. require((widget.alignment() & Qt::AlignLeft) != 0
  181. && (widget.alignment() & Qt::AlignTop) != 0,
  182. "the ladder canvas must start at its top-left origin");
  183. const std::string rung_id = logic_editor_service.addRung(logic_id).id;
  184. require(!rung_id.empty(), "the grid regression fixture must create a row");
  185. require(logic_editor_service.setHorizontalWireRange(
  186. logic_id, rung_id, 4, 4, true).succeeded,
  187. "the grid regression fixture must draw one horizontal cell");
  188. widget.reloadLogic();
  189. widget.resize(1200, 400);
  190. widget.show();
  191. QCoreApplication::processEvents(QEventLoop::AllEvents);
  192. constexpr qreal left_bus = 60.0;
  193. constexpr qreal cell_width = 96.0;
  194. constexpr qreal output_width = 224.0;
  195. constexpr qreal first_grid_top = 46.0;
  196. constexpr qreal row_height = 78.0;
  197. const auto clickScene = [&widget](const QPointF &position,
  198. Qt::KeyboardModifiers modifiers = Qt::NoModifier)
  199. {
  200. const QPoint point = widget.mapFromScene(position);
  201. QMouseEvent press(
  202. QEvent::MouseButtonPress,
  203. QPointF(point),
  204. Qt::LeftButton,
  205. Qt::LeftButton,
  206. modifiers);
  207. QApplication::sendEvent(widget.viewport(), &press);
  208. QMouseEvent release(
  209. QEvent::MouseButtonRelease,
  210. QPointF(point),
  211. Qt::LeftButton,
  212. Qt::NoButton,
  213. modifiers);
  214. QApplication::sendEvent(widget.viewport(), &release);
  215. };
  216. const auto dragScene = [&widget](
  217. const QPointF &from,
  218. const QPointF &to,
  219. Qt::KeyboardModifiers modifiers = Qt::NoModifier)
  220. {
  221. const QPoint from_point = widget.mapFromScene(from);
  222. const QPoint to_point = widget.mapFromScene(to);
  223. QMouseEvent press(
  224. QEvent::MouseButtonPress,
  225. QPointF(from_point),
  226. Qt::LeftButton,
  227. Qt::LeftButton,
  228. modifiers);
  229. QApplication::sendEvent(widget.viewport(), &press);
  230. QMouseEvent move(
  231. QEvent::MouseMove,
  232. QPointF(to_point),
  233. Qt::NoButton,
  234. Qt::LeftButton,
  235. modifiers);
  236. QApplication::sendEvent(widget.viewport(), &move);
  237. QMouseEvent release(
  238. QEvent::MouseButtonRelease,
  239. QPointF(to_point),
  240. Qt::LeftButton,
  241. Qt::NoButton,
  242. modifiers);
  243. QApplication::sendEvent(widget.viewport(), &release);
  244. };
  245. clickScene(QPointF(
  246. left_bus + 4.0 * cell_width + cell_width / 2.0,
  247. first_grid_top + row_height / 2.0));
  248. require(widget.selectedRungId() == rung_id,
  249. "clicking a grid cell must select its row");
  250. QString delete_error;
  251. QObject::connect(
  252. &widget,
  253. &LogicEditorWidget::editorError,
  254. [&delete_error](const QString &message) { delete_error = message; });
  255. const LogicEditorResult deleted = widget.deleteSelected();
  256. require(deleted.succeeded,
  257. "Delete on a selected horizontal cell must succeed: "
  258. + delete_error.toStdString());
  259. const LadderRung *rung = logic_editor_service.findRung(logic_id, rung_id);
  260. require(rung != nullptr && rung->cells.size() == 10U
  261. && rung->cells[4].kind == LadderCellKind::Gap,
  262. "Delete on one horizontal cell must preserve the row and clear only that cell");
  263. widget.clearSelection();
  264. require(widget.selectedRungId().empty(),
  265. "clearing the selection must not expose the first row as selected");
  266. require(!widget.addHorizontalWire().succeeded,
  267. "the horizontal-wire command must require an explicitly selected cell");
  268. require(logic_editor_service.setHorizontalWireRange(
  269. logic_id, rung_id, 3, 3, true).succeeded,
  270. "fixture must restore a wire for precise hit testing");
  271. widget.reloadLogic();
  272. clickScene(QPointF(
  273. left_bus + 3.0 * cell_width,
  274. first_grid_top + row_height / 2.0));
  275. require(!widget.deleteSelected().succeeded,
  276. "Delete on a column boundary must not delete an adjacent cell");
  277. require(logic_editor_service.findRung(logic_id, rung_id)->cells[3].kind
  278. == LadderCellKind::Wire,
  279. "a boundary selection must preserve the adjacent horizontal wire");
  280. clickScene(QPointF(
  281. left_bus + 10.0 * cell_width + output_width / 2.0,
  282. first_grid_top + row_height / 2.0));
  283. require(!widget.deleteSelected().succeeded
  284. && logic_editor_service.findLogic(logic_id)->rungs.size() == 1U,
  285. "Delete on an empty output slot must never delete the whole row");
  286. clickScene(QPointF(
  287. left_bus + 2.0 * cell_width + cell_width / 2.0,
  288. first_grid_top + row_height / 2.0));
  289. require(widget.addHorizontalWire().succeeded
  290. && logic_editor_service.findRung(logic_id, rung_id)->cells[2].kind
  291. == LadderCellKind::Wire,
  292. "the horizontal-wire command must act on an explicitly selected cell");
  293. const LogicEditorResult first_node = logic_editor_service.setConditionAtColumn(
  294. logic_id,
  295. rung_id,
  296. 0,
  297. ContactNodeConfig{
  298. RegisterAddress{RegisterArea::M, 0},
  299. ContactMode::NormallyOpen},
  300. true);
  301. const LogicEditorResult second_node = logic_editor_service.setConditionAtColumn(
  302. logic_id,
  303. rung_id,
  304. 1,
  305. ContactNodeConfig{
  306. RegisterAddress{RegisterArea::M, 1},
  307. ContactMode::NormallyOpen},
  308. true);
  309. require(first_node.succeeded && second_node.succeeded,
  310. "fixture must create adjacent conditions for multi-selection");
  311. widget.reloadLogic();
  312. dragScene(
  313. QPointF(left_bus + 5.0, first_grid_top + 5.0),
  314. QPointF(
  315. left_bus + 2.0 * cell_width - 5.0,
  316. first_grid_top + row_height - 5.0));
  317. require(widget.selectedNodeIds().size() == 2U,
  318. "mouse drag must select multiple conditions in the same row");
  319. widget.clearSelection();
  320. clickScene(QPointF(
  321. left_bus + cell_width / 2.0,
  322. first_grid_top + row_height / 2.0));
  323. dragScene(
  324. QPointF(left_bus + cell_width + 5.0, first_grid_top + 5.0),
  325. QPointF(
  326. left_bus + 2.0 * cell_width - 5.0,
  327. first_grid_top + row_height - 5.0),
  328. Qt::ControlModifier);
  329. require(widget.selectedNodeIds().size() == 2U,
  330. "Ctrl-drag must append objects to the existing selection");
  331. require(widget.addParallelBranch(ContactNodeConfig{
  332. RegisterAddress{RegisterArea::M, 2},
  333. ContactMode::NormallyOpen}).succeeded
  334. && logic_editor_service.findLogic(logic_id)->rungs.size() == 2U,
  335. "parallel insertion must use the explicitly selected condition range");
  336. const LogicEditorResult output = logic_editor_service.setOutput(
  337. logic_id,
  338. rung_id,
  339. CoilNodeConfig{
  340. RegisterAddress{RegisterArea::M, 3}, CoilMode::Normal},
  341. true);
  342. require(output.succeeded,
  343. "syntax-location fixture must create an output instruction");
  344. widget.reloadLogic();
  345. widget.focusSyntaxLocation(
  346. rung_id, ProjectLimits::kMaximumLadderColumns);
  347. require(
  348. widget.selectedRungId() == rung_id
  349. && widget.selectedNodeId() == output.id,
  350. "a syntax error at column 11 must focus the output slot");
  351. widget.focusSyntaxLocation(rung_id, 1);
  352. require(
  353. widget.selectedRungId() == rung_id
  354. && widget.selectedNodeId() == first_node.id,
  355. "a syntax error in the condition area must focus its one-based column");
  356. }
  357. void testLadderLayoutAndDragDeletion()
  358. {
  359. TestProjectStorage storage;
  360. ProjectService project_service(storage);
  361. LogicEditorService editor(project_service);
  362. const std::string logic_id = editor.ensureDefaultLogic().id;
  363. const std::string upper = editor.addRung(logic_id).id;
  364. const std::string lower = editor.addRung(logic_id).id;
  365. require(
  366. editor.setConditionAtColumn(
  367. logic_id,
  368. upper,
  369. 0,
  370. ContactNodeConfig{
  371. RegisterAddress{RegisterArea::M, 10},
  372. ContactMode::NormallyClosed},
  373. true).succeeded
  374. && editor.setHorizontalWireRange(
  375. logic_id, upper, 1, 1, true).succeeded
  376. && editor.setOutput(
  377. logic_id,
  378. upper,
  379. MoveNodeConfig{
  380. WordOperand{
  381. WordOperandKind::Register,
  382. RegisterAddress{RegisterArea::D, 10},
  383. 0},
  384. RegisterAddress{RegisterArea::D, 20}},
  385. true).succeeded
  386. && editor.setHorizontalWireRange(
  387. logic_id, lower, 0, 1, true).succeeded
  388. && editor.setVerticalConnection(
  389. logic_id, upper, lower, 2, true).succeeded
  390. && editor.updateRungComment(
  391. logic_id, upper, "主网络注释").succeeded
  392. && editor.updateRungComment(
  393. logic_id, lower, "支路注释不应重复").succeeded,
  394. "layout fixture must create a connected two-row network");
  395. editor.clearHistory();
  396. LogicEditorWidget widget(editor);
  397. widget.setLogicId(logic_id);
  398. widget.resize(1320, 360);
  399. widget.show();
  400. QCoreApplication::processEvents(QEventLoop::AllEvents);
  401. bool found_head_comment = false;
  402. bool found_branch_comment = false;
  403. for (QGraphicsItem *item : widget.scene()->items())
  404. {
  405. auto *text = dynamic_cast<QGraphicsSimpleTextItem *>(item);
  406. if (text == nullptr)
  407. {
  408. continue;
  409. }
  410. found_head_comment = found_head_comment
  411. || text->text() == QStringLiteral("主网络注释");
  412. found_branch_comment = found_branch_comment
  413. || text->text() == QStringLiteral("支路注释不应重复");
  414. }
  415. require(found_head_comment && !found_branch_comment,
  416. "only the network-head comment must be rendered");
  417. constexpr qreal left_bus = 60.0;
  418. constexpr qreal cell_width = 96.0;
  419. constexpr qreal right_bus = left_bus + 10.0 * cell_width + 224.0;
  420. constexpr qreal first_grid_top = 46.0;
  421. constexpr qreal second_grid_bottom = first_grid_top + 2.0 * 78.0;
  422. QImage grid_image(1320, 360, QImage::Format_ARGB32_Premultiplied);
  423. grid_image.fill(Qt::transparent);
  424. {
  425. QPainter painter(&grid_image);
  426. widget.scene()->render(
  427. &painter,
  428. QRectF(0.0, 0.0, 1320.0, 360.0),
  429. QRectF(0.0, 0.0, 1320.0, 360.0),
  430. Qt::IgnoreAspectRatio);
  431. }
  432. const auto background_at = [&grid_image, first_grid_top](qreal x)
  433. {
  434. return grid_image.pixelColor(
  435. qRound(x), qRound(first_grid_top + 68.0));
  436. };
  437. const QColor node_background = background_at(
  438. left_bus + cell_width - 10.0);
  439. const QColor wire_background = background_at(
  440. left_bus + 2.0 * cell_width - 10.0);
  441. const QColor gap_background = background_at(
  442. left_bus + 3.0 * cell_width - 10.0);
  443. const QColor output_background = background_at(right_bus - 10.0);
  444. require(
  445. node_background == QColor(QStringLiteral("#ffffff"))
  446. && node_background == wire_background
  447. && wire_background == gap_background
  448. && gap_background == output_background,
  449. "node, wire, gap, and output cells must share one grid background");
  450. bool found_left_rail = false;
  451. bool found_right_rail = false;
  452. for (QGraphicsItem *item : widget.scene()->items())
  453. {
  454. auto *line_item = dynamic_cast<QGraphicsLineItem *>(item);
  455. if (line_item == nullptr || line_item->data(0).isValid())
  456. {
  457. continue;
  458. }
  459. const QLineF line = line_item->line();
  460. const bool exact_span = qFuzzyCompare(
  461. line.y1() + 1.0, first_grid_top + 1.0)
  462. && qFuzzyCompare(
  463. line.y2() + 1.0, second_grid_bottom + 1.0);
  464. found_left_rail = found_left_rail
  465. || (exact_span && qFuzzyCompare(
  466. line.x1() + 1.0, left_bus + 1.0));
  467. found_right_rail = found_right_rail
  468. || (exact_span && qFuzzyCompare(
  469. line.x1() + 1.0, right_bus + 1.0));
  470. }
  471. require(found_left_rail && found_right_rail,
  472. "both rails must share the exact first-to-last row span");
  473. const QPoint from = widget.mapFromScene(
  474. QPointF(left_bus + 4.0, first_grid_top + 4.0));
  475. const QPoint to = widget.mapFromScene(
  476. QPointF(right_bus - 4.0, second_grid_bottom - 4.0));
  477. QMouseEvent press(
  478. QEvent::MouseButtonPress,
  479. QPointF(from),
  480. Qt::LeftButton,
  481. Qt::LeftButton,
  482. Qt::NoModifier);
  483. QApplication::sendEvent(widget.viewport(), &press);
  484. QMouseEvent move(
  485. QEvent::MouseMove,
  486. QPointF(to),
  487. Qt::NoButton,
  488. Qt::LeftButton,
  489. Qt::NoModifier);
  490. QApplication::sendEvent(widget.viewport(), &move);
  491. QMouseEvent release(
  492. QEvent::MouseButtonRelease,
  493. QPointF(to),
  494. Qt::LeftButton,
  495. Qt::NoButton,
  496. Qt::NoModifier);
  497. QApplication::sendEvent(widget.viewport(), &release);
  498. require(widget.selectedNodeIds().size() == 2U,
  499. "drag selection must include the condition and output instruction");
  500. const QList<QGraphicsItem *> selected_scene_items = widget.scene()->items();
  501. require(
  502. std::any_of(
  503. selected_scene_items.cbegin(),
  504. selected_scene_items.cend(),
  505. [](QGraphicsItem *item) { return item->zValue() == 100.0; }),
  506. "selected objects must be painted by the highest selection layer");
  507. require(widget.deleteSelected().succeeded,
  508. "Delete must submit the complete drag selection once");
  509. const LadderRung *upper_rung = editor.findRung(logic_id, upper);
  510. const LadderRung *lower_rung = editor.findRung(logic_id, lower);
  511. require(
  512. upper_rung->cells[0].kind == LadderCellKind::Gap
  513. && upper_rung->cells[1].kind == LadderCellKind::Gap
  514. && !upper_rung->output.has_value()
  515. && lower_rung->cells[0].kind == LadderCellKind::Gap
  516. && lower_rung->cells[1].kind == LadderCellKind::Gap
  517. && editor.findLogic(logic_id)->verticalConnections.empty(),
  518. "drag deletion must clear cells, output, and vertical connection together");
  519. require(editor.undo().succeeded,
  520. "one undo must restore the complete drag deletion");
  521. upper_rung = editor.findRung(logic_id, upper);
  522. require(
  523. upper_rung->cells[0].kind == LadderCellKind::Node
  524. && upper_rung->cells[1].kind == LadderCellKind::Wire
  525. && upper_rung->output.has_value()
  526. && editor.findLogic(logic_id)->verticalConnections.size() == 1U,
  527. "one undo must restore every object removed by the drag selection");
  528. }
  529. void testCursorAdvanceAndInlineCommandInput()
  530. {
  531. TestProjectStorage storage;
  532. ProjectService project_service(storage);
  533. LogicEditorService editor(project_service);
  534. const std::string logic_id = editor.ensureDefaultLogic().id;
  535. LogicEditorWidget widget(editor);
  536. widget.setLogicId(logic_id);
  537. widget.resize(1320, 440);
  538. widget.show();
  539. QCoreApplication::processEvents(QEventLoop::AllEvents);
  540. for (int column = 0;
  541. column < ProjectLimits::kMaximumConditionColumns;
  542. ++column)
  543. {
  544. require(widget.addHorizontalWire().succeeded,
  545. "repeated toolbar wire input must advance through all ten cells");
  546. }
  547. const ControlLogic *logic = editor.findLogic(logic_id);
  548. require(logic != nullptr && logic->rungs.size() == 1U,
  549. "the first wire on empty logic must atomically create one row");
  550. for (const LadderCell &cell : logic->rungs.front().cells)
  551. {
  552. require(cell.kind == LadderCellKind::Wire,
  553. "ten repeated wire actions must fill ten distinct cells");
  554. }
  555. require(widget.setOutput(
  556. CoilNodeConfig{
  557. RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal},
  558. true).succeeded,
  559. "the output action must succeed after the tenth cell");
  560. logic = editor.findLogic(logic_id);
  561. require(logic->rungs.size() == 2U
  562. && logic->rungs.front().output.has_value(),
  563. "the output action must append the next empty row atomically");
  564. require(widget.addHorizontalWire().succeeded
  565. && editor.findLogic(logic_id)->rungs[1].cells[0].kind
  566. == LadderCellKind::Wire,
  567. "the toolbar cursor must continue at the appended row first cell");
  568. constexpr qreal left_bus = 60.0;
  569. constexpr qreal cell_width = 96.0;
  570. constexpr qreal output_width = 224.0;
  571. constexpr qreal second_row_center = 197.0;
  572. const auto double_click = [&widget](const QPointF &scene_point)
  573. {
  574. const QPoint point = widget.mapFromScene(scene_point);
  575. QMouseEvent event(
  576. QEvent::MouseButtonDblClick,
  577. QPointF(point),
  578. Qt::LeftButton,
  579. Qt::LeftButton,
  580. Qt::NoModifier);
  581. QApplication::sendEvent(widget.viewport(), &event);
  582. QCoreApplication::processEvents(QEventLoop::AllEvents);
  583. };
  584. const auto press_enter = [](QLineEdit *input)
  585. {
  586. QKeyEvent event(
  587. QEvent::KeyPress,
  588. Qt::Key_Return,
  589. Qt::NoModifier);
  590. QApplication::sendEvent(input, &event);
  591. QCoreApplication::processEvents(QEventLoop::AllEvents);
  592. };
  593. double_click(QPointF(
  594. left_bus + 1.5 * cell_width,
  595. second_row_center));
  596. QLineEdit *input = widget.findChild<QLineEdit *>(
  597. QStringLiteral("logicCommandInput"));
  598. require(input != nullptr && input->isVisible()
  599. && input->completer() != nullptr,
  600. "double-clicking a cell must open the inline command editor with completion");
  601. const int first_input_left = input->geometry().left();
  602. input->setText(QStringLiteral("LD M4"));
  603. press_enter(input);
  604. const LadderRung &second = editor.findLogic(logic_id)->rungs[1];
  605. require(second.cells[1].node.has_value()
  606. && input->isVisible()
  607. && input->geometry().left() > first_input_left,
  608. "a committed inline condition must move the editor one cell right");
  609. double_click(QPointF(
  610. left_bus + ProjectLimits::kMaximumConditionColumns * cell_width
  611. + output_width / 2.0,
  612. second_row_center));
  613. input->setText(QStringLiteral("OUT M61"));
  614. press_enter(input);
  615. logic = editor.findLogic(logic_id);
  616. require(logic->rungs.size() == 3U
  617. && logic->rungs[1].output.has_value()
  618. && input->isVisible(),
  619. "an inline output must append a row and keep continuous input active");
  620. }
  621. void testSegmentLevelTraceProjection()
  622. {
  623. TestProjectStorage storage;
  624. ProjectService project_service(storage);
  625. LogicEditorService editor(project_service);
  626. const std::string logic_id = editor.ensureDefaultLogic().id;
  627. const std::string upper = editor.addRung(logic_id).id;
  628. const std::string lower = editor.addRung(logic_id).id;
  629. const LogicEditorResult condition = editor.setConditionAtColumn(
  630. logic_id,
  631. upper,
  632. 0,
  633. ContactNodeConfig{
  634. RegisterAddress{RegisterArea::M, 0},
  635. ContactMode::NormallyOpen},
  636. true);
  637. const LogicEditorResult output = editor.setOutput(
  638. logic_id,
  639. upper,
  640. CoilNodeConfig{
  641. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
  642. true);
  643. require(condition.succeeded && output.succeeded
  644. && editor.setVerticalConnection(
  645. logic_id, upper, lower, 0, true).succeeded,
  646. "the segment trace fixture must create a contact, output and vertical edge");
  647. const ControlLogic *logic = editor.findLogic(logic_id);
  648. const std::string cell_id = logic->rungs.front().cells.front().id;
  649. const std::string vertical_id = logic->verticalConnections.front().id;
  650. LogicTraceSnapshot trace;
  651. LogicTraceValues &values = trace.logicValues[logic_id];
  652. values.cellInputPowerValues[cell_id] = true;
  653. values.cellPowerValues[cell_id] = false;
  654. values.rungValues[upper] = false;
  655. values.nodeValues[output.id] = false;
  656. LogicEditorWidget widget(editor);
  657. widget.setLogicId(logic_id);
  658. widget.resize(1320, 360);
  659. widget.show();
  660. widget.setRuntimeTrace(trace);
  661. QCoreApplication::processEvents(QEventLoop::AllEvents);
  662. bool found_split_contact = false;
  663. bool found_inactive_output = false;
  664. bool found_inactive_vertical = false;
  665. for (QGraphicsItem *item : widget.scene()->items())
  666. {
  667. const QString type = item->data(0).toString();
  668. if (type == QStringLiteral("cell")
  669. && item->data(1).toString().toStdString() == upper
  670. && item->data(2).toInt() == 0)
  671. {
  672. found_split_contact = item->data(6).toBool()
  673. && !item->data(7).toBool();
  674. }
  675. else if (type == QStringLiteral("output")
  676. && item->data(1).toString().toStdString() == upper)
  677. {
  678. found_inactive_output = !item->data(3).toBool()
  679. && !item->data(4).toBool();
  680. }
  681. else if (type == QStringLiteral("vertical")
  682. && item->data(1).toString().toStdString() == vertical_id)
  683. {
  684. found_inactive_vertical = !item->data(5).toBool();
  685. }
  686. }
  687. require(found_split_contact && found_inactive_output
  688. && found_inactive_vertical,
  689. "trace projection must keep left/right contact power and inactive verticals separate");
  690. values.cellPowerValues[cell_id] = true;
  691. values.rungValues[upper] = true;
  692. values.nodeValues[output.id] = true;
  693. values.verticalConnectionValues[vertical_id] = true;
  694. widget.setRuntimeTrace(trace);
  695. bool found_active_output = false;
  696. bool found_active_vertical = false;
  697. for (QGraphicsItem *item : widget.scene()->items())
  698. {
  699. const QString type = item->data(0).toString();
  700. if (type == QStringLiteral("output")
  701. && item->data(1).toString().toStdString() == upper)
  702. {
  703. found_active_output = item->data(3).toBool()
  704. && item->data(4).toBool();
  705. }
  706. else if (type == QStringLiteral("vertical")
  707. && item->data(1).toString().toStdString() == vertical_id)
  708. {
  709. found_active_vertical = item->data(5).toBool();
  710. }
  711. }
  712. require(found_active_output && found_active_vertical,
  713. "explicitly energized output and vertical segments must project as active");
  714. }
  715. void testLogicClipboardUsesExplicitObjectAndRowSelection()
  716. {
  717. TestProjectStorage storage;
  718. ProjectService project_service(storage);
  719. LogicEditorService editor(project_service);
  720. const std::string logic_id = editor.ensureDefaultLogic().id;
  721. const std::string source = editor.addRung(logic_id).id;
  722. const std::string target = editor.addRung(logic_id).id;
  723. require(
  724. editor.setHorizontalWireRange(
  725. logic_id, source, 0, 0, true).succeeded,
  726. "clipboard fixture must create one source wire");
  727. editor.clearHistory();
  728. LogicEditorWidget widget(editor);
  729. widget.setLogicId(logic_id);
  730. widget.resize(1320, 360);
  731. widget.show();
  732. QCoreApplication::processEvents(QEventLoop::AllEvents);
  733. const auto find_item = [&widget](
  734. const QString &type,
  735. const std::string &rung_id,
  736. int column) -> QGraphicsItem *
  737. {
  738. const QList<QGraphicsItem *> items = widget.scene()->items();
  739. const auto found = std::find_if(
  740. items.cbegin(), items.cend(),
  741. [&type, &rung_id, column](QGraphicsItem *item)
  742. {
  743. return item->data(0).toString() == type
  744. && item->data(1).toString().toStdString() == rung_id
  745. && (column < 0 || item->data(2).toInt() == column);
  746. });
  747. return found == items.cend() ? nullptr : *found;
  748. };
  749. const auto click_item = [&widget](QGraphicsItem *item)
  750. {
  751. require(item != nullptr, "clipboard test target item must exist");
  752. const QPoint point = widget.mapFromScene(
  753. item->sceneBoundingRect().center());
  754. QMouseEvent press(
  755. QEvent::MouseButtonPress,
  756. QPointF(point),
  757. Qt::LeftButton,
  758. Qt::LeftButton,
  759. Qt::NoModifier);
  760. QApplication::sendEvent(widget.viewport(), &press);
  761. QMouseEvent release(
  762. QEvent::MouseButtonRelease,
  763. QPointF(point),
  764. Qt::LeftButton,
  765. Qt::NoButton,
  766. Qt::NoModifier);
  767. QApplication::sendEvent(widget.viewport(), &release);
  768. };
  769. click_item(find_item(QStringLiteral("cell"), source, 0));
  770. const LogicClipboardCopyResult wire_copy = widget.copySelection();
  771. require(
  772. widget.hasCopyableSelection()
  773. && wire_copy.copy.succeeded
  774. && wire_copy.fragment.mode == LogicClipboardMode::GridObjects
  775. && wire_copy.fragment.cells.size() == 1U
  776. && wire_copy.fragment.cells.front().kind == LadderCellKind::Wire
  777. && wire_copy.fragment.rows.empty(),
  778. "clicking one wire must copy one grid object instead of its whole row");
  779. click_item(find_item(QStringLiteral("cell"), target, 0));
  780. const LogicClipboardPasteResult pasted = widget.pasteClipboard(
  781. wire_copy.fragment);
  782. require(
  783. pasted.edit.succeeded
  784. && editor.findLogic(logic_id)->rungs.size() == 2U
  785. && editor.findCell(logic_id, target, 0)->kind
  786. == LadderCellKind::Wire,
  787. "widget paste must place one wire without creating or copying a row");
  788. const LogicClipboardCopyResult pasted_selection = widget.copySelection();
  789. require(
  790. pasted_selection.copy.succeeded
  791. && pasted_selection.fragment.cells.size() == 1U
  792. && pasted_selection.fragment.cells.front().kind
  793. == LadderCellKind::Wire,
  794. "a successful paste must select the newly pasted wire");
  795. click_item(find_item(QStringLiteral("rowHeader"), source, -1));
  796. const LogicClipboardCopyResult row_copy = widget.copySelection();
  797. require(
  798. row_copy.copy.succeeded
  799. && row_copy.fragment.mode == LogicClipboardMode::WholeRows
  800. && row_copy.fragment.rows.size() == 1U,
  801. "only clicking the left row header may create a whole-row clipboard");
  802. }
  803. } // namespace
  804. int main(int argc, char *argv[])
  805. {
  806. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  807. QApplication application(argc, argv);
  808. try
  809. {
  810. testQueuedOfflineTraceIsIgnoredAfterReturningToEditing();
  811. testLogicEditorGridSelectionAndDeletion();
  812. testLadderLayoutAndDragDeletion();
  813. testCursorAdvanceAndInlineCommandInput();
  814. testSegmentLevelTraceProjection();
  815. testLogicClipboardUsesExplicitObjectAndRowSelection();
  816. }
  817. catch (const std::exception &error)
  818. {
  819. std::cerr << "runtime panel controller tests failed: "
  820. << error.what() << '\n';
  821. return 1;
  822. }
  823. std::cout << "runtime panel controller tests passed\n";
  824. return 0;
  825. }