#include "domain/hmi_model.h" #include "domain/register_repository.h" #include "services/hmi_runtime_service.h" #include "services/offline_simulation_service.h" #include "services/software_logic_executor.h" #include #include #include #include #include #include namespace { void require(bool condition, const std::string &message) { if (!condition) { throw std::runtime_error(message); } } LogicNode contact(const std::string &id, int address, ContactMode mode = ContactMode::NormallyOpen) { return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, 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); } 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"); } 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 root; root.id = "root-parallel"; root.kind = ConditionExpressionKind::Parallel; root.children = { ConditionExpression::fromNode(contact("a", 0)), nested_series}; 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 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 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(); } 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"); } } // namespace int main(int argc, char *argv[]) { QCoreApplication application(argc, argv); try { testSeriesParallelContactsAndSequentialVisibility(); testNestedSeriesParallelExpression(); testAllComparisons(); testSetResetAndDisabledLogic(); testSetResetPairOnSameAddress(); testConflictingCoilsAreRejected(); testHmiSimulationClosedLoop(); testSimulationLifecycleSnapshotAndFault(); testRepositoryFailureEntersFaultState(); } 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; }