#include "domain/hmi_model.h" #include "domain/project_limits.h" #include "domain/register_repository.h" #include "domain/virtual_register_repository.h" #include "services/hmi_runtime_service.h" #include "services/offline_simulation_service.h" #include "services/online_logic_monitor_service.h" #include "services/software_logic_executor.h" #include "support/test_support.h" #include #include #include #include #include #include namespace { using TestSupport::require; LogicNode contact(const std::string &id, int address, ContactMode mode = ContactMode::NormallyOpen) { return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true}; } LogicNode edgeContact(const std::string &id, int address, EdgeMode mode) { return {id, EdgeContactNodeConfig{ RegisterAddress{RegisterArea::M, address}, mode}, true}; } WordOperand constantOperand(std::int16_t value) { return { WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, value}; } WordOperand registerOperand(int address) { return { WordOperandKind::Register, RegisterAddress{RegisterArea::D, address}, 0}; } LogicNode move( const std::string &id, WordOperand source, int destination) { return { id, MoveNodeConfig{ source, RegisterAddress{RegisterArea::D, destination}}, true}; } LogicNode arithmetic( const std::string &id, ArithmeticOperation operation, WordOperand left, WordOperand right, int destination) { return { id, ArithmeticNodeConfig{ operation, left, right, RegisterAddress{RegisterArea::D, destination}}, true}; } LogicNode comparison(const std::string &id, int address, ComparisonOperator operation, std::int16_t value) { return {id, CompareNodeConfig{RegisterAddress{RegisterArea::D, address}, operation, value}, true}; } LogicNode coil(const std::string &id, int address, CoilMode mode = CoilMode::Normal) { return {id, CoilNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true}; } LadderRung rung(const std::string &id, const std::vector> &stages, const LogicNode &output) { LadderRung result; result.id = id; result.name = id; std::vector series_children; for (std::size_t index = 0; index < stages.size(); ++index) { std::vector parallel_children; for (const LogicNode &node : stages[index]) { parallel_children.push_back(ConditionExpression::fromNode(node)); } if (parallel_children.size() == 1U) { series_children.push_back(std::move(parallel_children.front())); } else { ConditionExpression parallel; parallel.id = id + "-parallel-" + std::to_string(index); parallel.kind = ConditionExpressionKind::Parallel; parallel.children = std::move(parallel_children); series_children.push_back(std::move(parallel)); } } if (series_children.size() == 1U) { result.condition = std::move(series_children.front()); } else { ConditionExpression series; series.id = id + "-series"; series.kind = ConditionExpressionKind::Series; series.children = std::move(series_children); result.condition = std::move(series); } const int occupied_columns = static_cast(stages.size()); if (occupied_columns < ProjectLimits::kMaximumConditionColumns) { ConditionExpression wire = ConditionExpression::fromWire( id + "-output-wire", ProjectLimits::kMaximumConditionColumns - occupied_columns); if (result.condition->kind == ConditionExpressionKind::Series) { result.condition->children.push_back(std::move(wire)); } else { ConditionExpression series; series.id = id + "-explicit-series"; series.kind = ConditionExpressionKind::Series; series.children.push_back(std::move(*result.condition)); series.children.push_back(std::move(wire)); result.condition = std::move(series); } } result.output = output; return result; } ControlLogic logic(const std::vector &rungs) { return {"logic-1", "logic-1", rungs, true}; } bool readBit(RegisterRepository &repository, int address) { const BitReadResult result = repository.readBit( RegisterAddress{RegisterArea::M, address}); require(result.succeeded, "test register read must succeed"); return result.value; } void writeBit(RegisterRepository &repository, int address, bool value) { require(repository.writeBit(RegisterAddress{RegisterArea::M, address}, value).succeeded, "test bit write must succeed"); } void writeWord(RegisterRepository &repository, int address, std::int16_t value) { require(repository.writeWord(RegisterAddress{RegisterArea::D, address}, value).succeeded, "test word write must succeed"); } std::int16_t readWord(RegisterRepository &repository, int address) { const WordReadResult result = repository.readWord( RegisterAddress{RegisterArea::D, address}); require(result.succeeded, "test word read must succeed"); return result.value; } void testNestedSeriesParallelExpression() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; ConditionExpression nested_series; nested_series.id = "nested-series"; nested_series.kind = ConditionExpressionKind::Series; nested_series.children = { ConditionExpression::fromNode(contact("b", 1)), ConditionExpression::fromNode(contact("c", 2))}; ConditionExpression padded_a; padded_a.id = "padded-a"; padded_a.kind = ConditionExpressionKind::Series; padded_a.children = { ConditionExpression::fromNode(contact("a", 0)), ConditionExpression::fromWire("a-branch-wire", 1)}; ConditionExpression parallel; parallel.id = "root-parallel"; parallel.kind = ConditionExpressionKind::Parallel; parallel.children = {std::move(padded_a), nested_series}; ConditionExpression root; root.id = "nested-output-series"; root.kind = ConditionExpressionKind::Series; root.children = { std::move(parallel), ConditionExpression::fromWire("nested-output-wire", 8)}; LadderRung nested_rung; nested_rung.id = "nested-rung"; nested_rung.name = "nested-rung"; nested_rung.condition = root; nested_rung.output = coil("nested-output", 10); const ControlLogic program = logic({nested_rung}); writeBit(repository, 1, true); writeBit(repository, 2, true); LogicTraceSnapshot trace; require(executor.executeScan({program}, repository, &trace).succeeded, "nested expression scan must succeed"); require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)"); require(trace.expressionValues.at("nested-series") && trace.expressionValues.at("root-parallel") && trace.rungValues.at("nested-rung"), "scan trace must expose active nested expression and rung values"); writeBit(repository, 2, false); require(executor.executeScan({program}, repository, &trace).succeeded, "nested false scan must succeed"); require(!readBit(repository, 10), "incomplete B AND C branch must be false"); writeBit(repository, 0, true); require(executor.executeScan({program}, repository, &trace).succeeded, "alternate branch scan must succeed"); require(readBit(repository, 10), "A branch must independently energize output"); } void testUnconditionalCoil() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; LadderRung unconditional; unconditional.id = "unconditional-rung"; unconditional.name = "unconditional-rung"; unconditional.condition = ConditionExpression::fromWire( "unconditional-wire", ProjectLimits::kMaximumConditionColumns); unconditional.output = coil("unconditional-coil", 10); const ControlLogic program = logic({unconditional}); LogicTraceSnapshot trace; require(executor.validate({program}).succeeded, "a full-width wire network must pass runtime validation"); require(executor.executeScan({program}, repository, &trace).succeeded, "a full-width wire network scan must succeed"); require(readBit(repository, 10), "a full-width wire network must energize its coil as a constant-true rung"); require(trace.rungValues.at("unconditional-rung") && trace.nodePowerValues.at("unconditional-coil"), "an unconditional rung must report energized power flow"); } void testWirePassThroughAndPowerTrace() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; ConditionExpression root; root.id = "wire-series"; root.kind = ConditionExpressionKind::Series; root.children = { ConditionExpression::fromNode(contact("wire-input", 0)), ConditionExpression::fromWire("wire-segment", 2), ConditionExpression::fromNode(contact("wire-output", 1)), ConditionExpression::fromWire("wire-output-padding", 6)}; LadderRung wired_rung; wired_rung.id = "wired-rung"; wired_rung.name = "wired-rung"; wired_rung.condition = root; wired_rung.output = coil("wired-coil", 10); const ControlLogic program = logic({wired_rung}); writeBit(repository, 1, true); LogicTraceSnapshot trace; require(executor.executeScan({program}, repository, &trace).succeeded, "wire expression scan must succeed"); require(!readBit(repository, 10), "a horizontal wire must not bypass a false upstream series contact"); require(trace.expressionValues.at("wire-segment") && !trace.expressionInputValues.at("wire-segment") && !trace.expressionPowerValues.at("wire-segment"), "a wire must remain logically true without showing false upstream power"); writeBit(repository, 0, true); require(executor.executeScan({program}, repository, &trace).succeeded, "powered wire expression scan must succeed"); require(readBit(repository, 10) && trace.expressionInputValues.at("wire-segment") && trace.expressionPowerValues.at("wire-segment"), "a powered horizontal wire must pass current to the downstream contact"); } void testSeriesParallelContactsAndSequentialVisibility() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; const ControlLogic program = logic({ rung("rung-1", {{contact("start", 0), contact("alternate", 1)}, {contact("stop", 2, ContactMode::NormallyClosed)}}, coil("run", 3)), rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))}); writeBit(repository, 1, true); require(executor.executeScan({program}, repository).succeeded, "parallel and series scan must succeed"); require(readBit(repository, 3), "parallel OR and series AND must energize output"); require(readBit(repository, 4), "a later rung must see an earlier rung write in the same scan"); writeBit(repository, 2, true); require(executor.executeScan({program}, repository).succeeded, "normally closed scan must succeed"); require(!readBit(repository, 3), "normally closed stop contact must open the rung"); require(!readBit(repository, 4), "downstream normal coil must follow the new value"); } void testAllComparisons() { const std::array operations{ ComparisonOperator::Equal, ComparisonOperator::NotEqual, ComparisonOperator::LessThan, ComparisonOperator::LessThanOrEqual, ComparisonOperator::GreaterThan, ComparisonOperator::GreaterThanOrEqual}; const std::array actual_values{10, 9, 9, 10, 11, 10}; for (std::size_t index = 0; index < operations.size(); ++index) { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; writeWord(repository, 0, actual_values[index]); const ControlLogic program = logic({ rung("compare-rung", {{comparison("compare", 0, operations[index], 10)}, }, coil("result", 10))}); require(executor.executeScan({program}, repository).succeeded, "comparison scan must succeed"); require(readBit(repository, 10), "comparison operator must evaluate true"); } } void testSetResetAndDisabledLogic() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; ControlLogic program = logic({ rung("set-rung", {{contact("set-input", 0)}}, coil("set-output", 5, CoilMode::Set)), rung("reset-rung", {{contact("reset-input", 1)}}, coil("reset-output", 6, CoilMode::Reset))}); writeBit(repository, 0, true); require(executor.executeScan({program}, repository).succeeded, "set scan must succeed"); require(readBit(repository, 5), "set coil must latch true"); writeBit(repository, 0, false); require(executor.executeScan({program}, repository).succeeded, "inactive set scan must succeed"); require(readBit(repository, 5), "inactive set coil must retain its value"); writeBit(repository, 1, true); writeBit(repository, 6, true); require(executor.executeScan({program}, repository).succeeded, "reset scan must succeed"); require(!readBit(repository, 6), "reset coil must write false"); program.enabled = false; writeBit(repository, 5, true); require(executor.executeScan({program}, repository).succeeded, "disabled logic scan must be ignored successfully"); require(readBit(repository, 5), "disabled logic must not change outputs"); } void testMultipleLogicScanOrderAndTraceIsolation() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; ControlLogic first = logic({ rung("rung-1", {{contact("input", 0)}}, coil("output", 1))}); first.id = "logic-first"; first.name = "First"; ControlLogic second = logic({ rung("rung-1", {{contact("input", 1)}}, coil("output", 2))}); second.id = "logic-second"; second.name = "Second"; writeBit(repository, 0, true); LogicTraceSnapshot trace; require(executor.executeScan({first, second}, repository, &trace).succeeded, "all enabled logic modules must execute in project order"); require(readBit(repository, 1) && readBit(repository, 2), "a later logic module must observe an earlier module write in one scan"); require(trace.logicValues.size() == 2U && trace.forLogic(first.id).rungValues.at("rung-1") && trace.forLogic(second.id).rungValues.at("rung-1"), "runtime traces must be partitioned by logic id when node ids repeat"); ControlLogic disabled_draft; disabled_draft.id = "logic-draft"; disabled_draft.name = "Draft"; disabled_draft.enabled = false; disabled_draft.rungs.push_back( {"rung-1", "Draft", {}, std::nullopt, std::nullopt}); require(executor.validate({first, disabled_draft}).succeeded, "a disabled incomplete logic module must not block offline execution"); } void testEdgeContactsAreOneScanPulsesAndAreLogicScoped() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; const ControlLogic program = logic({ rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}}, coil("rising-output", 10)), rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}}, coil("falling-output", 11))}); writeBit(repository, 0, true); require(executor.executeScan({program}, repository).succeeded, "the first high scan must evaluate rising edge input"); require(readBit(repository, 10), "rising edge must pulse on OFF to ON"); require(!readBit(repository, 11), "falling edge must stay off without ON to OFF"); require(executor.executeScan({program}, repository).succeeded, "a repeated high scan must succeed"); require(!readBit(repository, 10), "rising edge must clear after one scan"); writeBit(repository, 1, true); require(executor.executeScan({program}, repository).succeeded, "the falling edge input must establish its previous high state"); writeBit(repository, 1, false); require(executor.executeScan({program}, repository).succeeded, "the first low scan after high must succeed"); require(readBit(repository, 11), "falling edge must pulse on ON to OFF"); require(executor.executeScan({program}, repository).succeeded, "a repeated low scan must succeed"); require(!readBit(repository, 11), "falling edge must clear after one scan"); ControlLogic first = logic({ rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}}, coil("shared-output-first", 20))}); first.id = "logic-first"; first.name = "First"; ControlLogic second = logic({ rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}}, coil("shared-output-second", 21))}); second.id = "logic-second"; second.name = "Second"; writeBit(repository, 2, true); require(executor.executeScan({first, second}, repository).succeeded, "logic-scoped edge history must support duplicate node ids"); writeBit(repository, 3, true); require(executor.executeScan({first, second}, repository).succeeded, "each duplicate edge node must update its own history"); require(!readBit(repository, 20) && readBit(repository, 21), "duplicate node ids in different logic modules must not share edge history"); executor.resetRuntime(); require(executor.executeScan({first, second}, repository).succeeded, "resetting runtime must clear edge history"); require(readBit(repository, 20) && readBit(repository, 21), "a restarted runtime must treat current ON inputs as fresh rising edges"); } void testMoveAndSaturatingArithmetic() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; const ControlLogic program = logic({ rung( "move-rung", {{contact("execute", 0)}}, move("move", constantOperand(7), 10)), rung( "add-rung", {{contact("execute-add", 0)}}, arithmetic( "add", ArithmeticOperation::Add, registerOperand(10), constantOperand(32767), 11)), rung( "sub-rung", {{contact("execute-sub", 0)}}, arithmetic( "sub", ArithmeticOperation::Subtract, constantOperand(-32768), constantOperand(1), 12))}); LogicTraceSnapshot trace; require(executor.executeScan({program}, repository, &trace).succeeded, "inactive data instructions must scan successfully"); require(readWord(repository, 10) == 0, "MOVE must not write while its rung is false"); writeBit(repository, 0, true); require(executor.executeScan({program}, repository, &trace).succeeded, "active data instructions must scan successfully"); require(readWord(repository, 10) == 7, "MOVE must copy a constant into the destination D register"); require(readWord(repository, 11) == 32767 && trace.wordValues.at("add").overflow, "ADD must saturate positive overflow and expose the overflow trace"); require(readWord(repository, 12) == -32768 && trace.wordValues.at("sub").overflow, "SUB must saturate negative overflow and expose the overflow trace"); const ControlLogic repeated_add = logic({ rung( "repeated-add-rung", {{contact("repeated-add-input", 1)}}, arithmetic( "repeated-add", ArithmeticOperation::Add, registerOperand(20), constantOperand(1), 20))}); writeBit(repository, 1, true); require(executor.executeScan({repeated_add}, repository).succeeded && executor.executeScan({repeated_add}, repository).succeeded, "ADD must execute on every scan while its rung remains true"); require(readWord(repository, 20) == 2, "ADD with the same source and destination must accumulate by scan"); } void testSetResetPairOnSameAddress() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; const ControlLogic program = logic({ rung("low-level-rung", {{comparison("low-level", 0, ComparisonOperator::LessThanOrEqual, 30)}}, coil("pump-set", 20, CoilMode::Set)), rung("high-level-rung", {{comparison("high-level", 0, ComparisonOperator::GreaterThanOrEqual, 80)}}, coil("pump-reset", 20, CoilMode::Reset))}); writeWord(repository, 0, 20); require(executor.executeScan({program}, repository).succeeded, "set/reset pair scan must succeed at the low limit"); require(readBit(repository, 20), "low level must latch the pump on"); writeWord(repository, 0, 50); require(executor.executeScan({program}, repository).succeeded, "set/reset pair scan must succeed inside the deadband"); require(readBit(repository, 20), "deadband must retain the latched on state"); writeWord(repository, 0, 90); require(executor.executeScan({program}, repository).succeeded, "set/reset pair scan must succeed at the high limit"); require(!readBit(repository, 20), "high level must reset the pump"); writeWord(repository, 0, 50); require(executor.executeScan({program}, repository).succeeded, "set/reset pair rescan must succeed inside the deadband"); require(!readBit(repository, 20), "deadband must retain the reset state"); } void testConflictingCoilsAreRejected() { SoftwareLogicExecutor executor; const ControlLogic normal_and_set = logic({ rung("normal-rung", {{contact("normal-input", 0)}}, coil("normal", 8)), rung("set-rung", {{contact("set-input", 1)}}, coil("set", 8, CoilMode::Set))}); const LogicScanResult set_result = executor.validate({normal_and_set}); require(!set_result.succeeded && set_result.error == LogicScanError::ConflictingOutput, "normal and set coils for one address must be rejected"); require(set_result.rungId == "set-rung" && set_result.nodeId == "set", "conflict error must identify the offending rung and node"); const ControlLogic reset_and_normal = logic({ rung("reset-rung", {{contact("reset-input", 0)}}, coil("reset", 9, CoilMode::Reset)), rung("normal-rung", {{contact("normal-input", 1)}}, coil("normal", 9))}); const LogicScanResult reset_result = executor.validate({reset_and_normal}); require(!reset_result.succeeded && reset_result.error == LogicScanError::ConflictingOutput, "reset and normal coils for one address must be rejected"); } void testHmiSimulationClosedLoop() { VirtualRegisterRepository repository; HmiRuntimeService hmi(repository); SoftwareLogicExecutor executor; HmiControl start; start.id = "start-button"; start.type = HmiControlType::Button; start.bounds = {0, 0, 80, 30}; start.text = "start"; start.binding = RegisterAddress{RegisterArea::M, 0}; start.buttonOperation = HmiButtonOperation::MomentaryOn; HmiControl indicator; indicator.id = "run-indicator"; indicator.type = HmiControlType::Indicator; indicator.bounds = {0, 40, 80, 30}; indicator.text = "run"; indicator.binding = RegisterAddress{RegisterArea::M, 2}; const ControlLogic program = logic({ rung("hold-rung", {{contact("stop", 1, ContactMode::NormallyClosed)}, {contact("start", 0), contact("feedback", 2)}}, coil("run", 2))}); require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded, "HMI start button press must write virtual M"); require(executor.executeScan({program}, repository).succeeded, "closed-loop scan must succeed"); require(hmi.readControl(indicator).bit_value, "HMI indicator must observe the logic output"); require(hmi.operateButton(start, HmiButtonEvent::Released).succeeded, "HMI start button release must write virtual M"); require(executor.executeScan({program}, repository).succeeded, "holding scan must succeed"); require(hmi.readControl(indicator).bit_value, "feedback contact must hold the output after start turns off"); writeBit(repository, 1, true); require(executor.executeScan({program}, repository).succeeded, "stop scan must succeed"); require(!hmi.readControl(indicator).bit_value, "stop contact must clear the HMI run indication"); } void testSimulationLifecycleSnapshotAndFault() { VirtualRegisterRepository repository; OfflineSimulationService simulation(repository); ControlLogic program = logic({ rung("snapshot-rung", {{contact("input", 0)}}, coil("output", 1))}); writeBit(repository, 0, true); require(simulation.start({program}).succeeded, "simulation must start"); require(simulation.state() == SimulationState::Running, "simulation must report running"); require(!readBit(repository, 0), "starting a session must clear virtual registers"); require(simulation.scanIntervalMs() == 50, "default scan interval must be 50 ms"); writeBit(repository, 0, true); program.rungs.front().output = coil("changed-output", 9); require(simulation.executeOnce().succeeded, "manual lifecycle scan must succeed"); require(readBit(repository, 1), "simulation must use its start-time snapshot"); require(simulation.successfulScanCount() == 1, "successful scans must be counted"); simulation.stop(); require(simulation.state() == SimulationState::Stopped, "simulation must stop"); require(simulation.start({program}).succeeded, "stopped simulation must support restart"); require(!readBit(repository, 1), "new session must clear prior output values"); simulation.stop(); } void testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs() { VirtualRegisterRepository repository; VirtualRegisterRepository initial_repository; OfflineSimulationService simulation(repository, &initial_repository); const ControlLogic program = logic({ rung("initial-rung", {{contact("initial-input", 0)}}, coil("runtime-output", 1))}); writeBit(initial_repository, 0, true); writeWord(initial_repository, 10, 321); require(simulation.start({program}).succeeded, "simulation must start from the explicit initial repository"); require(readBit(repository, 0) && readWord(repository, 10) == 321, "simulation startup must copy initial M/D values into its runtime repository"); require(simulation.executeOnce().succeeded && readBit(repository, 1), "simulation must execute against the copied initial values"); simulation.stop(); require(!readBit(repository, 1) && readBit(repository, 0) && readWord(repository, 10) == 321, "stopping simulation must restore initial values instead of runtime outputs"); writeBit(initial_repository, 0, false); require(simulation.start({program}).succeeded, "simulation must support restart with changed initial values"); require(!readBit(repository, 0) && !readBit(repository, 1), "restart must use the latest initial values and discard the previous output"); simulation.stop(); } class FailingVirtualRegisterRepository final : public VirtualRegisterRepository { public: BitReadResult readBit(const RegisterAddress &) const override { return {false, false, RegisterError::Unavailable}; } }; void testRepositoryFailureEntersFaultState() { FailingVirtualRegisterRepository repository; OfflineSimulationService simulation(repository); const ControlLogic program = logic({ rung("fault-rung", {{contact("fault-input", 0)}}, coil("fault-output", 1))}); require(simulation.start({program}).succeeded, "valid logic must pass simulation startup"); const LogicScanResult result = simulation.executeOnce(); require(!result.succeeded && result.error == LogicScanError::RegisterReadFailed, "repository read failure must fail the scan"); require(simulation.state() == SimulationState::Faulted, "repository failure must enter the fault state"); require(simulation.lastError().logicId == "logic-1" && simulation.lastError().rungId == "fault-rung" && simulation.lastError().nodeId == "fault-input", "fault feedback must retain logic, rung and node context"); } void testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource() { VirtualRegisterRepository plc_source; OnlineLogicMonitorService monitor(plc_source); const ControlLogic program = logic({ rung("online-rung-1", {{contact("online-input", 0)}}, coil("online-output-1", 1)), rung("online-rung-2", {{contact("online-feedback", 1)}}, coil("online-output-2", 2))}); writeBit(plc_source, 0, true); require(monitor.start({program}).succeeded, "online monitor must start from the PLC source snapshot"); const LogicTraceSnapshot &first_trace = monitor.traceSnapshot(); require(first_trace.rungValues.at("online-rung-1") && first_trace.rungValues.at("online-rung-2"), "a local output must be visible to later rungs in the same temporary scan"); require(!readBit(plc_source, 1) && !readBit(plc_source, 2), "local trace outputs must never change the PLC source repository"); writeBit(plc_source, 0, false); require(monitor.executeOnce().succeeded, "a new PLC snapshot must support another local trace scan"); const LogicTraceSnapshot &second_trace = monitor.traceSnapshot(); require(!second_trace.rungValues.at("online-rung-1") && !second_trace.rungValues.at("online-rung-2"), "each local scan must restart from the latest PLC source values"); require(!readBit(plc_source, 1) && !readBit(plc_source, 2), "repeated local scans must remain read-only toward the PLC source"); } void testOnlineMonitorPreservesFaultAfterStopping() { FailingVirtualRegisterRepository plc_source; OnlineLogicMonitorService monitor(plc_source); const ControlLogic program = logic({ rung("online-fault-rung", {{contact("online-fault-input", 0)}}, coil("online-fault-output", 1))}); const OnlineLogicMonitorStartResult start = monitor.start({program}); require(!start.succeeded && start.detail.error == LogicScanError::RegisterReadFailed, "an online snapshot failure must be reported at startup"); monitor.stop(); require(monitor.state() == OnlineLogicMonitorState::Stopped, "stopping a failed online monitor must leave it stopped"); require(!monitor.lastError().succeeded && monitor.lastError().error == LogicScanError::RegisterReadFailed, "stopping after a local trace fault must preserve its diagnostic"); } } // namespace int main(int argc, char *argv[]) { QCoreApplication application(argc, argv); try { testSeriesParallelContactsAndSequentialVisibility(); testNestedSeriesParallelExpression(); testUnconditionalCoil(); testWirePassThroughAndPowerTrace(); testAllComparisons(); testSetResetAndDisabledLogic(); testMultipleLogicScanOrderAndTraceIsolation(); testEdgeContactsAreOneScanPulsesAndAreLogicScoped(); testMoveAndSaturatingArithmetic(); testSetResetPairOnSameAddress(); testConflictingCoilsAreRejected(); testHmiSimulationClosedLoop(); testSimulationLifecycleSnapshotAndFault(); testSimulationUsesInitialValuesAndDiscardsRuntimeOutputs(); testRepositoryFailureEntersFaultState(); testOnlineMonitorUsesTemporaryRegistersWithoutWritingPlcSource(); testOnlineMonitorPreservesFaultAfterStopping(); } catch (const std::exception &error) { std::cerr << "offline simulation service tests failed: " << error.what() << '\n'; return 1; } std::cout << "offline simulation service tests passed\n"; return 0; }