Files
secs-gem/tests/test_data_model.cpp
T
raphael d69f26b415 CC1: persistent file-backed spool
Adds opt-in disk persistence to SpoolStore.  `enable_persistence(dir)`
turns every enqueue into a single `<seq>.spool` file alongside the
in-memory queue; drain and clear delete the matching files; restart
replays the directory sorted by seq.

Writes are atomic: serialize the message via the SECS-II codec, write
to `.tmp`, then `std::filesystem::rename` to the final name.  Malformed
records are dropped silently so a single bad file can't poison the
whole spool.

`secs_server --spool-dir <path>` enables persistence at startup.
Without the flag the behaviour is identical to before (in-memory only).

Two new tests: enqueue → restart → replay → drain restores the wire
order, and clear deletes the journal files.

Test suite: 291 cases / 1515 assertions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:51:46 +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 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);
}