#include "domain/active_register_repository.h" #include "domain/project_limits.h" #include "domain/project_storage.h" #include "infrastructure/plc_communication_error_classifier.h" #include "infrastructure/plc_communication_service.h" #include "support/test_support.h" #include "infrastructure/plc_register_repository.h" #include "domain/virtual_register_repository.h" #include "services/offline_simulation_service.h" #include "services/plc_communication_gateway.h" #include "services/plc_discovery_gateway.h" #include "services/project_service.h" #include "services/runtime_mode_service.h" #include "services/logic_editor_service.h" #include #include #include #include #include #include namespace { using TestProjectStorage = TestSupport::InMemoryProjectStorage; class FakePlcGateway final : public PlcCommunicationGateway { public: PlcCommunicationResult connectDevice( const PlcSerialConfiguration &configuration) override { last_configuration = configuration; last_error_type = PlcCommunicationError::None; last_error.clear(); initial_read = false; connection_state = PlcConnectionState::Connected; if (state_changed) { state_changed(); } return {true, {}}; } void disconnectDevice() override { ++disconnect_count; connection_state = PlcConnectionState::Disconnected; initial_read = false; if (initial_read_changed) { initial_read_changed(false); } if (state_changed) { state_changed(); } } PlcCommunicationResult setPollAddresses( const std::vector &addresses) override { poll_addresses = addresses; return {true, {}}; } PlcCommunicationResult setPollAddresses( const std::vector &addresses, const std::vector &ranges) override { poll_addresses = addresses; poll_ranges = ranges; return {true, {}}; } PlcConnectionState state() const override { return connection_state; } bool initialReadCompleted() const override { return initial_read; } PlcCommunicationError lastErrorType() const override { return last_error_type; } const std::string &lastError() const override { return last_error; } void setCallbacks( std::function state_callback, std::function initial_callback, std::function cache_callback, std::function poll_cycle_callback, std::function error_callback) override { state_changed = std::move(state_callback); initial_read_changed = std::move(initial_callback); cache_updated = std::move(cache_callback); poll_cycle_completed = std::move(poll_cycle_callback); error_reported = std::move(error_callback); } void completeInitialRead() { initial_read = true; if (initial_read_changed) { initial_read_changed(true); } } void failCommunication( PlcCommunicationError error_type, const std::string &message) { initial_read = false; last_error_type = error_type; last_error = message; connection_state = PlcConnectionState::Faulted; if (initial_read_changed) { initial_read_changed(false); } if (state_changed) { state_changed(); } if (error_reported) { error_reported(last_error); } } PlcConnectionState connection_state = PlcConnectionState::Disconnected; bool initial_read = false; int disconnect_count = 0; PlcCommunicationError last_error_type = PlcCommunicationError::None; std::string last_error; PlcSerialConfiguration last_configuration; std::vector poll_addresses; std::vector poll_ranges; std::function state_changed; std::function initial_read_changed; std::function cache_updated; std::function poll_cycle_completed; std::function error_reported; }; using TestSupport::require; void testPlcCacheAndWriteForwarding() { PlcRegisterRepository repository; const RegisterAddress m0{RegisterArea::M, 0}; const RegisterAddress d0{RegisterArea::D, 0}; require(repository.readBit(m0).error == RegisterError::Unavailable, "uninitialized PLC bit cache must be unavailable"); require(repository.readWord(d0).error == RegisterError::Unavailable, "uninitialized PLC word cache must be unavailable"); repository.updateBit(0, true); repository.updateWord(0, -123); require(repository.readBit(m0).succeeded && repository.readBit(m0).value, "valid PLC bit cache must be readable"); require(repository.readWord(d0).succeeded && repository.readWord(d0).value == -123, "valid PLC word cache must preserve signed values"); RegisterAddress written_address{RegisterArea::M, 1}; bool written_bit = false; std::int16_t written_word = 0; std::vector written_words; int single_word_write_count = 0; int multi_word_write_count = 0; repository.setWriteHandlers( [&](const RegisterAddress &address, bool value) { written_address = address; written_bit = value; return RegisterWriteResult{true, RegisterError::None}; }, [&](const RegisterAddress &address, std::int16_t value) { written_address = address; written_word = value; ++single_word_write_count; return RegisterWriteResult{true, RegisterError::None}; }, [&](const RegisterAddress &address, const std::vector &values) { written_address = address; written_words = values; ++multi_word_write_count; return RegisterWriteResult{true, RegisterError::None}; }); require(repository.writeBit(m0, false).succeeded && written_address == m0 && !written_bit, "PLC bit writes must be forwarded without changing the cache"); require(repository.writeWord(d0, 456).succeeded && written_address == d0 && written_word == 456 && single_word_write_count == 1, "PLC word writes must be forwarded without changing the cache"); require(repository.writeWords(d0, {789}).succeeded && written_word == 789 && single_word_write_count == 2 && multi_word_write_count == 0, "one-word generic writes must keep using the PLC single-word request"); const RegisterAddress d10{RegisterArea::D, 10}; require(repository.writeWords( d10, {static_cast(0x5678), static_cast(0x1234)}).succeeded && written_address == d10 && written_words.size() == 2U && static_cast(written_words[0]) == 0x5678U && static_cast(written_words[1]) == 0x1234U && multi_word_write_count == 1, "Int32 words must be forwarded in one low-word-first request"); const RegisterAddress d20{RegisterArea::D, 20}; require(repository.writeWords( d20, {static_cast(0x0000), static_cast(0x0000), static_cast(0x0000), static_cast(0x3ff0)}).succeeded && written_address == d20 && written_words.size() == 4U && static_cast(written_words[3]) == 0x3ff0U && multi_word_write_count == 2, "Double words must be forwarded in one four-register request"); repository.invalidate(); require(!repository.readWord(d0).succeeded, "disconnecting must invalidate cached PLC values"); } void testRuntimeRepositorySwitchingAndDisconnect() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); HmiPage page; page.id = "main-page"; page.name = "Main"; HmiControl indicator; indicator.id = "run-state"; indicator.type = HmiControlType::Indicator; indicator.bounds = {0, 0, 80, 40}; indicator.text = "Run"; indicator.binding = RegisterAddress{RegisterArea::M, 5}; page.controls.push_back(indicator); project_service.editProject().initialHmiPageId = page.id; project_service.editProject().hmiPages.push_back(page); VirtualRegisterRepository virtual_repository; PlcRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); LogicEditorService logic_editor_service(project_service); FakePlcGateway gateway; RuntimeModeService service( project_service, logic_editor_service, simulation_service, online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); const PlcCommunicationResult connected = service.connectPlc( {"COM9", 2, 19200, 8, 2, 1, 1000, 2, 200}); require(connected.succeeded && gateway.poll_addresses.size() == 1U, "PLC connection must receive the project address poll set"); service.setMonitorAddresses({ RegisterAddress{RegisterArea::M, 5}, RegisterAddress{RegisterArea::D, 8}}); require(gateway.poll_addresses.size() == 2U && gateway.poll_addresses.front() == RegisterAddress{RegisterArea::M, 5} && gateway.poll_addresses.back() == RegisterAddress{RegisterArea::D, 8}, "monitor addresses must merge with and deduplicate project poll addresses"); require(service.enterOnlineRunning().error == ModeTransitionError::InitialPlcReadRequired, "online mode must wait for the first valid PLC read"); plc_repository.updateBit(5, true); gateway.completeInitialRead(); require(service.enterOnlineRunning().succeeded, "online mode must start after the first valid PLC read"); require(active_repository.readBit({RegisterArea::M, 5}).succeeded && active_repository.readBit({RegisterArea::M, 5}).value, "online mode must expose the PLC cache through the active repository"); gateway.disconnectDevice(); require(service.mode() == ApplicationMode::Editing, "an online disconnect must return the application to editing mode"); require(service.onlineLogicMonitorService().state() == OnlineLogicMonitorState::Stopped, "an online disconnect must stop the local read-only trace executor"); require(!service.initialPlcReadCompleted(), "an online disconnect must clear the initial read flag"); require(active_repository.readBit({RegisterArea::M, 5}).succeeded && !active_repository.readBit({RegisterArea::M, 5}).value, "editing after disconnect must switch back to the virtual repository"); } void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository virtual_repository; PlcRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); LogicEditorService logic_editor_service(project_service); FakePlcGateway gateway; RuntimeModeService service( project_service, logic_editor_service, simulation_service, online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); require(service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "PLC connection must start before testing a communication fault"); gateway.completeInitialRead(); require(service.enterOnlineRunning().succeeded, "completed initial read must allow online running before a fault"); gateway.failCommunication( PlcCommunicationError::CommunicationTimeout, "PLC communication timed out while the serial port remained open"); require(service.mode() == ApplicationMode::Editing, "a PLC communication fault must return online running to editing"); require(service.onlineLogicMonitorService().state() == OnlineLogicMonitorState::Stopped, "a PLC communication fault must stop the local trace executor"); require(!service.initialPlcReadCompleted(), "a PLC communication fault must revoke initial read readiness"); require(service.enterOnlineRunning().error == ModeTransitionError::InitialPlcReadRequired, "faulted PLC state must not reuse readiness from the previous connection"); const int disconnect_count = gateway.disconnect_count; require(service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "faulted PLC state must support direct reconnect"); require(gateway.disconnect_count == disconnect_count + 1, "reconnecting from a fault must clean the previous serial session first"); } void testPlcCommunicationErrorClassification() { PlcCommunicationErrorContext context; context.portName = QStringLiteral("COM3"); PlcCommunicationFailure failure = classifyPlcCommunicationError( QModbusDevice::ConnectionError, context); require(failure.type == PlcCommunicationError::SerialPortOpenFailed && failure.message.contains(QStringLiteral("未能打开")), "connection failure before opening the port must be classified explicitly"); context.serialSessionOpened = true; failure = classifyPlcCommunicationError(QModbusDevice::TimeoutError, context); require(failure.type == PlcCommunicationError::PlcNotResponding && failure.message.contains(QStringLiteral("PLC 未响应")), "initial timeout with an open serial port must report a non-responsive PLC"); context.receivedValidResponse = true; failure = classifyPlcCommunicationError(QModbusDevice::TimeoutError, context); require(failure.type == PlcCommunicationError::CommunicationTimeout && failure.message.contains(QStringLiteral("仍处于打开状态")), "timeout after valid traffic must report an open local serial session"); failure = classifyPlcCommunicationError(QModbusDevice::ConnectionError, context); require(failure.type == PlcCommunicationError::SerialConnectionLost && failure.message.contains(QStringLiteral("连接已中断")), "connection errors after opening the port must report a lost serial link"); failure = classifyPlcCommunicationError(QModbusDevice::ProtocolError, context); require(failure.type == PlcCommunicationError::ProtocolError && failure.message.contains(QStringLiteral("Modbus 协议异常")), "protocol errors must remain distinct from timeouts and disconnections"); } void testPlcConfigurationBoundaries() { PlcSerialConfiguration configuration; configuration.portName = "COM3"; require(validatePlcSerialConfiguration(configuration).succeeded, "the standard COM3 9600 8E1 configuration must be accepted"); configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress; require(validatePlcSerialConfiguration(configuration).succeeded, "PLC station 247 must be accepted"); configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress + 1; require(!validatePlcSerialConfiguration(configuration).succeeded, "PLC station 248 must be rejected"); configuration.serverAddress = 1; configuration.retries = ProjectLimits::kMaximumRetries; require(validatePlcSerialConfiguration(configuration).succeeded, "five retries must be accepted"); configuration.retries = ProjectLimits::kMaximumRetries + 1; require(!validatePlcSerialConfiguration(configuration).succeeded, "six retries must be rejected"); configuration.retries = 0; configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs - 1; require(!validatePlcSerialConfiguration(configuration).succeeded, "a response timeout below 100 ms must be rejected"); configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs; require(validatePlcSerialConfiguration(configuration).succeeded, "a response timeout of 100 ms must be accepted"); } void testPlcDiscoveryCandidatePriorityAndCoverage() { PlcSerialConfiguration preferred; preferred.portName = "COM3"; preferred.serverAddress = 17; preferred.baudRate = 38400; preferred.dataBits = 7; preferred.parity = 3; preferred.stopBits = 2; preferred.responseTimeoutMs = 1500; preferred.retries = 4; preferred.pollIntervalMs = 350; const std::vector candidates = buildPlcDiscoveryCandidates( preferred, {"COM1", "COM2", "COM3", "COM2", ""}); require(candidates.size() == 180U, "discovery must cover 60 serial settings on every unique available port"); require(candidates.front().portName == "COM3" && candidates.front().serverAddress == 17 && candidates.front().baudRate == 38400 && candidates.front().dataBits == 7 && candidates.front().parity == 3 && candidates.front().stopBits == 2, "discovery must try the complete current configuration first"); require(candidates.at(1).portName == "COM1" && candidates.at(2).portName == "COM2", "the preferred port must be first without dropping other ports"); std::set> unique_candidates; bool contains_standard_8e1 = false; bool contains_full_supported_edge = false; for (const PlcSerialConfiguration &candidate : candidates) { require(candidate.serverAddress == preferred.serverAddress && candidate.responseTimeoutMs == preferred.responseTimeoutMs && candidate.retries == preferred.retries && candidate.pollIntervalMs == preferred.pollIntervalMs, "discovery must preserve the selected station and normal timing settings"); unique_candidates.emplace( candidate.portName, candidate.baudRate, candidate.dataBits, candidate.parity, candidate.stopBits); contains_standard_8e1 = contains_standard_8e1 || (candidate.portName == "COM2" && candidate.baudRate == 9600 && candidate.dataBits == 8 && candidate.parity == 2 && candidate.stopBits == 1); contains_full_supported_edge = contains_full_supported_edge || (candidate.portName == "COM1" && candidate.baudRate == 115200 && candidate.dataBits == 7 && candidate.parity == 0 && candidate.stopBits == 2); } require(unique_candidates.size() == candidates.size(), "discovery candidates must not contain duplicate serial settings"); require(contains_standard_8e1 && contains_full_supported_edge, "discovery must cover common settings and every supported boundary"); require(buildPlcDiscoveryCandidates(preferred, {}).empty(), "discovery must stop immediately when no serial port is available"); } void testPlcPollQuantityBoundaries() { PlcRegisterRepository repository; PlcCommunicationService service(repository); std::vector too_many_addresses; for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index) { too_many_addresses.push_back({RegisterArea::M, index}); } too_many_addresses.push_back({RegisterArea::D, 0}); require(!service.setPollAddresses(too_many_addresses).succeeded, "more than the configured poll address limit must be rejected"); std::vector too_many_blocks; for (std::size_t index = 0; index < ProjectLimits::kMaximumPollBlocks + 1U; ++index) { too_many_blocks.push_back({RegisterArea::M, static_cast(index * 2U)}); } require(!service.setPollAddresses(too_many_blocks).succeeded, "more than the configured Modbus read block limit must be rejected"); std::vector maximum_contiguous_block; for (int index = 0; index < ProjectLimits::kMaximumModbusReadCount; ++index) { maximum_contiguous_block.push_back({RegisterArea::M, index}); } require(service.setPollAddresses(maximum_contiguous_block).succeeded, "a contiguous Modbus read block of 120 values must be accepted"); std::vector double_boundary_addresses; for (int index = 0; index <= 123; ++index) { double_boundary_addresses.push_back({RegisterArea::D, index}); } require(service.setPollAddresses( double_boundary_addresses, {{RegisterAddress{RegisterArea::D, 118}, 4}}).succeeded, "a Double at the 120-word boundary must stay in one Modbus read block"); std::vector unsplittable_ranges; for (int start = 0; start <= 120; start += 3) { unsplittable_ranges.push_back({ RegisterAddress{RegisterArea::D, start}, 4}); } require(!service.setPollAddresses({}, unsplittable_ranges).succeeded, "an overlapping multi-word chain without a 120-word split point must fail"); } } // namespace int main() { try { testPlcCacheAndWriteForwarding(); testRuntimeRepositorySwitchingAndDisconnect(); testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); testPlcCommunicationErrorClassification(); testPlcConfigurationBoundaries(); testPlcDiscoveryCandidatePriorityAndCoverage(); testPlcPollQuantityBoundaries(); } catch (const std::exception &error) { std::cerr << "PLC runtime tests failed: " << error.what() << '\n'; return 1; } std::cout << "PLC runtime tests passed\n"; return 0; }