|
- #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 "support/test_support.h"
-
- #include <functional>
- #include <iostream>
- #include <stdexcept>
- #include <string>
- #include <utility>
-
- 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<RegisterAddress> &addresses) override
- {
- return setPollAddresses(addresses, {});
- }
-
- PlcCommunicationResult setPollAddresses(
- const std::vector<RegisterAddress> &addresses,
- const std::vector<RegisterWordRange> &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<void()> state_callback,
- std::function<void(bool)> initial_callback,
- std::function<void()> cache_callback,
- std::function<void()> poll_cycle_callback,
- std::function<void(const std::string &)> 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();
- }
- }
-
- const std::vector<RegisterAddress> &pollAddresses() const
- {
- return poll_addresses;
- }
-
- const std::vector<RegisterWordRange> &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<void()> state_changed;
- std::function<void(bool)> initial_read_changed;
- std::function<void()> cache_updated;
- std::function<void()> poll_cycle_completed;
- std::function<void(const std::string &)> error_reported;
- std::vector<RegisterAddress> poll_addresses;
- std::vector<RegisterWordRange> 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<LogicNode>{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<RegisterAddress>({
- {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<RegisterWordRange>({
- {{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 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},
- {"testMonitorPollRangeRollback", testMonitorPollRangeRollback},
- {"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime},
- });
- }
|