fc3422a4a9
Picks up the file renames that landed alongside the previous commit and fixes everything that pointed at the old root locations: - README.md doc-map updated: every entry now points at docs/X.md, with a new "docs/" lead entry pointing at the guided-tour index. - README inline cross-refs (ARCHITECTURE / INTEGRATION / SECURITY / BENCHMARKS / MES_INTEROP / PROOFS) repointed to docs/. - README "Interop" section rewritten — used to mention only secsgem-py; now covers all four external validators (secsgem-py 31 / secs4java8 55 / tshark 69 frames / libFuzzer 200 k+ runs) with a one-line summary each, plus pointers to interop/README.md and docs/VERIFICATION.md. - README "Deferred follow-ups" cleaned: dropped the explanatory "Listed here so reviewers don't go looking for them in COMPLIANCE.md and find an 'out of scope' entry that sounds defensive" sentence — the section header speaks for itself. - docs/00_index.md "Where the rest of the docs live" table: dropped every `../` prefix since the docs are now siblings. - docs/01_what_is_secs_gem.md PROOFS reference updated to sibling. - docs/02_the_cast.md INTEGRATION + MES_INTEROP refs updated to siblings; dropped the stale "at the repo root" wording. - interop/README.md: VERIFICATION + PROOFS refs updated to ../docs/X.md; stale "~24 + 4 checks" updated to 31 (matches PROOFS.md and README). - examples/pvd_tool/README.md: every doc cross-ref now points at ../../docs/X.md. - Source / data / CI comments mentioning doc names (e.g. "INTEGRATION.md §3", "COMPLIANCE.md gap") rewritten to "docs/INTEGRATION.md §3" etc. — affects 9 files across include/, apps/, tests/, data/, examples/, .gitea/workflows/. Verified: full build under docker passes, 445/445 test cases pass, 2 753/2 753 assertions pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
380 lines
13 KiB
C++
380 lines
13 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <sys/types.h>
|
|
#include <unistd.h>
|
|
|
|
#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 docs/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<uint8_t>(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<uint8_t>(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<uint8_t>(
|
|
static_cast<uint8_t>(AlarmSeverity::EquipmentSafety) |
|
|
static_cast<uint8_t>(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<uint8_t>(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<uint32_t>(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<std::vector<uint32_t>>(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<std::vector<uint32_t>>(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);
|
|
}
|