Files
secs-gem/tests/test_data_model.cpp
T
raphael b1772cfefd feat(daemon): Phase D — GEM300 in-the-loop (jobs, recipes, EC changes)
Semantics settled and documented: v1 is observe-and-report. The engine keeps
acking S16/S3/S7/S2F15 from its FSM tables — exactly the behaviour both
reference implementations validated — while the tool observes lifecycle
events on the Subscribe stream and reports physical progress back. Gating
stays the documented v2 deferred-reply item.

Engine: two new store observers (HandlerSlot pattern) — RecipeStore fires
(ppid, body) after an add (S7F3 downloads), EquipmentConstantStore fires
(id, value) on ACCEPTED S2F15 writes only. Unit-tested.

Daemon: the service registers PJ/recipe/EC observers (io thread; add_
observers coexist with register_default_handlers' primaries) and fans the
new HostRequest variants out via push_request (fire-and-forget, no-
buffering contract). ProcessJob carries action (Start->START, Resume->
RESUME, Paused->PAUSE, Stopping->STOP, Aborting->ABORT) + recipe + material
bindings read store-side on the io thread. ReportProcessJob maps SETTING_UP
->SetupComplete, COMPLETE->ProcessComplete, ABORTED->AbortComplete via
read_sync; PROCESSING is informational; unknown job => INVALID_OBJECT,
table-rejected transition => CANNOT_DO_NOW. Carriers deferred (CarrierStore
has no observer machinery; ReportCarrier stays UNIMPLEMENTED) — roadmap.

Python client: on_process_job / on_recipe / on_constant_change decorators +
report_job(job_id, state); ProcessJob dataclass exported.

Tests: daemon suite 141 -> 175 assertions — the full in-process loop
(S16F11 create -> tool setup -> S16F5 PJSTART -> stream ProcessJob with
recipe+carriers -> ReportProcessJob(COMPLETE) -> FSM at ProcessComplete),
rejection paths, S7F3 -> ProcessProgram, S2F15 -> ConstantChange with the
configured name. Core 475/3097 (observer units). Live regression: daemon
interop 20 checks + pyclient 13 checks still green against the running
daemon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:53:45 +02:00

479 lines
17 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);
}
TEST_CASE("host command handler runs vendor behaviour and decides the ack") {
HostCommandRegistry r;
r.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
// Behaviour: record that we ran, read a parameter, and choose the ack.
bool ran = false;
std::string seen_ppid;
r.set_handler("START", [&](const std::string& rcmd,
const std::vector<CommandParameter>& params) {
ran = true;
CHECK(rcmd == "START");
for (const auto& p : params)
if (p.name == "PPID") seen_ppid = p.value.as_ascii();
return HostCmdAck::Accept;
});
CHECK(r.has_handler("START"));
CHECK_FALSE(r.has_handler("STOP"));
std::vector<CommandParameter> params{{"PPID", s2::Item::ascii("RECIPE-A")}};
auto res = r.dispatch("START", params);
CHECK(ran);
CHECK(seen_ppid == "RECIPE-A");
CHECK(res.ack == HostCmdAck::Accept);
// Declarative side effects from the Spec still travel with the result.
CHECK(res.emit_ceid.value_or(0) == 300);
}
TEST_CASE("host command handler can reject, overriding the declarative ack") {
HostCommandRegistry r;
// YAML default says Accept; the handler decides otherwise at runtime.
r.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
r.set_handler("START", [](const std::string&,
const std::vector<CommandParameter>&) {
return HostCmdAck::CannotDoNow; // e.g. equipment busy
});
auto res = r.dispatch("START", {});
CHECK(res.ack == HostCmdAck::CannotDoNow);
// emit_ceid is still carried, but the router only applies it on Accept,
// so a rejecting handler naturally suppresses the event.
CHECK(res.emit_ceid.value_or(0) == 300);
}
TEST_CASE("host command with no handler stays purely declarative") {
HostCommandRegistry r;
r.register_command("STOP", {HostCmdAck::Accept, std::nullopt, std::nullopt});
// No set_handler call: behaviour is exactly the pre-existing YAML default.
CHECK_FALSE(r.has_handler("STOP"));
CHECK(r.dispatch("STOP", {}).ack == HostCmdAck::Accept);
}
TEST_CASE("host command handler on an unregistered command is never reached") {
HostCommandRegistry r;
bool ran = false;
// Handler attached but the command was never registered in the dictionary.
r.set_handler("GHOST", [&](const std::string&,
const std::vector<CommandParameter>&) {
ran = true;
return HostCmdAck::Accept;
});
auto res = r.dispatch("GHOST", {});
CHECK(res.ack == HostCmdAck::InvalidCommand);
CHECK_FALSE(ran); // unknown commands short-circuit before the handler
}
// ---- 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);
}
TEST_CASE("RecipeStore added-observer fires after the store is updated") {
RecipeStore r;
std::string seen_ppid, seen_body;
r.add_added_handler([&](const std::string& p, const std::string& b) {
seen_ppid = p;
seen_body = b;
});
r.add("R-1", "body");
CHECK(seen_ppid == "R-1");
CHECK(seen_body == "body");
CHECK(r.get("R-1").has_value()); // observer saw the post-update store
}
TEST_CASE("EquipmentConstantStore changed-observer fires on Accept only") {
EquipmentConstantStore ecids;
ecids.add({10, "Knob", "", s2::Item::u4(uint32_t{1}),
s2::Item::u4(uint32_t{1}), "0", "5"});
int fired = 0;
ecids.add_changed_handler([&](uint32_t id, const s2::Item& v) {
++fired;
CHECK(id == 10);
CHECK(v == s2::Item::u4(uint32_t{3}));
});
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{3})) == EquipmentAck::Accept);
CHECK(fired == 1);
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{99})) ==
EquipmentAck::Denied_OutOfRange);
CHECK(ecids.set_value(999, s2::Item::u4(uint32_t{1})) ==
EquipmentAck::Denied_UnknownEcid);
CHECK(fired == 1); // rejected writes never fire
}