#include #include #include #include #include #include #include "secsgem/gem/data_model.hpp" using namespace secsgem::gem; // ---- Status variables ---------------------------------------------------- TEST_CASE("SVID add / get / list / set value") { StatusVariableStore svids; svids.add({1, "Clock", "", s2::Item::ascii("19700101000000")}); svids.add({2, "EventsEnabled", "", s2::Item::boolean(true)}); CHECK(svids.get(1).has_value()); CHECK_FALSE(svids.get(99).has_value()); CHECK(svids.all().size() == 2); svids.set_value(2, s2::Item::boolean(false)); CHECK(svids.get(2)->value == s2::Item::boolean(false)); } // ---- Equipment constants ------------------------------------------------- TEST_CASE("ECID set rejects unknown id and out-of-range values") { EquipmentConstantStore ecids; ecids.add({10, "MaxSpool", "msgs", s2::Item::u4(uint32_t{100}), s2::Item::u4(uint32_t{100}), "0", "1000"}); CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{50})) == EquipmentAck::Accept); CHECK(ecids.get(10)->value == s2::Item::u4(uint32_t{50})); CHECK(ecids.set_value(999, s2::Item::u4(uint32_t{1})) == EquipmentAck::Denied_UnknownEcid); // Out-of-range rejected (closes COMPLIANCE.md gap). CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{5000})) == EquipmentAck::Denied_OutOfRange); CHECK(ecids.get(10)->value == s2::Item::u4(uint32_t{50})); // unchanged } TEST_CASE("ECID with no min/max accepts any value") { EquipmentConstantStore ecids; ecids.add({1, "n", "", s2::Item::u4(uint32_t{0}), s2::Item::u4(uint32_t{0}), "", ""}); CHECK(ecids.set_value(1, s2::Item::u4(uint32_t{99999999})) == EquipmentAck::Accept); } TEST_CASE("ECID range validation skipped for non-numeric formats") { EquipmentConstantStore ecids; ecids.add({1, "label", "", s2::Item::ascii("a"), s2::Item::ascii("a"), "0", "10"}); CHECK(ecids.set_value(1, s2::Item::ascii("foo")) == EquipmentAck::Accept); } // ---- Clock --------------------------------------------------------------- TEST_CASE("clock string has 16 characters") { Clock c; auto s = c.current_time_string(); REQUIRE(s.size() == 16); } TEST_CASE("set_time_string accepts well-formed and rejects malformed") { Clock c; CHECK(c.set_time_string("20260101000000") == TimeAck::Accept); CHECK(c.set_time_string("2026010100000000") == TimeAck::Accept); CHECK(c.set_time_string("not-a-date-12345") == TimeAck::Error); CHECK(c.set_time_string("short") == TimeAck::Error); } TEST_CASE("Clock: E148 sync quality starts Unsynchronized") { Clock c; CHECK(c.sync_quality() == TimeSyncQuality::Unsynchronized); CHECK(c.sync_count() == 0); } TEST_CASE("Clock: consecutive set_time_string updates record drift") { Clock c; // First sync: drift measured against the (zero) initial offset. REQUIRE(c.set_time_string("20260101000000") == TimeAck::Accept); CHECK(c.sync_count() == 1); // Second sync, far in the future: drift should be a large positive number. REQUIRE(c.set_time_string("20270101000000") == TimeAck::Accept); CHECK(c.sync_count() == 2); CHECK(c.last_drift_seconds() > 60 * 60 * 24); // > 1 day CHECK(c.sync_quality() == TimeSyncQuality::Unsynchronized); } TEST_CASE("Clock: same-value resync registers as Synchronized") { Clock c; REQUIRE(c.set_time_string("20260601000000") == TimeAck::Accept); // Apply the same target again; the offset doesn't move materially. REQUIRE(c.set_time_string("20260601000000") == TimeAck::Accept); CHECK(std::abs(c.last_drift_seconds()) <= 1); CHECK(c.sync_quality() == TimeSyncQuality::Synchronized); } // ---- Host command registry ---------------------------------------------- TEST_CASE("host command registry returns spec + result") { HostCommandRegistry r; r.register_command("START", {HostCmdAck::Accept, 300, std::nullopt}); r.register_command("STOP", {HostCmdAck::CannotDoNow, std::nullopt, std::nullopt}); r.register_command("FAULT", {HostCmdAck::Accept, std::nullopt, 1}); CHECK(r.has("START")); CHECK_FALSE(r.has("PAUSE")); auto start = r.dispatch("START", {}); CHECK(start.ack == HostCmdAck::Accept); CHECK(start.emit_ceid.value_or(0) == 300); auto fault = r.dispatch("FAULT", {}); CHECK(fault.set_alarm.value_or(0) == 1); CHECK(r.dispatch("UNKNOWN", {}).ack == HostCmdAck::InvalidCommand); } // ---- Event reports ------------------------------------------------------- TEST_CASE("define reports rejects unknown VID") { EventReportSubscriptions ev; ev.register_event({100, "x"}); auto exists = [](uint32_t id) { return id == 1 || id == 2; }; CHECK(ev.define_reports({{1000, {1, 2}}}, exists) == DefineReportAck::Accept); CHECK(ev.define_reports({{1001, {1, 999}}}, exists) == DefineReportAck::InvalidVid); } TEST_CASE("full event-report pipeline") { StatusVariableStore svids; svids.add({1, "ControlState", "", s2::Item::ascii("OnlineRemote")}); svids.add({2, "Clock", "", s2::Item::ascii("19700101000000")}); EventReportSubscriptions ev; ev.register_event({100, "ControlStateChanged"}); ev.register_event({200, "AlarmSetEvent"}); auto exists = [&](uint32_t id) { return svids.has(id); }; auto value = [&](uint32_t id) { return svids.value(id); }; REQUIRE(ev.define_reports({{1000, {1}}, {1001, {1, 2}}}, exists) == DefineReportAck::Accept); REQUIRE(ev.link_event_reports({{100, {1000, 1001}}, {200, {1001}}}) == LinkEventAck::Accept); CHECK_FALSE(ev.is_enabled(100)); REQUIRE(ev.enable_events(true, {100}) == EnableEventAck::Accept); CHECK(ev.is_enabled(100)); CHECK_FALSE(ev.is_enabled(200)); auto reports = ev.compose_for(100, value); REQUIRE(reports.size() == 2); CHECK(reports[0].rptid == 1000); REQUIRE(reports[0].values.size() == 1); CHECK(reports[0].values[0] == s2::Item::ascii("OnlineRemote")); CHECK(reports[1].rptid == 1001); REQUIRE(reports[1].values.size() == 2); } TEST_CASE("enable_events with empty CEID list enables all registered events") { EventReportSubscriptions ev; ev.register_event({1, "a"}); ev.register_event({2, "b"}); CHECK(ev.enable_events(true, {}) == EnableEventAck::Accept); CHECK(ev.is_enabled(1)); CHECK(ev.is_enabled(2)); CHECK(ev.enable_events(false, {}) == EnableEventAck::Accept); CHECK_FALSE(ev.is_enabled(1)); } TEST_CASE("link_event_reports rejects unknown CEID or RPTID") { EventReportSubscriptions ev; ev.register_event({100, "x"}); ev.define_reports({{500, {1}}}, [](uint32_t) { return true; }); CHECK(ev.link_event_reports({{999, {500}}}) == LinkEventAck::UnknownCeid); CHECK(ev.link_event_reports({{100, {999}}}) == LinkEventAck::UnknownRptid); CHECK(ev.link_event_reports({{100, {500}}}) == LinkEventAck::Accept); } // ---- Alarms -------------------------------------------------------------- TEST_CASE("alarm enable / set / clear / list") { AlarmRegistry r; r.add({1, "Chiller Temp High", 4}); r.add({2, "Door Open", 1}); CHECK(r.set_enabled(1, true) == AlarmAck::Accept); CHECK(r.enabled(1)); CHECK(r.set_enabled(999, true) == AlarmAck::Error); auto alcd_set = r.set_active(1); REQUIRE(alcd_set.has_value()); CHECK((*alcd_set & 0x80) != 0); CHECK((*alcd_set & 0x7F) == 4); CHECK(r.active(1)); auto alcd_clr = r.clear_active(1); REQUIRE(alcd_clr.has_value()); CHECK((*alcd_clr & 0x80) == 0); CHECK_FALSE(r.active(1)); CHECK(r.all().size() == 2); } TEST_CASE("AlarmSeverity bit-flag helpers (E5 §10.3 / E30 §6.13)") { // Single-category alarms. Alarm safety{1, "door open", static_cast(AlarmSeverity::PersonalSafety)}; CHECK(safety.has(AlarmSeverity::PersonalSafety)); CHECK_FALSE(safety.has(AlarmSeverity::EquipmentSafety)); CHECK(safety.is_safety()); Alarm temp_warn{2, "chiller high", static_cast(AlarmSeverity::ParameterWarning)}; CHECK_FALSE(temp_warn.is_safety()); CHECK(temp_warn.has(AlarmSeverity::ParameterWarning)); // Multi-category alarms: combine Irrecoverable + EquipmentSafety. Alarm combo{3, "spindle seized", static_cast( static_cast(AlarmSeverity::EquipmentSafety) | static_cast(AlarmSeverity::Irrecoverable))}; CHECK(combo.is_safety()); CHECK(combo.has(AlarmSeverity::Irrecoverable)); CHECK(combo.has(AlarmSeverity::EquipmentSafety)); CHECK_FALSE(combo.has(AlarmSeverity::PersonalSafety)); // The ALCD set-bit is *not* part of the category bitmap. const uint8_t set_alcd = static_cast(combo.severity_category | 0x80); CHECK(severity_bits(set_alcd) == combo.severity_category); CHECK(has_severity(set_alcd, AlarmSeverity::Irrecoverable)); } // ---- Process programs ---------------------------------------------------- TEST_CASE("recipe CRUD") { RecipeStore r; r.add("RECIPE-A", "step1\nstep2\n"); r.add("RECIPE-B", "alt body"); CHECK(r.list().size() == 2); CHECK(r.get("RECIPE-A").value() == "step1\nstep2\n"); CHECK_FALSE(r.get("UNKNOWN").has_value()); CHECK(r.remove("RECIPE-A") == ProcessProgramAck::Accept); CHECK_FALSE(r.get("RECIPE-A").has_value()); CHECK(r.remove("RECIPE-A") == ProcessProgramAck::PpidNotFound); } // ---- Composite EquipmentDataModel --------------------------------------- TEST_CASE("EquipmentDataModel composes the eight stores") { EquipmentDataModel m; m.svids.add({1, "Clock", "", s2::Item::ascii("")}); m.dvids.add({2, "Temp", "C", s2::Item::u4(uint32_t{25})}); CHECK(m.vid_exists(1)); CHECK(m.vid_exists(2)); CHECK_FALSE(m.vid_exists(3)); CHECK(m.vid_value(2) == s2::Item::u4(uint32_t{25})); } // ---- Spool --------------------------------------------------------------- TEST_CASE("spool enqueue respects spoolable_streams whitelist") { SpoolStore s; s.set_spoolable_streams({5, 6}); CHECK(s.enqueue(s2::Message(6, 11, false)) == SpoolStore::EnqueueResult::Queued); CHECK(s.enqueue(s2::Message(5, 1, false)) == SpoolStore::EnqueueResult::Queued); CHECK(s.enqueue(s2::Message(1, 2, false)) == SpoolStore::EnqueueResult::Dropped_NotSpoolable); CHECK(s.size() == 2); } TEST_CASE("spool FIFO eviction when max_size reached") { SpoolStore s; s.set_spoolable_streams({6}); s.set_max_size(3); for (int i = 0; i < 5; ++i) s.enqueue(s2::Message(6, 11, false, s2::Item::u4(static_cast(i)))); CHECK(s.size() == 3); // Oldest two (0, 1) were evicted; remaining are 2, 3, 4. auto drained = s.drain(); REQUIRE(drained.size() == 3); auto first_u4 = [](const s2::Message& m) -> uint32_t { return std::get>(m.body->storage()).front(); }; CHECK(first_u4(drained[0]) == 2); CHECK(first_u4(drained[2]) == 4); } TEST_CASE("spool drain returns FIFO order and empties the queue") { SpoolStore s; s.set_spoolable_streams({6}); s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{1}))); s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{2}))); auto out = s.drain(); CHECK(out.size() == 2); CHECK(s.empty()); CHECK(s.drain().empty()); } TEST_CASE("spool force flag controls whether enqueue is taken") { SpoolStore s; s.set_spoolable_streams({6}); CHECK_FALSE(s.force_spool()); s.set_force_spool(true); CHECK(s.force_spool()); } TEST_CASE("spool persistence: write, restart, replay") { namespace fs = std::filesystem; // Unique temp dir per test run. auto dir = fs::temp_directory_path() / ("spool_test_" + std::to_string(::getpid()) + "_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); fs::remove_all(dir); { SpoolStore s; s.set_spoolable_streams({5, 6}); s.enable_persistence(dir); CHECK(s.empty()); s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{42}))); s.enqueue(s2::Message(5, 1, false, s2::Item::ascii("ALARM"))); CHECK(s.size() == 2); // Two journal files written. std::size_t count = 0; for (auto& e : fs::directory_iterator(dir)) { if (e.path().extension() == ".spool") ++count; } CHECK(count == 2); } // Simulate restart: fresh store rehydrates from the same dir. { SpoolStore s; s.set_spoolable_streams({5, 6}); s.enable_persistence(dir); REQUIRE(s.size() == 2); auto drained = s.drain(); REQUIRE(drained.size() == 2); CHECK(drained[0].stream == 6); CHECK(drained[1].stream == 5); // FIFO: first-enqueued comes out first. REQUIRE(drained[0].body.has_value()); CHECK(std::get>(drained[0].body->storage()).front() == 42); CHECK(drained[1].body->as_ascii() == "ALARM"); // Files removed after drain. std::size_t count = 0; for (auto& e : fs::directory_iterator(dir)) { if (e.path().extension() == ".spool") ++count; } CHECK(count == 0); } fs::remove_all(dir); } TEST_CASE("spool persistence: clear deletes files") { namespace fs = std::filesystem; auto dir = fs::temp_directory_path() / ("spool_clear_" + std::to_string(::getpid()) + "_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count() + 1)); fs::remove_all(dir); SpoolStore s; s.set_spoolable_streams({6}); s.enable_persistence(dir); s.enqueue(s2::Message(6, 11, false)); s.enqueue(s2::Message(6, 11, false)); CHECK(s.size() == 2); s.clear(); CHECK(s.empty()); std::size_t count = 0; for (auto& e : fs::directory_iterator(dir)) { if (e.path().extension() == ".spool") ++count; } CHECK(count == 0); fs::remove_all(dir); }