综合平台编程器项目的远程存储
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

542 行
23 KiB

  1. #include "domain/active_register_repository.h"
  2. #include "domain/project_limits.h"
  3. #include "domain/project_storage.h"
  4. #include "infrastructure/plc_communication_error_classifier.h"
  5. #include "infrastructure/plc_communication_service.h"
  6. #include "support/test_support.h"
  7. #include "infrastructure/plc_register_repository.h"
  8. #include "domain/virtual_register_repository.h"
  9. #include "services/offline_simulation_service.h"
  10. #include "services/plc_communication_gateway.h"
  11. #include "services/plc_discovery_gateway.h"
  12. #include "services/project_service.h"
  13. #include "services/runtime_mode_service.h"
  14. #include "services/logic_editor_service.h"
  15. #include <functional>
  16. #include <iostream>
  17. #include <set>
  18. #include <stdexcept>
  19. #include <tuple>
  20. #include <utility>
  21. namespace {
  22. using TestProjectStorage = TestSupport::InMemoryProjectStorage;
  23. class FakePlcGateway final : public PlcCommunicationGateway
  24. {
  25. public:
  26. PlcCommunicationResult connectDevice(
  27. const PlcSerialConfiguration &configuration) override
  28. {
  29. last_configuration = configuration;
  30. last_error_type = PlcCommunicationError::None;
  31. last_error.clear();
  32. initial_read = false;
  33. connection_state = PlcConnectionState::Connected;
  34. if (state_changed)
  35. {
  36. state_changed();
  37. }
  38. return {true, {}};
  39. }
  40. void disconnectDevice() override
  41. {
  42. ++disconnect_count;
  43. connection_state = PlcConnectionState::Disconnected;
  44. initial_read = false;
  45. if (initial_read_changed)
  46. {
  47. initial_read_changed(false);
  48. }
  49. if (state_changed)
  50. {
  51. state_changed();
  52. }
  53. }
  54. PlcCommunicationResult setPollAddresses(
  55. const std::vector<RegisterAddress> &addresses) override
  56. {
  57. poll_addresses = addresses;
  58. return {true, {}};
  59. }
  60. PlcCommunicationResult setPollAddresses(
  61. const std::vector<RegisterAddress> &addresses,
  62. const std::vector<RegisterWordRange> &ranges) override
  63. {
  64. poll_addresses = addresses;
  65. poll_ranges = ranges;
  66. return {true, {}};
  67. }
  68. PlcConnectionState state() const override { return connection_state; }
  69. bool initialReadCompleted() const override { return initial_read; }
  70. PlcCommunicationError lastErrorType() const override { return last_error_type; }
  71. const std::string &lastError() const override { return last_error; }
  72. void setCallbacks(
  73. std::function<void()> state_callback,
  74. std::function<void(bool)> initial_callback,
  75. std::function<void()> cache_callback,
  76. std::function<void()> poll_cycle_callback,
  77. std::function<void(const std::string &)> error_callback) override
  78. {
  79. state_changed = std::move(state_callback);
  80. initial_read_changed = std::move(initial_callback);
  81. cache_updated = std::move(cache_callback);
  82. poll_cycle_completed = std::move(poll_cycle_callback);
  83. error_reported = std::move(error_callback);
  84. }
  85. void completeInitialRead()
  86. {
  87. initial_read = true;
  88. if (initial_read_changed)
  89. {
  90. initial_read_changed(true);
  91. }
  92. }
  93. void failCommunication(
  94. PlcCommunicationError error_type, const std::string &message)
  95. {
  96. initial_read = false;
  97. last_error_type = error_type;
  98. last_error = message;
  99. connection_state = PlcConnectionState::Faulted;
  100. if (initial_read_changed)
  101. {
  102. initial_read_changed(false);
  103. }
  104. if (state_changed)
  105. {
  106. state_changed();
  107. }
  108. if (error_reported)
  109. {
  110. error_reported(last_error);
  111. }
  112. }
  113. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  114. bool initial_read = false;
  115. int disconnect_count = 0;
  116. PlcCommunicationError last_error_type = PlcCommunicationError::None;
  117. std::string last_error;
  118. PlcSerialConfiguration last_configuration;
  119. std::vector<RegisterAddress> poll_addresses;
  120. std::vector<RegisterWordRange> poll_ranges;
  121. std::function<void()> state_changed;
  122. std::function<void(bool)> initial_read_changed;
  123. std::function<void()> cache_updated;
  124. std::function<void()> poll_cycle_completed;
  125. std::function<void(const std::string &)> error_reported;
  126. };
  127. using TestSupport::require;
  128. void testPlcCacheAndWriteForwarding()
  129. {
  130. PlcRegisterRepository repository;
  131. const RegisterAddress m0{RegisterArea::M, 0};
  132. const RegisterAddress d0{RegisterArea::D, 0};
  133. require(repository.readBit(m0).error == RegisterError::Unavailable,
  134. "uninitialized PLC bit cache must be unavailable");
  135. require(repository.readWord(d0).error == RegisterError::Unavailable,
  136. "uninitialized PLC word cache must be unavailable");
  137. repository.updateBit(0, true);
  138. repository.updateWord(0, -123);
  139. require(repository.readBit(m0).succeeded && repository.readBit(m0).value,
  140. "valid PLC bit cache must be readable");
  141. require(repository.readWord(d0).succeeded
  142. && repository.readWord(d0).value == -123,
  143. "valid PLC word cache must preserve signed values");
  144. RegisterAddress written_address{RegisterArea::M, 1};
  145. bool written_bit = false;
  146. std::int16_t written_word = 0;
  147. std::vector<std::int16_t> written_words;
  148. int single_word_write_count = 0;
  149. int multi_word_write_count = 0;
  150. repository.setWriteHandlers(
  151. [&](const RegisterAddress &address, bool value)
  152. {
  153. written_address = address;
  154. written_bit = value;
  155. return RegisterWriteResult{true, RegisterError::None};
  156. },
  157. [&](const RegisterAddress &address, std::int16_t value)
  158. {
  159. written_address = address;
  160. written_word = value;
  161. ++single_word_write_count;
  162. return RegisterWriteResult{true, RegisterError::None};
  163. },
  164. [&](const RegisterAddress &address,
  165. const std::vector<std::int16_t> &values)
  166. {
  167. written_address = address;
  168. written_words = values;
  169. ++multi_word_write_count;
  170. return RegisterWriteResult{true, RegisterError::None};
  171. });
  172. require(repository.writeBit(m0, false).succeeded
  173. && written_address == m0 && !written_bit,
  174. "PLC bit writes must be forwarded without changing the cache");
  175. require(repository.writeWord(d0, 456).succeeded
  176. && written_address == d0 && written_word == 456
  177. && single_word_write_count == 1,
  178. "PLC word writes must be forwarded without changing the cache");
  179. require(repository.writeWords(d0, {789}).succeeded
  180. && written_word == 789
  181. && single_word_write_count == 2
  182. && multi_word_write_count == 0,
  183. "one-word generic writes must keep using the PLC single-word request");
  184. const RegisterAddress d10{RegisterArea::D, 10};
  185. require(repository.writeWords(
  186. d10,
  187. {static_cast<std::int16_t>(0x5678),
  188. static_cast<std::int16_t>(0x1234)}).succeeded
  189. && written_address == d10
  190. && written_words.size() == 2U
  191. && static_cast<std::uint16_t>(written_words[0]) == 0x5678U
  192. && static_cast<std::uint16_t>(written_words[1]) == 0x1234U
  193. && multi_word_write_count == 1,
  194. "Int32 words must be forwarded in one low-word-first request");
  195. const RegisterAddress d20{RegisterArea::D, 20};
  196. require(repository.writeWords(
  197. d20,
  198. {static_cast<std::int16_t>(0x0000),
  199. static_cast<std::int16_t>(0x0000),
  200. static_cast<std::int16_t>(0x0000),
  201. static_cast<std::int16_t>(0x3ff0)}).succeeded
  202. && written_address == d20
  203. && written_words.size() == 4U
  204. && static_cast<std::uint16_t>(written_words[3]) == 0x3ff0U
  205. && multi_word_write_count == 2,
  206. "Double words must be forwarded in one four-register request");
  207. repository.invalidate();
  208. require(!repository.readWord(d0).succeeded,
  209. "disconnecting must invalidate cached PLC values");
  210. }
  211. void testRuntimeRepositorySwitchingAndDisconnect()
  212. {
  213. TestProjectStorage storage;
  214. ProjectService project_service(storage, defaultProjectLimitSettings());
  215. HmiPage page;
  216. page.id = "main-page";
  217. page.name = "Main";
  218. HmiControl indicator;
  219. indicator.id = "run-state";
  220. indicator.type = HmiControlType::Indicator;
  221. indicator.bounds = {0, 0, 80, 40};
  222. indicator.text = "Run";
  223. indicator.binding = RegisterAddress{RegisterArea::M, 5};
  224. page.controls.push_back(indicator);
  225. project_service.editProject().initialHmiPageId = page.id;
  226. project_service.editProject().hmiPages.push_back(page);
  227. VirtualRegisterRepository virtual_repository;
  228. PlcRegisterRepository plc_repository;
  229. ActiveRegisterRepository active_repository(virtual_repository);
  230. OfflineSimulationService simulation_service(virtual_repository);
  231. OnlineLogicMonitorService online_monitor_service(plc_repository);
  232. LogicEditorService logic_editor_service(project_service);
  233. FakePlcGateway gateway;
  234. RuntimeModeService service(
  235. project_service,
  236. logic_editor_service,
  237. simulation_service,
  238. online_monitor_service);
  239. service.configurePlc(
  240. gateway, active_repository, virtual_repository, plc_repository);
  241. const PlcCommunicationResult connected = service.connectPlc(
  242. {"COM9", 2, 19200, 8, 2, 1, 1000, 2, 200});
  243. require(connected.succeeded && gateway.poll_addresses.size() == 1U,
  244. "PLC connection must receive the project address poll set");
  245. service.setMonitorAddresses({
  246. RegisterAddress{RegisterArea::M, 5},
  247. RegisterAddress{RegisterArea::D, 8}});
  248. require(gateway.poll_addresses.size() == 2U
  249. && gateway.poll_addresses.front()
  250. == RegisterAddress{RegisterArea::M, 5}
  251. && gateway.poll_addresses.back()
  252. == RegisterAddress{RegisterArea::D, 8},
  253. "monitor addresses must merge with and deduplicate project poll addresses");
  254. require(service.enterOnlineRunning().error
  255. == ModeTransitionError::InitialPlcReadRequired,
  256. "online mode must wait for the first valid PLC read");
  257. plc_repository.updateBit(5, true);
  258. gateway.completeInitialRead();
  259. require(service.enterOnlineRunning().succeeded,
  260. "online mode must start after the first valid PLC read");
  261. require(active_repository.readBit({RegisterArea::M, 5}).succeeded
  262. && active_repository.readBit({RegisterArea::M, 5}).value,
  263. "online mode must expose the PLC cache through the active repository");
  264. gateway.disconnectDevice();
  265. require(service.mode() == ApplicationMode::Editing,
  266. "an online disconnect must return the application to editing mode");
  267. require(service.onlineLogicMonitorService().state()
  268. == OnlineLogicMonitorState::Stopped,
  269. "an online disconnect must stop the local read-only trace executor");
  270. require(!service.initialPlcReadCompleted(),
  271. "an online disconnect must clear the initial read flag");
  272. require(active_repository.readBit({RegisterArea::M, 5}).succeeded
  273. && !active_repository.readBit({RegisterArea::M, 5}).value,
  274. "editing after disconnect must switch back to the virtual repository");
  275. }
  276. void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect()
  277. {
  278. TestProjectStorage storage;
  279. ProjectService project_service(storage, defaultProjectLimitSettings());
  280. VirtualRegisterRepository virtual_repository;
  281. PlcRegisterRepository plc_repository;
  282. ActiveRegisterRepository active_repository(virtual_repository);
  283. OfflineSimulationService simulation_service(virtual_repository);
  284. OnlineLogicMonitorService online_monitor_service(plc_repository);
  285. LogicEditorService logic_editor_service(project_service);
  286. FakePlcGateway gateway;
  287. RuntimeModeService service(
  288. project_service,
  289. logic_editor_service,
  290. simulation_service,
  291. online_monitor_service);
  292. service.configurePlc(
  293. gateway, active_repository, virtual_repository, plc_repository);
  294. require(service.connectPlc(
  295. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  296. "PLC connection must start before testing a communication fault");
  297. gateway.completeInitialRead();
  298. require(service.enterOnlineRunning().succeeded,
  299. "completed initial read must allow online running before a fault");
  300. gateway.failCommunication(
  301. PlcCommunicationError::CommunicationTimeout,
  302. "PLC communication timed out while the serial port remained open");
  303. require(service.mode() == ApplicationMode::Editing,
  304. "a PLC communication fault must return online running to editing");
  305. require(service.onlineLogicMonitorService().state()
  306. == OnlineLogicMonitorState::Stopped,
  307. "a PLC communication fault must stop the local trace executor");
  308. require(!service.initialPlcReadCompleted(),
  309. "a PLC communication fault must revoke initial read readiness");
  310. require(service.enterOnlineRunning().error
  311. == ModeTransitionError::InitialPlcReadRequired,
  312. "faulted PLC state must not reuse readiness from the previous connection");
  313. const int disconnect_count = gateway.disconnect_count;
  314. require(service.connectPlc(
  315. {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
  316. "faulted PLC state must support direct reconnect");
  317. require(gateway.disconnect_count == disconnect_count + 1,
  318. "reconnecting from a fault must clean the previous serial session first");
  319. }
  320. void testPlcCommunicationErrorClassification()
  321. {
  322. PlcCommunicationErrorContext context;
  323. context.portName = QStringLiteral("COM3");
  324. PlcCommunicationFailure failure = classifyPlcCommunicationError(
  325. QModbusDevice::ConnectionError, context);
  326. require(failure.type == PlcCommunicationError::SerialPortOpenFailed
  327. && failure.message.contains(QStringLiteral("未能打开")),
  328. "connection failure before opening the port must be classified explicitly");
  329. context.serialSessionOpened = true;
  330. failure = classifyPlcCommunicationError(QModbusDevice::TimeoutError, context);
  331. require(failure.type == PlcCommunicationError::PlcNotResponding
  332. && failure.message.contains(QStringLiteral("PLC 未响应")),
  333. "initial timeout with an open serial port must report a non-responsive PLC");
  334. context.receivedValidResponse = true;
  335. failure = classifyPlcCommunicationError(QModbusDevice::TimeoutError, context);
  336. require(failure.type == PlcCommunicationError::CommunicationTimeout
  337. && failure.message.contains(QStringLiteral("仍处于打开状态")),
  338. "timeout after valid traffic must report an open local serial session");
  339. failure = classifyPlcCommunicationError(QModbusDevice::ConnectionError, context);
  340. require(failure.type == PlcCommunicationError::SerialConnectionLost
  341. && failure.message.contains(QStringLiteral("连接已中断")),
  342. "connection errors after opening the port must report a lost serial link");
  343. failure = classifyPlcCommunicationError(QModbusDevice::ProtocolError, context);
  344. require(failure.type == PlcCommunicationError::ProtocolError
  345. && failure.message.contains(QStringLiteral("Modbus 协议异常")),
  346. "protocol errors must remain distinct from timeouts and disconnections");
  347. }
  348. void testPlcConfigurationBoundaries()
  349. {
  350. PlcSerialConfiguration configuration;
  351. configuration.portName = "COM3";
  352. require(validatePlcSerialConfiguration(configuration).succeeded,
  353. "the standard COM3 9600 8E1 configuration must be accepted");
  354. configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress;
  355. require(validatePlcSerialConfiguration(configuration).succeeded,
  356. "PLC station 247 must be accepted");
  357. configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress + 1;
  358. require(!validatePlcSerialConfiguration(configuration).succeeded,
  359. "PLC station 248 must be rejected");
  360. configuration.serverAddress = 1;
  361. configuration.retries = ProjectLimits::kMaximumRetries;
  362. require(validatePlcSerialConfiguration(configuration).succeeded,
  363. "five retries must be accepted");
  364. configuration.retries = ProjectLimits::kMaximumRetries + 1;
  365. require(!validatePlcSerialConfiguration(configuration).succeeded,
  366. "six retries must be rejected");
  367. configuration.retries = 0;
  368. configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs - 1;
  369. require(!validatePlcSerialConfiguration(configuration).succeeded,
  370. "a response timeout below 100 ms must be rejected");
  371. configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs;
  372. require(validatePlcSerialConfiguration(configuration).succeeded,
  373. "a response timeout of 100 ms must be accepted");
  374. }
  375. void testPlcDiscoveryCandidatePriorityAndCoverage()
  376. {
  377. PlcSerialConfiguration preferred;
  378. preferred.portName = "COM3";
  379. preferred.serverAddress = 17;
  380. preferred.baudRate = 38400;
  381. preferred.dataBits = 7;
  382. preferred.parity = 3;
  383. preferred.stopBits = 2;
  384. preferred.responseTimeoutMs = 1500;
  385. preferred.retries = 4;
  386. preferred.pollIntervalMs = 350;
  387. const std::vector<PlcSerialConfiguration> candidates =
  388. buildPlcDiscoveryCandidates(
  389. preferred, {"COM1", "COM2", "COM3", "COM2", ""});
  390. require(candidates.size() == 180U,
  391. "discovery must cover 60 serial settings on every unique available port");
  392. require(candidates.front().portName == "COM3"
  393. && candidates.front().serverAddress == 17
  394. && candidates.front().baudRate == 38400
  395. && candidates.front().dataBits == 7
  396. && candidates.front().parity == 3
  397. && candidates.front().stopBits == 2,
  398. "discovery must try the complete current configuration first");
  399. require(candidates.at(1).portName == "COM1"
  400. && candidates.at(2).portName == "COM2",
  401. "the preferred port must be first without dropping other ports");
  402. std::set<std::tuple<std::string, int, int, int, int>> unique_candidates;
  403. bool contains_standard_8e1 = false;
  404. bool contains_full_supported_edge = false;
  405. for (const PlcSerialConfiguration &candidate : candidates)
  406. {
  407. require(candidate.serverAddress == preferred.serverAddress
  408. && candidate.responseTimeoutMs == preferred.responseTimeoutMs
  409. && candidate.retries == preferred.retries
  410. && candidate.pollIntervalMs == preferred.pollIntervalMs,
  411. "discovery must preserve the selected station and normal timing settings");
  412. unique_candidates.emplace(
  413. candidate.portName,
  414. candidate.baudRate,
  415. candidate.dataBits,
  416. candidate.parity,
  417. candidate.stopBits);
  418. contains_standard_8e1 = contains_standard_8e1
  419. || (candidate.portName == "COM2"
  420. && candidate.baudRate == 9600
  421. && candidate.dataBits == 8
  422. && candidate.parity == 2
  423. && candidate.stopBits == 1);
  424. contains_full_supported_edge = contains_full_supported_edge
  425. || (candidate.portName == "COM1"
  426. && candidate.baudRate == 115200
  427. && candidate.dataBits == 7
  428. && candidate.parity == 0
  429. && candidate.stopBits == 2);
  430. }
  431. require(unique_candidates.size() == candidates.size(),
  432. "discovery candidates must not contain duplicate serial settings");
  433. require(contains_standard_8e1 && contains_full_supported_edge,
  434. "discovery must cover common settings and every supported boundary");
  435. require(buildPlcDiscoveryCandidates(preferred, {}).empty(),
  436. "discovery must stop immediately when no serial port is available");
  437. }
  438. void testPlcPollQuantityBoundaries()
  439. {
  440. PlcRegisterRepository repository;
  441. PlcCommunicationService service(repository);
  442. std::vector<RegisterAddress> too_many_addresses;
  443. for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index)
  444. {
  445. too_many_addresses.push_back({RegisterArea::M, index});
  446. }
  447. too_many_addresses.push_back({RegisterArea::D, 0});
  448. require(!service.setPollAddresses(too_many_addresses).succeeded,
  449. "more than the configured poll address limit must be rejected");
  450. std::vector<RegisterAddress> too_many_blocks;
  451. for (std::size_t index = 0;
  452. index < ProjectLimits::kMaximumPollBlocks + 1U;
  453. ++index)
  454. {
  455. too_many_blocks.push_back({RegisterArea::M, static_cast<int>(index * 2U)});
  456. }
  457. require(!service.setPollAddresses(too_many_blocks).succeeded,
  458. "more than the configured Modbus read block limit must be rejected");
  459. std::vector<RegisterAddress> maximum_contiguous_block;
  460. for (int index = 0; index < ProjectLimits::kMaximumModbusReadCount; ++index)
  461. {
  462. maximum_contiguous_block.push_back({RegisterArea::M, index});
  463. }
  464. require(service.setPollAddresses(maximum_contiguous_block).succeeded,
  465. "a contiguous Modbus read block of 120 values must be accepted");
  466. std::vector<RegisterAddress> double_boundary_addresses;
  467. for (int index = 0; index <= 123; ++index)
  468. {
  469. double_boundary_addresses.push_back({RegisterArea::D, index});
  470. }
  471. require(service.setPollAddresses(
  472. double_boundary_addresses,
  473. {{RegisterAddress{RegisterArea::D, 118}, 4}}).succeeded,
  474. "a Double at the 120-word boundary must stay in one Modbus read block");
  475. std::vector<RegisterWordRange> unsplittable_ranges;
  476. for (int start = 0; start <= 120; start += 3)
  477. {
  478. unsplittable_ranges.push_back({
  479. RegisterAddress{RegisterArea::D, start}, 4});
  480. }
  481. require(!service.setPollAddresses({}, unsplittable_ranges).succeeded,
  482. "an overlapping multi-word chain without a 120-word split point must fail");
  483. }
  484. } // namespace
  485. int main()
  486. {
  487. return TestSupport::runTestSuite("PLC runtime tests", {
  488. {"testPlcCacheAndWriteForwarding", testPlcCacheAndWriteForwarding},
  489. {"testRuntimeRepositorySwitchingAndDisconnect", testRuntimeRepositorySwitchingAndDisconnect},
  490. {"testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect", testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect},
  491. {"testPlcCommunicationErrorClassification", testPlcCommunicationErrorClassification},
  492. {"testPlcConfigurationBoundaries", testPlcConfigurationBoundaries},
  493. {"testPlcDiscoveryCandidatePriorityAndCoverage", testPlcDiscoveryCandidatePriorityAndCoverage},
  494. {"testPlcPollQuantityBoundaries", testPlcPollQuantityBoundaries},
  495. });
  496. }