综合平台编程器项目的远程存储
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

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