#include #include #include "secsgem/gem/runtime.hpp" #include "secsgem/secs2/item.hpp" using namespace secsgem; namespace gem = secsgem::gem; namespace s2 = secsgem::secs2; #ifndef SECSGEM_DATA_DIR #error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt" #endif // Port 0 binds an OS-chosen ephemeral port. These tests never call run()/ // run_async(), so the acceptor is opened but never armed — we exercise the // outbound API by posting and draining with poll(), no host involved. static gem::EquipmentRuntime::Config test_config() { gem::EquipmentRuntime::Config c; c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml"; c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml"; c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml"; c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml"; c.port = 0; return c; } TEST_CASE("EquipmentRuntime loads the data dictionary from config") { gem::EquipmentRuntime rt(test_config()); CHECK(rt.model().svids.all().size() == 3); CHECK(rt.model().alarms.all().size() == 2); CHECK(rt.control_state() == gem::ControlState::HostOffline); } TEST_CASE("EquipmentRuntime.set_variable posts onto the io thread and updates the model") { gem::EquipmentRuntime rt(test_config()); rt.set_variable(1, s2::Item::ascii("OnlineRemote")); CHECK(rt.model().svids.value(1) != s2::Item::ascii("OnlineRemote")); // not yet — posted rt.poll(); CHECK(rt.model().svids.value(1) == s2::Item::ascii("OnlineRemote")); } TEST_CASE("EquipmentRuntime.set_alarm / clear_alarm toggle the active flag") { gem::EquipmentRuntime rt(test_config()); rt.set_alarm(1); rt.poll(); CHECK(rt.model().alarms.active(1)); rt.clear_alarm(1); rt.poll(); CHECK_FALSE(rt.model().alarms.active(1)); } TEST_CASE("EquipmentRuntime.on_command registers the behaviour hook on the model") { gem::EquipmentRuntime rt(test_config()); bool ran = false; rt.on_command("START", [&](const std::string& rcmd, const std::vector&) { ran = (rcmd == "START"); return gem::HostCmdAck::Accept; }); CHECK(rt.model().commands.has_handler("START")); auto res = rt.model().commands.dispatch("START", {}); CHECK(ran); CHECK(res.ack == gem::HostCmdAck::Accept); } TEST_CASE("EquipmentRuntime control-state mirror tracks transitions; observers coexist with default handlers") { gem::EquipmentRuntime rt(test_config()); int observed = 0; rt.add_control_state_observer( [&](gem::ControlState, gem::ControlState, gem::ControlEvent) { ++observed; }); // Simulate what register_default_handlers does: claim the PRIMARY slot // after the runtime (mirror) and the observer are already registered. rt.control().set_state_change_handler( [](gem::ControlState, gem::ControlState, gem::ControlEvent) {}); CHECK(rt.control_state() == gem::ControlState::HostOffline); // mirror initial rt.control().on_host_request_online(); // HostOffline -> ... -> OnlineRemote // The atomic mirror followed the FSM, and the added observer survived the // primary set_ call — the exact scenario that used to be impossible. CHECK(rt.control_state() == gem::ControlState::OnlineRemote); CHECK(rt.control_state() == rt.control().state()); CHECK(observed == 2); // two chained transitions }