diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 401b11c..80009af 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -92,7 +92,7 @@ Legend: | Equipment Terminal Services | 🟡 | E30 §6.19| S10F1/F2, S10F3/F4 | Single-line only. **S10F5/F6 multi-block, S10F7 broadcast not implemented.** | | Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. | | Limits Monitoring | ⬜ | E30 §6.21| S2F45/F46, S2F47/F48, S6F45/F46 | Not implemented. | -| Spooling | ⬜ | E30 §6.22| S6F23/F24, S6F25/F26, spool state model | Not implemented. The plan calls this out as a Layer-3+ subsystem. | +| Spooling | 🟡 | E30 §6.22| S2F43/F44, S6F23/F24 | Spoolable-streams config (S2F43/F44) + spool request transmit/purge (S6F23/F24) implemented; SpoolStore queues equipment-initiated primaries when the host is unreachable or force-spool is on, drains FIFO on S6F23 transmit, purges on S6F23 purge. **Not implemented**: S6F25/F26 spooled-data-ready notification, automatic spool activation tied to HSMS NOT-SELECTED, persistent (on-disk) spool. | | Control | ✅ | E30 §6.2 | — | See Fundamental. | --- @@ -129,7 +129,9 @@ Legend: | S6F7 / S6F8 | H↔E | ⬜ | — | multi-block | | S6F11 / S6F12 | E→H | ✅ | `messages.hpp` | ✅ round-trip + demo | | S6F15 / S6F16 | H→E | ⬜ | — | event report request | -| S6F23 / S6F26 | spool | ⬜ | — | spooling | +| S2F43 / S2F44 | H→E | ✅ | catalog | ✅ round-trip + demo | +| S6F23 / S6F24 | H→E | ✅ | catalog | ✅ round-trip + demo | +| S6F25 / S6F26 | spool | ⬜ | — | spool-data-ready notification | | S7F3 / S7F4 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S7F5 / S7F6 | H→E | ✅ | `messages.hpp` | ✅ in demo | | S7F19 / S7F20 | H→E | ✅ | `messages.hpp` | ✅ round-trip + demo | @@ -180,8 +182,11 @@ Unit tests: **63 cases / 278 assertions pass** (`docker compose run --rm tests`) The honest list, in priority order: -1. **Implement spooling** (E30 §6.22, S6F23/F24/F25/F26 + spool state - model). This is the single largest missing GEM feature. +1. **Finish spooling**: S6F25/F26 spooled-data-ready notification, plus + automatic activation when HSMS goes NOT-SELECTED (and automatic + notification on re-SELECT) so the host doesn't have to manually flip + the test-only `force_spool` flag. Optional: persistent on-disk spool + so equipment restarts don't lose queued events. 2. **Implement the S9 error stream** (S9F1, F3, F5, F7, F9, F11, F13) for the documented transport/protocol error cases. 3. **Implement Documentation messages** S1F19/F20 (GEM-compliance) and diff --git a/apps/secs_client.cpp b/apps/secs_client.cpp index 46ba579..fdf94ec 100644 --- a/apps/secs_client.cpp +++ b/apps/secs_client.cpp @@ -269,7 +269,60 @@ int main(int argc, char** argv) { }); }); - // 13. List recipes. + // 13a. Force the equipment into spool mode (test-only RCMD; stands in + // for "the host link has gone down" in the real protocol). + seq->steps.push_back([conn, logfn, fail](auto next) { + conn->send_request(gem::s2f41_host_command("SPOOL_ON", {}), + [logfn, fail, next](std::error_code ec, const s2::Message& reply) { + if (ec) { fail("S2F41/SPOOL_ON", ec); return; } + auto r = gem::parse_s2f42(reply); + logfn("S2F42 HCACK=" + + (r ? std::to_string(static_cast(r->hcack)) : "?")); + next(); + }); + }); + + // 13b. Trigger a START while spooling — the CEID 300 emission should + // land in the equipment's spool, not arrive live. + seq->steps.push_back([conn, logfn, fail, pause_then](auto next) { + conn->send_request(gem::s2f41_host_command("START", {}), + [logfn, fail, pause_then, next](std::error_code ec, + const s2::Message& reply) { + if (ec) { fail("S2F41/START(spool)", ec); return; } + auto r = gem::parse_s2f42(reply); + logfn("S2F42 HCACK=" + + (r ? std::to_string(static_cast(r->hcack)) : "?") + + " (CEID 300 should be spooled, not delivered live)"); + pause_then(std::chrono::milliseconds(150), next); + }); + }); + + // 13c. Drop the spool flag back to off. + seq->steps.push_back([conn, logfn, fail](auto next) { + conn->send_request(gem::s2f41_host_command("SPOOL_OFF", {}), + [logfn, fail, next](std::error_code ec, const s2::Message& reply) { + if (ec) { fail("S2F41/SPOOL_OFF", ec); return; } + auto r = gem::parse_s2f42(reply); + logfn("S2F42 HCACK=" + + (r ? std::to_string(static_cast(r->hcack)) : "?")); + next(); + }); + }); + + // 13d. Ask the equipment to transmit the spooled queue. + seq->steps.push_back([conn, logfn, fail, pause_then](auto next) { + conn->send_request(gem::s6f23_request_spool_data(gem::SpoolRequestCode::Transmit), + [logfn, fail, pause_then, next](std::error_code ec, + const s2::Message& reply) { + if (ec) { fail("S6F23", ec); return; } + auto a = gem::ack_byte(reply); + logfn("S6F24 RSDA=" + (a ? std::to_string(*a) : "?") + + " (expect spooled S6F11 to arrive next)"); + pause_then(std::chrono::milliseconds(300), next); + }); + }); + + // 14. List recipes. seq->steps.push_back([conn, logfn, fail](auto next) { conn->send_request(gem::s7f19_current_eppd_request(), [logfn, fail, next](std::error_code ec, const s2::Message& reply) { diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index 8f6c4c6..0284a33 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -73,10 +73,30 @@ int main(int argc, char** argv) { auto active_conn = std::make_shared>(); - auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) { - asio::post(io, [active_conn, model, logfn, ceid]() { - auto conn = active_conn->lock(); - if (!conn) return; + // Deliver a primary message to the host, or spool it if we can't (either + // there is no SELECTED session or the spool's force-flag is on). Returns + // true if the message was delivered or queued; false if dropped because + // the stream isn't spoolable and there is no live session. + auto deliver_or_spool = [active_conn, model, logfn](s2::Message msg, + const std::string& what) -> bool { + auto conn = active_conn->lock(); + const bool spooling = model->spool.force_spool() || !conn; + if (spooling) { + auto r = model->spool.enqueue(msg); + if (r == gem::SpoolStore::EnqueueResult::Queued) { + logfn("spool: " + what + " queued (depth=" + + std::to_string(model->spool.size()) + ")"); + return true; + } + logfn("spool: " + what + " dropped (stream not spoolable, no host)"); + return false; + } + conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {}); + return true; + }; + + auto emit_event = [&io, model, logfn, deliver_or_spool](uint32_t ceid) { + asio::post(io, [model, logfn, deliver_or_spool, ceid]() { if (!model->is_event_enabled(ceid)) { logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed"); return; @@ -84,23 +104,21 @@ int main(int argc, char** argv) { auto reports = model->compose_reports_for(ceid); logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + std::to_string(reports.size()) + " reports)"); - conn->send_request(gem::s6f11_event_report(0, ceid, reports), - [](std::error_code, const s2::Message&) {}); + deliver_or_spool(gem::s6f11_event_report(0, ceid, reports), + "S6F11 CEID=" + std::to_string(ceid)); }); }; - auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) { - asio::post(io, [active_conn, model, logfn, emit_event, alid]() { - auto conn = active_conn->lock(); - if (!conn) return; + auto emit_alarm_set = [&io, model, logfn, emit_event, deliver_or_spool](uint32_t alid) { + asio::post(io, [model, logfn, emit_event, deliver_or_spool, alid]() { auto alarm = model->alarms.get(alid); if (!alarm) return; auto alcd = model->alarms.set_active(alid); if (!alcd) return; if (model->alarms.enabled(alid)) { logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); - conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text), - [](std::error_code, const s2::Message&) {}); + deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text), + "S5F1 ALID=" + std::to_string(alid)); } else { logfn("alarm " + std::to_string(alid) + " not enabled; suppressed"); } @@ -250,10 +268,53 @@ int main(int argc, char** argv) { if (result.ack == gem::HostCmdAck::Accept) { if (result.emit_ceid) emit_event(*result.emit_ceid); if (result.set_alarm) emit_alarm_set(*result.set_alarm); + if (result.force_spool) { + model->spool.set_force_spool(*result.force_spool); + logfn(std::string("spool: force_spool=") + (*result.force_spool ? "true" : "false") + + " (depth=" + std::to_string(model->spool.size()) + ")"); + } } return gem::s2f42_host_command_ack(result.ack, {}); }); + router.on(2, 43, [model, logfn](const s2::Message& msg) { + auto streams = gem::parse_s2f43(msg); + auto ack = gem::ResetSpoolAck::Accept; + std::vector per; + if (!streams) { + ack = gem::ResetSpoolAck::Denied_NotAllowed; + } else { + model->spool.set_spoolable_streams(*streams); + logfn("S2F43 spoolable=" + std::to_string(streams->size()) + " streams"); + } + return gem::s2f44_reset_spooling_ack(ack, per); + }); + + router.on(6, 23, [&io, active_conn, model, logfn](const s2::Message& msg) { + auto rsdc = gem::parse_s6f23(msg); + if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied); + if (*rsdc == gem::SpoolRequestCode::Purge) { + const auto n = model->spool.size(); + model->spool.clear(); + logfn("S6F23 purge: dropped " + std::to_string(n) + " messages"); + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + } + // Transmit: drain the queue, fire each as a fresh primary. Defer to + // the executor so the S6F24 ack flushes before the drained primaries + // go out — the host should see ACK first, then the spooled traffic. + auto drained = model->spool.drain(); + logfn("S6F23 transmit: draining " + std::to_string(drained.size()) + + " messages"); + asio::post(io, [active_conn, drained = std::move(drained), logfn]() mutable { + auto conn = active_conn->lock(); + if (!conn) return; + for (auto& m : drained) { + conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {}); + } + }); + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + }); + router.on(5, 3, [model, logfn](const s2::Message& msg) { auto req = gem::parse_s5f3(msg); auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0) diff --git a/data/equipment.yaml b/data/equipment.yaml index 073a6e5..44f0d20 100644 --- a/data/equipment.yaml +++ b/data/equipment.yaml @@ -39,12 +39,25 @@ recipes: # Dispatched by S2F41. `ack` is the HCACK enum value. Optional `emit_ceid` # fires a CEID after dispatch (e.g. ProcessStarted on START), and optional -# `set_alarm` activates an alarm (e.g. FAULT -> alarm 1). +# `set_alarm` activates an alarm (e.g. FAULT -> alarm 1). `force_spool` +# (true/false) flips the spool store's test-force flag — for the demo this +# stands in for "the host link has gone down" without actually dropping +# TCP. host_commands: - - {name: START, ack: Accept, emit_ceid: 300} - - {name: STOP, ack: Accept} - - {name: PAUSE, ack: CannotDoNow} - - {name: FAULT, ack: Accept, set_alarm: 1} + - {name: START, ack: Accept, emit_ceid: 300} + - {name: STOP, ack: Accept} + - {name: PAUSE, ack: CannotDoNow} + - {name: FAULT, ack: Accept, set_alarm: 1} + - {name: SPOOL_ON, ack: Accept, force_spool: true} + - {name: SPOOL_OFF, ack: Accept, force_spool: false} + +# Spool (E30 §6.22). Streams listed here may be queued by the equipment +# when the host is unreachable; queued messages drain when the host sends +# S6F23 with RSDC=0. max_size caps the in-memory queue (FIFO eviction on +# overflow). +spool: + max_size: 100 + spoolable_streams: [5, 6] # CEID emitted automatically whenever the control state machine transitions # (i.e. on every change-handler call). Set to null to disable. diff --git a/data/messages.yaml b/data/messages.yaml index 71bfb82..af3e968 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -354,6 +354,38 @@ messages: - {name: name, shape: {kind: scalar, item_type: ASCII}} - {name: code, shape: {kind: scalar, item_type: BINARY_BYTE}} + # S2F43 / S2F44 — Reset Spooling (E30 §6.22, configure spoolable streams). + - id: S2F43 + stream: 2 + function: 43 + w: true + builder: s2f43_reset_spooling + parser: parse_s2f43 + body: + kind: list_of + element: {kind: scalar, item_type: BINARY_BYTE} + name: streams + + - id: S2F44 + stream: 2 + function: 44 + builder: s2f44_reset_spooling_ack + parser: parse_s2f44 + body: + kind: list + struct_name: ResetSpoolingReply + fields: + - {name: rspack, shape: {kind: scalar, item_type: BINARY_BYTE, enum: ResetSpoolAck}} + - name: per_stream + shape: + kind: list_of + element: + kind: list + struct_name: ResetSpoolStreamAck + fields: + - {name: stream, shape: {kind: scalar, item_type: BINARY_BYTE}} + - {name: strack, shape: {kind: scalar, item_type: BINARY_BYTE}} + # ===================================================================== # S5 — Alarms # ===================================================================== @@ -444,6 +476,21 @@ messages: builder: s6f12_event_report_ack body: {kind: scalar, item_type: BINARY_BYTE, enum: EventReportAck, param: ack} + # S6F23 / S6F24 — Request Spooled Data (E30 §6.22; host transmits or purges). + - id: S6F23 + stream: 6 + function: 23 + w: true + builder: s6f23_request_spool_data + parser: parse_s6f23 + body: {kind: scalar, item_type: BINARY_BYTE, enum: SpoolRequestCode, param: rsdc} + + - id: S6F24 + stream: 6 + function: 24 + builder: s6f24_request_spool_data_ack + body: {kind: scalar, item_type: BINARY_BYTE, enum: SpoolRequestAck, param: rsda} + # ===================================================================== # S7 — Process programs # ===================================================================== diff --git a/include/secsgem/gem/data_model.hpp b/include/secsgem/gem/data_model.hpp index 7c741ed..c9eca64 100644 --- a/include/secsgem/gem/data_model.hpp +++ b/include/secsgem/gem/data_model.hpp @@ -6,6 +6,7 @@ #include "secsgem/gem/store/event_reports.hpp" #include "secsgem/gem/store/host_commands.hpp" #include "secsgem/gem/store/recipes.hpp" +#include "secsgem/gem/store/spool.hpp" #include "secsgem/gem/store/status_variables.hpp" namespace secsgem::gem { @@ -24,6 +25,7 @@ struct EquipmentDataModel { RecipeStore recipes; Clock clock; HostCommandRegistry commands; + SpoolStore spool; // Convenience: VID -> value lookup spanning SVIDs and DVIDs. std::optional vid_value(uint32_t vid) const { diff --git a/include/secsgem/gem/store/host_commands.hpp b/include/secsgem/gem/store/host_commands.hpp index 94073e8..e2bf10b 100644 --- a/include/secsgem/gem/store/host_commands.hpp +++ b/include/secsgem/gem/store/host_commands.hpp @@ -38,12 +38,14 @@ class HostCommandRegistry { HostCmdAck ack = HostCmdAck::Accept; std::optional emit_ceid; std::optional set_alarm; + std::optional force_spool; // toggles the SpoolStore's flag }; struct Result { HostCmdAck ack = HostCmdAck::InvalidCommand; std::optional emit_ceid; std::optional set_alarm; + std::optional force_spool; }; void register_command(std::string rcmd, Spec spec) { @@ -53,8 +55,8 @@ class HostCommandRegistry { Result dispatch(const std::string& rcmd, const std::vector& /*params*/) const { auto it = by_rcmd_.find(rcmd); - if (it == by_rcmd_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt}; - return {it->second.ack, it->second.emit_ceid, it->second.set_alarm}; + if (it == by_rcmd_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt, std::nullopt}; + return {it->second.ack, it->second.emit_ceid, it->second.set_alarm, it->second.force_spool}; } private: diff --git a/include/secsgem/gem/store/spool.hpp b/include/secsgem/gem/store/spool.hpp new file mode 100644 index 0000000..f2d9fdb --- /dev/null +++ b/include/secsgem/gem/store/spool.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/secs2/message.hpp" + +// E30 §6.22 Spooling. +// +// When the equipment can't deliver a primary message to the host (because +// the link is down, the host hasn't selected, or — for the demo — a +// test-force flag is set), it queues the message in the spool instead of +// dropping it. When the host later sends S6F23 with RSDC=0, the equipment +// transmits everything in the queue. RSDC=1 purges. +// +// The set of streams the equipment is allowed to spool is configured via +// S2F43 (Reset Spooling). This implementation supports the simple form +// (per-stream enable list, no per-message-type granularity) which covers +// the common cases (stream 5 alarms + stream 6 event reports). + +namespace secsgem::gem { + +namespace s2 = secsgem::secs2; + +// S2F44 RSPACK — ack for S2F43. +enum class ResetSpoolAck : uint8_t { + Accept = 0, + Denied_Busy = 1, + Denied_NotAllowed = 2, + Denied_NoSuchStream = 3, +}; + +// S6F24 RSDA — ack for S6F23 (Request Spooled Data). +enum class SpoolRequestAck : uint8_t { + Accept = 0, + Denied = 1, +}; + +// S6F23 RSDC — host's request code in the Spool Request. +enum class SpoolRequestCode : uint8_t { + Transmit = 0, + Purge = 1, +}; + +class SpoolStore { + public: + enum class EnqueueResult { + Queued, + Dropped_NotSpoolable, // stream not in the configured spoolable set + Dropped_Full, // queue at max_size, oldest-message overflow policy + }; + + // Spool config (set by S2F43 + loader). + void set_spoolable_streams(std::vector streams) { + spoolable_.clear(); + for (auto s : streams) spoolable_.insert(s); + } + bool is_stream_spoolable(uint8_t stream) const { + return spoolable_.count(stream) > 0; + } + std::vector spoolable_streams() const { + return {spoolable_.begin(), spoolable_.end()}; + } + + void set_max_size(std::size_t n) { max_size_ = n; } + std::size_t max_size() const { return max_size_; } + + // Equipment may force-enable spool mode (e.g. while the host is offline, + // or via a test command in the demo). Independent of the spoolable + // stream list — if `force_spool` is true *and* the stream is spoolable, + // enqueue() returns Queued. If `force_spool` is false, enqueue() still + // returns Dropped_NotSpoolable (so the caller can send live instead). + void set_force_spool(bool on) { force_spool_ = on; } + bool force_spool() const { return force_spool_; } + + EnqueueResult enqueue(s2::Message msg) { + if (!is_stream_spoolable(msg.stream)) return EnqueueResult::Dropped_NotSpoolable; + if (queue_.size() >= max_size_) { + // Overflow policy: drop the oldest (FIFO eviction). + queue_.pop_front(); + } + queue_.push_back(std::move(msg)); + return EnqueueResult::Queued; + } + + // Drain everything currently in the queue (FIFO order). + std::vector drain() { + std::vector out; + out.reserve(queue_.size()); + while (!queue_.empty()) { + out.push_back(std::move(queue_.front())); + queue_.pop_front(); + } + return out; + } + + void clear() { queue_.clear(); } + std::size_t size() const { return queue_.size(); } + bool empty() const { return queue_.empty(); } + + private: + std::set spoolable_; + std::deque queue_; + std::size_t max_size_ = 100; + bool force_spool_ = false; +}; + +} // namespace secsgem::gem diff --git a/src/config/loader.cpp b/src/config/loader.cpp index 315b6d3..4d61e8e 100644 --- a/src/config/loader.cpp +++ b/src/config/loader.cpp @@ -204,10 +204,21 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo spec.emit_ceid = static_cast(e.as()); if (auto a = c["set_alarm"]) spec.set_alarm = static_cast(a.as()); + if (auto f = c["force_spool"]) + spec.force_spool = f.as(); model.commands.register_command(c["name"].as(), spec); } } + if (auto sp = root["spool"]) { + if (auto m = sp["max_size"]) model.spool.set_max_size(static_cast(m.as())); + if (auto s = sp["spoolable_streams"]) { + std::vector streams; + for (const auto& v : s) streams.push_back(static_cast(v.as())); + model.spool.set_spoolable_streams(std::move(streams)); + } + } + return desc; } diff --git a/tests/test_data_model.cpp b/tests/test_data_model.cpp index 113b3bf..869c906 100644 --- a/tests/test_data_model.cpp +++ b/tests/test_data_model.cpp @@ -186,7 +186,7 @@ TEST_CASE("recipe CRUD") { // ---- Composite EquipmentDataModel --------------------------------------- -TEST_CASE("EquipmentDataModel composes the seven stores") { +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})}); @@ -195,3 +195,50 @@ TEST_CASE("EquipmentDataModel composes the seven stores") { 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(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>(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()); +} diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index 27934f8..f0e09d7 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -64,6 +64,20 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman CHECK(start.emit_ceid.value_or(0) == 300); auto fault = m.commands.dispatch("FAULT", {}); CHECK(fault.set_alarm.value_or(0) == 1); + + // Spool config loaded from YAML. + CHECK(m.spool.is_stream_spoolable(5)); + CHECK(m.spool.is_stream_spoolable(6)); + CHECK_FALSE(m.spool.is_stream_spoolable(1)); + CHECK(m.spool.max_size() == 100); + + // force_spool flag plumbed from YAML through CommandSpec. + auto on = m.commands.dispatch("SPOOL_ON", {}); + auto off = m.commands.dispatch("SPOOL_OFF", {}); + REQUIRE(on.force_spool.has_value()); + CHECK(*on.force_spool == true); + REQUIRE(off.force_spool.has_value()); + CHECK(*off.force_spool == false); } TEST_CASE("loader surfaces YAML errors with file path") {