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

1095 wiersze
44 KiB

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