b1772cfefd
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>
479 lines
17 KiB
C++
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
|
|
}
|