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

565 lines
22 KiB

  1. #include "services/runtime_mode_service.h"
  2. #include "services/logic_editor_service.h"
  3. #include "services/offline_simulation_service.h"
  4. #include "services/project_service.h"
  5. #include "domain/active_register_repository.h"
  6. #include "domain/project_storage.h"
  7. #include "domain/register_repository.h"
  8. #include "domain/virtual_register_repository.h"
  9. #include "infrastructure/plc_register_repository.h"
  10. #include "support/test_support.h"
  11. #include <functional>
  12. #include <iostream>
  13. #include <stdexcept>
  14. #include <string>
  15. #include <utility>
  16. namespace {
  17. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  18. class ReadyPlcGateway final : public PlcCommunicationGateway
  19. {
  20. public:
  21. PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override
  22. {
  23. connection_state = PlcConnectionState::Connected;
  24. if (state_changed)
  25. {
  26. state_changed();
  27. }
  28. return {true, {}};
  29. }
  30. void disconnectDevice() override
  31. {
  32. connection_state = PlcConnectionState::Disconnected;
  33. initial_read = false;
  34. }
  35. PlcCommunicationResult setPollAddresses(
  36. const std::vector<RegisterAddress> &addresses) override
  37. {
  38. return setPollAddresses(addresses, {});
  39. }
  40. PlcCommunicationResult setPollAddresses(
  41. const std::vector<RegisterAddress> &addresses,
  42. const std::vector<RegisterWordRange> &ranges) override
  43. {
  44. if (reject_poll_configuration)
  45. {
  46. return {false, "poll configuration rejected"};
  47. }
  48. poll_addresses = addresses;
  49. poll_ranges = ranges;
  50. return {true, {}};
  51. }
  52. PlcConnectionState state() const override { return connection_state; }
  53. bool initialReadCompleted() const override { return initial_read; }
  54. PlcCommunicationError lastErrorType() const override
  55. {
  56. return PlcCommunicationError::None;
  57. }
  58. const std::string &lastError() const override { return last_error; }
  59. void setCallbacks(
  60. std::function<void()> state_callback,
  61. std::function<void(bool)> initial_callback,
  62. std::function<void()> cache_callback,
  63. std::function<void()> poll_cycle_callback,
  64. std::function<void(const std::string &)> error_callback) override
  65. {
  66. state_changed = std::move(state_callback);
  67. initial_read_changed = std::move(initial_callback);
  68. cache_updated = std::move(cache_callback);
  69. poll_cycle_completed = std::move(poll_cycle_callback);
  70. error_reported = std::move(error_callback);
  71. }
  72. void completeInitialRead()
  73. {
  74. initial_read = true;
  75. if (initial_read_changed)
  76. {
  77. initial_read_changed(true);
  78. }
  79. }
  80. void completePollCycle()
  81. {
  82. if (poll_cycle_completed)
  83. {
  84. poll_cycle_completed();
  85. }
  86. }
  87. void reportFault()
  88. {
  89. connection_state = PlcConnectionState::Faulted;
  90. initial_read = false;
  91. if (state_changed)
  92. {
  93. state_changed();
  94. }
  95. }
  96. const std::vector<RegisterAddress> &pollAddresses() const
  97. {
  98. return poll_addresses;
  99. }
  100. const std::vector<RegisterWordRange> &pollRanges() const
  101. {
  102. return poll_ranges;
  103. }
  104. bool reject_poll_configuration = false;
  105. private:
  106. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  107. bool initial_read = false;
  108. std::string last_error;
  109. std::function<void()> state_changed;
  110. std::function<void(bool)> initial_read_changed;
  111. std::function<void()> cache_updated;
  112. std::function<void()> poll_cycle_completed;
  113. std::function<void(const std::string &)> error_reported;
  114. std::vector<RegisterAddress> poll_addresses;
  115. std::vector<RegisterWordRange> poll_ranges;
  116. };
  117. using TestSupport::require;
  118. void setConditionPath(LadderRung *rung, LogicNode node)
  119. {
  120. for (int column = 0;
  121. column < ProjectLimits::kMaximumConditionColumns;
  122. ++column)
  123. {
  124. rung->cells.push_back({
  125. rung->id + "-cell-" + std::to_string(column),
  126. column == 0 ? LadderCellKind::Node : LadderCellKind::Wire,
  127. column == 0 ? std::optional<LogicNode>{node} : std::nullopt});
  128. }
  129. }
  130. void testModeTransitions()
  131. {
  132. // 验证服务将 PLC 首读状态与领域模式切换规则正确组合
  133. TestProjectStorage storage;
  134. ProjectService project_service(storage, defaultProjectLimitSettings());
  135. VirtualRegisterRepository virtual_repository;
  136. VirtualRegisterRepository plc_repository;
  137. ActiveRegisterRepository active_repository(virtual_repository);
  138. OfflineSimulationService simulation_service(virtual_repository);
  139. OnlineLogicMonitorService online_monitor_service(plc_repository);
  140. LogicEditorService logic_editor_service(project_service);
  141. ReadyPlcGateway gateway;
  142. RuntimeModeService service(
  143. project_service,
  144. logic_editor_service,
  145. simulation_service,
  146. online_monitor_service);
  147. service.configurePlc(
  148. gateway, active_repository, virtual_repository, plc_repository);
  149. Project &project = project_service.editProject();
  150. HmiPage page;
  151. page.id = "runtime-page";
  152. page.name = "Runtime";
  153. HmiControl int32_display;
  154. int32_display.id = "runtime-int32";
  155. int32_display.type = HmiControlType::NumericDisplay;
  156. int32_display.bounds = {0, 0, 100, 40};
  157. int32_display.binding = RegisterAddress{RegisterArea::D, 50};
  158. int32_display.dataType = RegisterDataType::Int32;
  159. page.controls.push_back(int32_display);
  160. HmiControl double_display = int32_display;
  161. double_display.id = "runtime-double";
  162. double_display.bounds = {120, 0, 100, 40};
  163. double_display.binding = RegisterAddress{RegisterArea::D, 60};
  164. double_display.dataType = RegisterDataType::Float64;
  165. page.controls.push_back(double_display);
  166. HmiControl double_status = int32_display;
  167. double_status.id = "runtime-double-status";
  168. double_status.type = HmiControlType::StatusText;
  169. double_status.bounds = {240, 0, 140, 40};
  170. double_status.binding = RegisterAddress{RegisterArea::D, 70};
  171. double_status.dataType = RegisterDataType::Float64;
  172. double_status.statusText = HmiStatusWordTextConfig{{
  173. {std::nullopt, 0.0, "Low"},
  174. {0.0, std::nullopt, "High"}}};
  175. page.controls.push_back(double_status);
  176. HmiControl gated_button;
  177. gated_button.id = "runtime-gated-button";
  178. gated_button.type = HmiControlType::Button;
  179. gated_button.bounds = {400, 0, 120, 40};
  180. gated_button.binding = RegisterAddress{RegisterArea::M, 30};
  181. gated_button.buttonEnableCondition = HmiButtonWordEnableCondition{
  182. RegisterAddress{RegisterArea::D, 80},
  183. RegisterDataType::Int32,
  184. HmiButtonConditionOperator::GreaterThanOrEqual,
  185. 1.0};
  186. page.controls.push_back(gated_button);
  187. project.initialHmiPageId = page.id;
  188. project.hmiPages.push_back(page);
  189. project.alarmDefinitions.push_back(
  190. {"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"});
  191. project.alarmDefinitions.push_back(
  192. {"alarm-d", {RegisterArea::D, 34}, AlarmCondition::DHigh, 100, "D alarm"});
  193. project.registerComments = {
  194. {RegisterAddress{RegisterArea::M, 3999}, "只用于说明"},
  195. {RegisterAddress{RegisterArea::D, 3999}, "只用于说明"}};
  196. ControlLogic logic;
  197. logic.id = "poll-logic";
  198. logic.name = "Poll logic";
  199. LogicNode edge;
  200. edge.id = "poll-edge";
  201. edge.config = EdgeContactNodeConfig{
  202. RegisterAddress{RegisterArea::M, 20}, EdgeMode::Rising};
  203. LogicNode edge_coil;
  204. edge_coil.id = "poll-edge-coil";
  205. edge_coil.config = CoilNodeConfig{
  206. RegisterAddress{RegisterArea::M, 21}, CoilMode::Normal};
  207. LadderRung edge_rung;
  208. edge_rung.id = "poll-edge-rung";
  209. edge_rung.name = "Poll edge";
  210. setConditionPath(&edge_rung, edge);
  211. edge_rung.output = edge_coil;
  212. logic.rungs.push_back(edge_rung);
  213. LogicNode comparison;
  214. comparison.id = "poll-comparison";
  215. comparison.config = CompareNodeConfig{
  216. RegisterAddress{RegisterArea::D, 35}, ComparisonOperator::GreaterThan, 0};
  217. LogicNode comparison_coil;
  218. comparison_coil.id = "poll-comparison-coil";
  219. comparison_coil.config = CoilNodeConfig{
  220. RegisterAddress{RegisterArea::M, 22}, CoilMode::Normal};
  221. LadderRung comparison_rung;
  222. comparison_rung.id = "poll-comparison-rung";
  223. comparison_rung.name = "Poll comparison";
  224. setConditionPath(&comparison_rung, comparison);
  225. comparison_rung.output = comparison_coil;
  226. logic.rungs.push_back(comparison_rung);
  227. LogicNode move_input;
  228. move_input.id = "poll-move-input";
  229. move_input.config = ContactNodeConfig{
  230. RegisterAddress{RegisterArea::M, 27}, ContactMode::NormallyOpen};
  231. LogicNode move_output;
  232. move_output.id = "poll-move";
  233. move_output.config = MoveNodeConfig{
  234. WordOperand{
  235. WordOperandKind::Register,
  236. RegisterAddress{RegisterArea::D, 38},
  237. 0},
  238. RegisterAddress{RegisterArea::D, 39}};
  239. LadderRung move_rung;
  240. move_rung.id = "poll-move-rung";
  241. move_rung.name = "Poll MOVE";
  242. setConditionPath(&move_rung, move_input);
  243. move_rung.output = move_output;
  244. logic.rungs.push_back(move_rung);
  245. LogicNode add_input;
  246. add_input.id = "poll-add-input";
  247. add_input.config = ContactNodeConfig{
  248. RegisterAddress{RegisterArea::M, 28}, ContactMode::NormallyOpen};
  249. LogicNode add_output;
  250. add_output.id = "poll-add";
  251. add_output.config = ArithmeticNodeConfig{
  252. ArithmeticOperation::Add,
  253. WordOperand{
  254. WordOperandKind::Register,
  255. RegisterAddress{RegisterArea::D, 40},
  256. 0},
  257. WordOperand{
  258. WordOperandKind::Constant,
  259. RegisterAddress{RegisterArea::D, 0},
  260. 1},
  261. RegisterAddress{RegisterArea::D, 41}};
  262. LadderRung add_rung;
  263. add_rung.id = "poll-add-rung";
  264. add_rung.name = "Poll ADD";
  265. setConditionPath(&add_rung, add_input);
  266. add_rung.output = add_output;
  267. logic.rungs.push_back(add_rung);
  268. project.controlLogics.push_back(logic);
  269. require(service.mode() == ApplicationMode::Editing,
  270. "service must start in editing mode");
  271. require(service.policy().allowsProjectEditing,
  272. "editing mode must allow project editing");
  273. require(service.enterOfflineRunning().succeeded,
  274. "editing mode must enter offline running");
  275. require(service.simulationState() == SimulationState::Running,
  276. "offline mode must start the software executor");
  277. require(service.policy().usesVirtualRegisters,
  278. "offline running must use virtual registers");
  279. require(service.enterOnlineRunning().error
  280. == ModeTransitionError::MustReturnToEditing,
  281. "running modes must not switch directly");
  282. require(service.enterEditing().succeeded,
  283. "offline running must return to editing");
  284. require(service.simulationState() == SimulationState::Stopped,
  285. "returning to editing must stop the software executor first");
  286. require(service.enterOnlineRunning().error
  287. == ModeTransitionError::InitialPlcReadRequired,
  288. "online running must require an initial PLC read");
  289. require(service.connectPlc(
  290. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  291. "PLC connection must be established before its initial read can complete");
  292. require(gateway.pollAddresses()
  293. == std::vector<RegisterAddress>({
  294. {RegisterArea::M, 12}, {RegisterArea::M, 20},
  295. {RegisterArea::M, 21}, {RegisterArea::M, 22},
  296. {RegisterArea::M, 27}, {RegisterArea::M, 28},
  297. {RegisterArea::M, 30},
  298. {RegisterArea::D, 34}, {RegisterArea::D, 35},
  299. {RegisterArea::D, 38}, {RegisterArea::D, 39},
  300. {RegisterArea::D, 40}, {RegisterArea::D, 41},
  301. {RegisterArea::D, 50}, {RegisterArea::D, 51},
  302. {RegisterArea::D, 60}, {RegisterArea::D, 61},
  303. {RegisterArea::D, 62}, {RegisterArea::D, 63},
  304. {RegisterArea::D, 70}, {RegisterArea::D, 71},
  305. {RegisterArea::D, 72}, {RegisterArea::D, 73},
  306. {RegisterArea::D, 80}, {RegisterArea::D, 81}})
  307. && gateway.pollRanges()
  308. == std::vector<RegisterWordRange>({
  309. {{RegisterArea::D, 50}, 2},
  310. {{RegisterArea::D, 60}, 4},
  311. {{RegisterArea::D, 70}, 4},
  312. {{RegisterArea::D, 80}, 2}}),
  313. "instructions and multi-word status text must be fully polled while comments stay metadata-only");
  314. gateway.completeInitialRead();
  315. require(service.initialPlcReadCompleted(),
  316. "service must retain the initial PLC read state");
  317. plc_repository.writeBit({RegisterArea::M, 20}, true);
  318. require(service.enterOnlineRunning().succeeded,
  319. "online running must start after an initial PLC read");
  320. require(service.simulationState() == SimulationState::Stopped,
  321. "online running must keep the offline executor stopped");
  322. require(service.onlineLogicMonitorService().state()
  323. == OnlineLogicMonitorState::Running,
  324. "online running must start the local read-only trace executor");
  325. require(!plc_repository.readBit({RegisterArea::M, 21}).value,
  326. "the local trace output must not change the PLC source repository");
  327. require(service.policy().usesPlcRegisters,
  328. "online running must use PLC registers");
  329. require(service.policy().runsLogicExecutor,
  330. "online running must advertise the local read-only trace executor");
  331. const std::uint64_t scan_count = service.onlineLogicMonitorService()
  332. .successfulScanCount();
  333. gateway.completePollCycle();
  334. require(service.onlineLogicMonitorService().successfulScanCount()
  335. == scan_count + 1U,
  336. "a completed PLC poll cycle must trigger one new local trace scan");
  337. }
  338. void testHmiOnlyRuntimeSkipsLogicExecutor()
  339. {
  340. TestProjectStorage storage;
  341. ProjectService project_service(storage, defaultProjectLimitSettings());
  342. VirtualRegisterRepository virtual_repository;
  343. PlcRegisterRepository plc_repository;
  344. ActiveRegisterRepository active_repository(virtual_repository);
  345. OfflineSimulationService simulation_service(virtual_repository);
  346. OnlineLogicMonitorService online_monitor_service(plc_repository);
  347. LogicEditorService logic_editor_service(project_service);
  348. ReadyPlcGateway gateway;
  349. RuntimeModeService service(
  350. project_service,
  351. logic_editor_service,
  352. simulation_service,
  353. online_monitor_service);
  354. service.configurePlc(
  355. gateway, active_repository, virtual_repository, plc_repository);
  356. service.setHmiOnlyRuntime(true);
  357. require(!active_repository.readBit({RegisterArea::M, 0}).succeeded,
  358. "HMI-only runtime must use the unavailable PLC cache before its first read");
  359. HmiPage page;
  360. page.id = "hmi-only-page";
  361. page.name = "HMI only";
  362. HmiControl button;
  363. button.id = "hmi-only-button";
  364. button.type = HmiControlType::Button;
  365. button.bounds = {0, 0, 100, 40};
  366. button.binding = RegisterAddress{RegisterArea::M, 0};
  367. page.controls.push_back(button);
  368. Project &project = project_service.editProject();
  369. project.hmiPages.push_back(page);
  370. project.initialHmiPageId = page.id;
  371. // 该逻辑故意不完整,HMI 专用导出不应受它阻断
  372. project.controlLogics.push_back(ControlLogic{});
  373. require(service.connectPlc(
  374. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  375. "HMI-only runtime must accept a PLC connection request");
  376. gateway.completeInitialRead();
  377. plc_repository.updateBit(0, false);
  378. require(service.enterOnlineRunning().succeeded,
  379. "HMI-only runtime must ignore ladder logic readiness");
  380. require(service.policy().usesPlcRegisters
  381. && online_monitor_service.state()
  382. == OnlineLogicMonitorState::Stopped,
  383. "HMI-only runtime must use PLC registers without starting local logic");
  384. require(active_repository.readBit({RegisterArea::M, 0}).succeeded,
  385. "HMI-only runtime must switch HMI reads to the PLC cache");
  386. plc_repository.updateBit(0, true);
  387. require(virtual_repository.writeBit({RegisterArea::M, 0}, false).succeeded
  388. && active_repository.readBit({RegisterArea::M, 0}).value,
  389. "HMI-only runtime fault fixture must separate virtual and PLC values");
  390. plc_repository.invalidate();
  391. gateway.reportFault();
  392. require(service.mode() == ApplicationMode::OnlineRunning
  393. && !service.initialPlcReadCompleted()
  394. && active_repository.readBit({RegisterArea::M, 0}).error
  395. == RegisterError::Unavailable,
  396. "HMI-only runtime faults must keep the PLC cache active without falling back offline");
  397. }
  398. void testMonitorPollRangeRollback()
  399. {
  400. TestProjectStorage storage;
  401. ProjectService project_service(storage, defaultProjectLimitSettings());
  402. VirtualRegisterRepository virtual_repository;
  403. VirtualRegisterRepository plc_repository;
  404. ActiveRegisterRepository active_repository(virtual_repository);
  405. OfflineSimulationService simulation_service(virtual_repository);
  406. OnlineLogicMonitorService online_monitor_service(plc_repository);
  407. LogicEditorService logic_editor_service(project_service);
  408. ReadyPlcGateway gateway;
  409. RuntimeModeService service(
  410. project_service,
  411. logic_editor_service,
  412. simulation_service,
  413. online_monitor_service);
  414. service.configurePlc(
  415. gateway, active_repository, virtual_repository, plc_repository);
  416. gateway.reject_poll_configuration = true;
  417. const PlcCommunicationResult rejected = service.setMonitorAddresses(
  418. {{RegisterArea::D, 100}, {RegisterArea::D, 101},
  419. {RegisterArea::D, 102}, {RegisterArea::D, 103}},
  420. {{{RegisterArea::D, 100}, 4}});
  421. require(!rejected.succeeded,
  422. "a gateway-rejected Double monitor range must fail atomically");
  423. gateway.reject_poll_configuration = false;
  424. require(service.refreshPlcPollAddresses().succeeded
  425. && gateway.pollAddresses().empty()
  426. && gateway.pollRanges().empty(),
  427. "a rejected monitor candidate must not remain in runtime poll state");
  428. }
  429. void testDisconnectedOutputBlocksOfflineAndOnlineRuntime()
  430. {
  431. TestProjectStorage storage;
  432. ProjectService project_service(storage, defaultProjectLimitSettings());
  433. VirtualRegisterRepository virtual_repository;
  434. VirtualRegisterRepository plc_repository;
  435. ActiveRegisterRepository active_repository(virtual_repository);
  436. OfflineSimulationService simulation_service(virtual_repository);
  437. OnlineLogicMonitorService online_monitor_service(plc_repository);
  438. LogicEditorService logic_editor_service(project_service);
  439. ReadyPlcGateway gateway;
  440. RuntimeModeService service(
  441. project_service,
  442. logic_editor_service,
  443. simulation_service,
  444. online_monitor_service);
  445. service.configurePlc(
  446. gateway, active_repository, virtual_repository, plc_repository);
  447. ControlLogic logic;
  448. logic.id = "broken-logic";
  449. logic.name = "断路逻辑";
  450. LadderRung rung;
  451. rung.id = "broken-rung";
  452. rung.name = "行 1";
  453. for (int column = 0;
  454. column < ProjectLimits::kMaximumConditionColumns;
  455. ++column)
  456. {
  457. rung.cells.push_back({
  458. "broken-cell-" + std::to_string(column),
  459. LadderCellKind::Gap,
  460. std::nullopt});
  461. }
  462. rung.output = LogicNode{
  463. "broken-output",
  464. CoilNodeConfig{
  465. RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal},
  466. true};
  467. logic.rungs.push_back(rung);
  468. LadderRung unused;
  469. unused.id = "unused-rung";
  470. unused.name = "行 2";
  471. for (int column = 0;
  472. column < ProjectLimits::kMaximumConditionColumns;
  473. ++column)
  474. {
  475. unused.cells.push_back({
  476. "unused-cell-" + std::to_string(column),
  477. column >= 3 && column <= 5
  478. ? LadderCellKind::Wire : LadderCellKind::Gap,
  479. std::nullopt});
  480. }
  481. logic.rungs.push_back(unused);
  482. project_service.editProject().controlLogics.push_back(logic);
  483. logic_editor_service.clearHistory();
  484. const ModeTransitionResult offline = service.enterOfflineRunning();
  485. require(
  486. !offline.succeeded
  487. && offline.error == ModeTransitionError::ProjectNotReady
  488. && offline.detail.find("断路逻辑") != std::string::npos
  489. && offline.detail.find("第 11 列") != std::string::npos
  490. && offline.detail.find("第 1 列") != std::string::npos
  491. && service.mode() == ApplicationMode::Editing
  492. && simulation_service.state() == SimulationState::Stopped
  493. && service.lastSyntaxCheck().changed
  494. && service.lastSyntaxCheck().removedWireCells == 3U
  495. && logic_editor_service.findCell(
  496. "broken-logic", "unused-rung", 3)->kind
  497. == LadderCellKind::Gap
  498. && logic_editor_service.canUndo(),
  499. "runtime preflight must normalize unused lines before rejecting a broken output");
  500. require(service.connectPlc(
  501. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  502. "the online connectivity check requires a ready PLC cache");
  503. gateway.completeInitialRead();
  504. const ModeTransitionResult online = service.enterOnlineRunning();
  505. require(
  506. !online.succeeded
  507. && online.error == ModeTransitionError::ProjectNotReady
  508. && online.detail == offline.detail
  509. && service.mode() == ApplicationMode::Editing
  510. && online_monitor_service.state()
  511. == OnlineLogicMonitorState::Stopped
  512. && !service.lastSyntaxCheck().changed
  513. && logic_editor_service.undo().succeeded
  514. && logic_editor_service.findCell(
  515. "broken-logic", "unused-rung", 3)->kind
  516. == LadderCellKind::Wire,
  517. "the same disconnected output validation must block online runtime");
  518. }
  519. } // namespace
  520. int main()
  521. {
  522. return TestSupport::runTestSuite("runtime mode service tests", {
  523. {"testModeTransitions", testModeTransitions},
  524. {"testHmiOnlyRuntimeSkipsLogicExecutor", testHmiOnlyRuntimeSkipsLogicExecutor},
  525. {"testMonitorPollRangeRollback", testMonitorPollRangeRollback},
  526. {"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime},
  527. });
  528. }