Files
secs-gem/tests/test_data_model.cpp
T
raphael fc3422a4a9 docs: move root .md files into docs/ + update every reference
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>
2026-06-09 19:36:27 +02:00

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);
}