#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; for (std::size_t index = 0; index < stages.size(); ++index) { result.stages.push_back({id + "-stage-" + std::to_string(index), stages[index]}); } 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 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 testConflictingCoilsAreRejected() { SoftwareLogicExecutor executor; const ControlLogic program = 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 result = executor.validate({program}); require(!result.succeeded && result.error == LogicScanError::ConflictingOutput, "mixed output modes for one address must be rejected"); require(result.rungId == "set-rung" && result.nodeId == "set", "conflict error must identify the offending rung and node"); } 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}; 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.toggleButton(start).succeeded, "HMI start button 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.toggleButton(start).succeeded, "HMI start button must toggle off"); 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(); testAllComparisons(); testSetResetAndDisabledLogic(); 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; }