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

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