#include "services/runtime_mode_service.h" #include "services/logic_editor_service.h" #include "services/offline_simulation_service.h" #include "services/project_service.h" #include "domain/active_register_repository.h" #include "domain/project_storage.h" #include "domain/register_repository.h" #include "domain/virtual_register_repository.h" #include "infrastructure/plc_register_repository.h" #include "support/test_support.h" #include #include #include #include #include namespace { using TestProjectStorage = TestSupport::InMemoryProjectStorage; class ReadyPlcGateway final : public PlcCommunicationGateway { public: PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override { connection_state = PlcConnectionState::Connected; if (state_changed) { state_changed(); } return {true, {}}; } void disconnectDevice() override { connection_state = PlcConnectionState::Disconnected; initial_read = false; } PlcCommunicationResult setPollAddresses( const std::vector &addresses) override { return setPollAddresses(addresses, {}); } PlcCommunicationResult setPollAddresses( const std::vector &addresses, const std::vector &ranges) override { if (reject_poll_configuration) { return {false, "poll configuration rejected"}; } 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 PlcCommunicationError::None; } 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 completePollCycle() { if (poll_cycle_completed) { poll_cycle_completed(); } } void reportFault() { connection_state = PlcConnectionState::Faulted; initial_read = false; if (state_changed) { state_changed(); } } const std::vector &pollAddresses() const { return poll_addresses; } const std::vector &pollRanges() const { return poll_ranges; } bool reject_poll_configuration = false; private: PlcConnectionState connection_state = PlcConnectionState::Disconnected; bool initial_read = false; std::string last_error; std::function state_changed; std::function initial_read_changed; std::function cache_updated; std::function poll_cycle_completed; std::function error_reported; std::vector poll_addresses; std::vector poll_ranges; }; using TestSupport::require; void setConditionPath(LadderRung *rung, LogicNode node) { for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { rung->cells.push_back({ rung->id + "-cell-" + std::to_string(column), column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, column == 0 ? std::optional{node} : std::nullopt}); } } void testModeTransitions() { // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository virtual_repository; VirtualRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); LogicEditorService logic_editor_service(project_service); ReadyPlcGateway gateway; RuntimeModeService service( project_service, logic_editor_service, simulation_service, online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); Project &project = project_service.editProject(); HmiPage page; page.id = "runtime-page"; page.name = "Runtime"; HmiControl int32_display; int32_display.id = "runtime-int32"; int32_display.type = HmiControlType::NumericDisplay; int32_display.bounds = {0, 0, 100, 40}; int32_display.binding = RegisterAddress{RegisterArea::D, 50}; int32_display.dataType = RegisterDataType::Int32; page.controls.push_back(int32_display); HmiControl double_display = int32_display; double_display.id = "runtime-double"; double_display.bounds = {120, 0, 100, 40}; double_display.binding = RegisterAddress{RegisterArea::D, 60}; double_display.dataType = RegisterDataType::Float64; page.controls.push_back(double_display); HmiControl double_status = int32_display; double_status.id = "runtime-double-status"; double_status.type = HmiControlType::StatusText; double_status.bounds = {240, 0, 140, 40}; double_status.binding = RegisterAddress{RegisterArea::D, 70}; double_status.dataType = RegisterDataType::Float64; double_status.statusText = HmiStatusWordTextConfig{{ {std::nullopt, 0.0, "Low"}, {0.0, std::nullopt, "High"}}}; page.controls.push_back(double_status); HmiControl gated_button; gated_button.id = "runtime-gated-button"; gated_button.type = HmiControlType::Button; gated_button.bounds = {400, 0, 120, 40}; gated_button.binding = RegisterAddress{RegisterArea::M, 30}; gated_button.buttonEnableCondition = HmiButtonWordEnableCondition{ RegisterAddress{RegisterArea::D, 80}, RegisterDataType::Int32, HmiButtonConditionOperator::GreaterThanOrEqual, 1.0}; page.controls.push_back(gated_button); project.initialHmiPageId = page.id; project.hmiPages.push_back(page); project.alarmDefinitions.push_back( {"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"}); project.alarmDefinitions.push_back( {"alarm-d", {RegisterArea::D, 34}, AlarmCondition::DHigh, 100, "D alarm"}); project.registerComments = { {RegisterAddress{RegisterArea::M, 3999}, "只用于说明"}, {RegisterAddress{RegisterArea::D, 3999}, "只用于说明"}}; ControlLogic logic; logic.id = "poll-logic"; logic.name = "Poll logic"; LogicNode edge; edge.id = "poll-edge"; edge.config = EdgeContactNodeConfig{ RegisterAddress{RegisterArea::M, 20}, EdgeMode::Rising}; LogicNode edge_coil; edge_coil.id = "poll-edge-coil"; edge_coil.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 21}, CoilMode::Normal}; LadderRung edge_rung; edge_rung.id = "poll-edge-rung"; edge_rung.name = "Poll edge"; setConditionPath(&edge_rung, edge); edge_rung.output = edge_coil; logic.rungs.push_back(edge_rung); LogicNode comparison; comparison.id = "poll-comparison"; comparison.config = CompareNodeConfig{ RegisterAddress{RegisterArea::D, 35}, ComparisonOperator::GreaterThan, 0}; LogicNode comparison_coil; comparison_coil.id = "poll-comparison-coil"; comparison_coil.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 22}, CoilMode::Normal}; LadderRung comparison_rung; comparison_rung.id = "poll-comparison-rung"; comparison_rung.name = "Poll comparison"; setConditionPath(&comparison_rung, comparison); comparison_rung.output = comparison_coil; logic.rungs.push_back(comparison_rung); LogicNode move_input; move_input.id = "poll-move-input"; move_input.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 27}, ContactMode::NormallyOpen}; LogicNode move_output; move_output.id = "poll-move"; move_output.config = MoveNodeConfig{ WordOperand{ WordOperandKind::Register, RegisterAddress{RegisterArea::D, 38}, 0}, RegisterAddress{RegisterArea::D, 39}}; LadderRung move_rung; move_rung.id = "poll-move-rung"; move_rung.name = "Poll MOVE"; setConditionPath(&move_rung, move_input); move_rung.output = move_output; logic.rungs.push_back(move_rung); LogicNode add_input; add_input.id = "poll-add-input"; add_input.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 28}, ContactMode::NormallyOpen}; LogicNode add_output; add_output.id = "poll-add"; add_output.config = ArithmeticNodeConfig{ ArithmeticOperation::Add, WordOperand{ WordOperandKind::Register, RegisterAddress{RegisterArea::D, 40}, 0}, WordOperand{ WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, 1}, RegisterAddress{RegisterArea::D, 41}}; LadderRung add_rung; add_rung.id = "poll-add-rung"; add_rung.name = "Poll ADD"; setConditionPath(&add_rung, add_input); add_rung.output = add_output; logic.rungs.push_back(add_rung); project.controlLogics.push_back(logic); require(service.mode() == ApplicationMode::Editing, "service must start in editing mode"); require(service.policy().allowsProjectEditing, "editing mode must allow project editing"); require(service.enterOfflineRunning().succeeded, "editing mode must enter offline running"); require(service.simulationState() == SimulationState::Running, "offline mode must start the software executor"); require(service.policy().usesVirtualRegisters, "offline running must use virtual registers"); require(service.enterOnlineRunning().error == ModeTransitionError::MustReturnToEditing, "running modes must not switch directly"); require(service.enterEditing().succeeded, "offline running must return to editing"); require(service.simulationState() == SimulationState::Stopped, "returning to editing must stop the software executor first"); require(service.enterOnlineRunning().error == ModeTransitionError::InitialPlcReadRequired, "online running must require an initial PLC read"); require(service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "PLC connection must be established before its initial read can complete"); require(gateway.pollAddresses() == std::vector({ {RegisterArea::M, 12}, {RegisterArea::M, 20}, {RegisterArea::M, 21}, {RegisterArea::M, 22}, {RegisterArea::M, 27}, {RegisterArea::M, 28}, {RegisterArea::M, 30}, {RegisterArea::D, 34}, {RegisterArea::D, 35}, {RegisterArea::D, 38}, {RegisterArea::D, 39}, {RegisterArea::D, 40}, {RegisterArea::D, 41}, {RegisterArea::D, 50}, {RegisterArea::D, 51}, {RegisterArea::D, 60}, {RegisterArea::D, 61}, {RegisterArea::D, 62}, {RegisterArea::D, 63}, {RegisterArea::D, 70}, {RegisterArea::D, 71}, {RegisterArea::D, 72}, {RegisterArea::D, 73}, {RegisterArea::D, 80}, {RegisterArea::D, 81}}) && gateway.pollRanges() == std::vector({ {{RegisterArea::D, 50}, 2}, {{RegisterArea::D, 60}, 4}, {{RegisterArea::D, 70}, 4}, {{RegisterArea::D, 80}, 2}}), "instructions and multi-word status text must be fully polled while comments stay metadata-only"); gateway.completeInitialRead(); require(service.initialPlcReadCompleted(), "service must retain the initial PLC read state"); plc_repository.writeBit({RegisterArea::M, 20}, true); require(service.enterOnlineRunning().succeeded, "online running must start after an initial PLC read"); require(service.simulationState() == SimulationState::Stopped, "online running must keep the offline executor stopped"); require(service.onlineLogicMonitorService().state() == OnlineLogicMonitorState::Running, "online running must start the local read-only trace executor"); require(!plc_repository.readBit({RegisterArea::M, 21}).value, "the local trace output must not change the PLC source repository"); require(service.policy().usesPlcRegisters, "online running must use PLC registers"); require(service.policy().runsLogicExecutor, "online running must advertise the local read-only trace executor"); const std::uint64_t scan_count = service.onlineLogicMonitorService() .successfulScanCount(); gateway.completePollCycle(); require(service.onlineLogicMonitorService().successfulScanCount() == scan_count + 1U, "a completed PLC poll cycle must trigger one new local trace scan"); } void testHmiOnlyRuntimeSkipsLogicExecutor() { 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); ReadyPlcGateway gateway; RuntimeModeService service( project_service, logic_editor_service, simulation_service, online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); service.setHmiOnlyRuntime(true); require(!active_repository.readBit({RegisterArea::M, 0}).succeeded, "HMI-only runtime must use the unavailable PLC cache before its first read"); HmiPage page; page.id = "hmi-only-page"; page.name = "HMI only"; HmiControl button; button.id = "hmi-only-button"; button.type = HmiControlType::Button; button.bounds = {0, 0, 100, 40}; button.binding = RegisterAddress{RegisterArea::M, 0}; page.controls.push_back(button); Project &project = project_service.editProject(); project.hmiPages.push_back(page); project.initialHmiPageId = page.id; // 该逻辑故意不完整,HMI 专用导出不应受它阻断 project.controlLogics.push_back(ControlLogic{}); require(service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "HMI-only runtime must accept a PLC connection request"); gateway.completeInitialRead(); plc_repository.updateBit(0, false); require(service.enterOnlineRunning().succeeded, "HMI-only runtime must ignore ladder logic readiness"); require(service.policy().usesPlcRegisters && online_monitor_service.state() == OnlineLogicMonitorState::Stopped, "HMI-only runtime must use PLC registers without starting local logic"); require(active_repository.readBit({RegisterArea::M, 0}).succeeded, "HMI-only runtime must switch HMI reads to the PLC cache"); plc_repository.updateBit(0, true); require(virtual_repository.writeBit({RegisterArea::M, 0}, false).succeeded && active_repository.readBit({RegisterArea::M, 0}).value, "HMI-only runtime fault fixture must separate virtual and PLC values"); plc_repository.invalidate(); gateway.reportFault(); require(service.mode() == ApplicationMode::OnlineRunning && !service.initialPlcReadCompleted() && active_repository.readBit({RegisterArea::M, 0}).error == RegisterError::Unavailable, "HMI-only runtime faults must keep the PLC cache active without falling back offline"); } void testMonitorPollRangeRollback() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository virtual_repository; VirtualRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); LogicEditorService logic_editor_service(project_service); ReadyPlcGateway gateway; RuntimeModeService service( project_service, logic_editor_service, simulation_service, online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); gateway.reject_poll_configuration = true; const PlcCommunicationResult rejected = service.setMonitorAddresses( {{RegisterArea::D, 100}, {RegisterArea::D, 101}, {RegisterArea::D, 102}, {RegisterArea::D, 103}}, {{{RegisterArea::D, 100}, 4}}); require(!rejected.succeeded, "a gateway-rejected Double monitor range must fail atomically"); gateway.reject_poll_configuration = false; require(service.refreshPlcPollAddresses().succeeded && gateway.pollAddresses().empty() && gateway.pollRanges().empty(), "a rejected monitor candidate must not remain in runtime poll state"); } void testDisconnectedOutputBlocksOfflineAndOnlineRuntime() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository virtual_repository; VirtualRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); LogicEditorService logic_editor_service(project_service); ReadyPlcGateway gateway; RuntimeModeService service( project_service, logic_editor_service, simulation_service, online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); ControlLogic logic; logic.id = "broken-logic"; logic.name = "断路逻辑"; LadderRung rung; rung.id = "broken-rung"; rung.name = "行 1"; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { rung.cells.push_back({ "broken-cell-" + std::to_string(column), LadderCellKind::Gap, std::nullopt}); } rung.output = LogicNode{ "broken-output", CoilNodeConfig{ RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal}, true}; logic.rungs.push_back(rung); LadderRung unused; unused.id = "unused-rung"; unused.name = "行 2"; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { unused.cells.push_back({ "unused-cell-" + std::to_string(column), column >= 3 && column <= 5 ? LadderCellKind::Wire : LadderCellKind::Gap, std::nullopt}); } logic.rungs.push_back(unused); project_service.editProject().controlLogics.push_back(logic); logic_editor_service.clearHistory(); const ModeTransitionResult offline = service.enterOfflineRunning(); require( !offline.succeeded && offline.error == ModeTransitionError::ProjectNotReady && offline.detail.find("断路逻辑") != std::string::npos && offline.detail.find("第 11 列") != std::string::npos && offline.detail.find("第 1 列") != std::string::npos && service.mode() == ApplicationMode::Editing && simulation_service.state() == SimulationState::Stopped && service.lastSyntaxCheck().changed && service.lastSyntaxCheck().removedWireCells == 3U && logic_editor_service.findCell( "broken-logic", "unused-rung", 3)->kind == LadderCellKind::Gap && logic_editor_service.canUndo(), "runtime preflight must normalize unused lines before rejecting a broken output"); require(service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "the online connectivity check requires a ready PLC cache"); gateway.completeInitialRead(); const ModeTransitionResult online = service.enterOnlineRunning(); require( !online.succeeded && online.error == ModeTransitionError::ProjectNotReady && online.detail == offline.detail && service.mode() == ApplicationMode::Editing && online_monitor_service.state() == OnlineLogicMonitorState::Stopped && !service.lastSyntaxCheck().changed && logic_editor_service.undo().succeeded && logic_editor_service.findCell( "broken-logic", "unused-rung", 3)->kind == LadderCellKind::Wire, "the same disconnected output validation must block online runtime"); } } // namespace int main() { return TestSupport::runTestSuite("runtime mode service tests", { {"testModeTransitions", testModeTransitions}, {"testHmiOnlyRuntimeSkipsLogicExecutor", testHmiOnlyRuntimeSkipsLogicExecutor}, {"testMonitorPollRangeRollback", testMonitorPollRangeRollback}, {"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime}, }); }