8a48ffeed4
The single-slot set_*_handler pattern was a structural blocker, hit twice: the daemon could not observe control-state changes because register_default_handlers owns the slot, forcing GetControlState to read the FSM cross-thread (a data race), and blocking WatchHealth and the Subscribe stream's ControlStateChange variant. HandlerSlot<Args...> keeps a primary slot with exact legacy semantics (set_ replaces — one existing test depends on replacement) plus an append-only observer list (add_) that survives set_ calls. Fire sites are textually unchanged (operator bool / operator() / assign-from-function). Applied to ControlStateMachine + ProcessJobStore + ControlJobStore (the roadmap-critical three; the remaining single-slot classes follow the same 3-line pattern as needed). EquipmentRuntime gains an atomic control-state mirror registered as an observer — control_state() is now safe from any thread, retiring the GetControlState race — plus add_control_state_observer and add_link_observer (selected/closed fan-out), the hooks WatchHealth and Subscribe need. Tests: observer ordering, set-replaces-primary-but-observers-survive, observers-without-primary, PJ-store coexistence, and the runtime scenario that was previously impossible (mirror + observer + default-handlers set_). Core 464/464 (2816 assertions), daemon 16/16, live GEM300 demo passes with single-fire control-state transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
90 lines
3.3 KiB
C++
90 lines
3.3 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <vector>
|
|
|
|
#include "secsgem/gem/runtime.hpp"
|
|
#include "secsgem/secs2/item.hpp"
|
|
|
|
using namespace secsgem;
|
|
namespace gem = secsgem::gem;
|
|
namespace s2 = secsgem::secs2;
|
|
|
|
#ifndef SECSGEM_DATA_DIR
|
|
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
|
|
#endif
|
|
|
|
// Port 0 binds an OS-chosen ephemeral port. These tests never call run()/
|
|
// run_async(), so the acceptor is opened but never armed — we exercise the
|
|
// outbound API by posting and draining with poll(), no host involved.
|
|
static gem::EquipmentRuntime::Config test_config() {
|
|
gem::EquipmentRuntime::Config c;
|
|
c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml";
|
|
c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml";
|
|
c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml";
|
|
c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml";
|
|
c.port = 0;
|
|
return c;
|
|
}
|
|
|
|
TEST_CASE("EquipmentRuntime loads the data dictionary from config") {
|
|
gem::EquipmentRuntime rt(test_config());
|
|
CHECK(rt.model().svids.all().size() == 3);
|
|
CHECK(rt.model().alarms.all().size() == 2);
|
|
CHECK(rt.control_state() == gem::ControlState::HostOffline);
|
|
}
|
|
|
|
TEST_CASE("EquipmentRuntime.set_variable posts onto the io thread and updates the model") {
|
|
gem::EquipmentRuntime rt(test_config());
|
|
rt.set_variable(1, s2::Item::ascii("OnlineRemote"));
|
|
CHECK(rt.model().svids.value(1) != s2::Item::ascii("OnlineRemote")); // not yet — posted
|
|
rt.poll();
|
|
CHECK(rt.model().svids.value(1) == s2::Item::ascii("OnlineRemote"));
|
|
}
|
|
|
|
TEST_CASE("EquipmentRuntime.set_alarm / clear_alarm toggle the active flag") {
|
|
gem::EquipmentRuntime rt(test_config());
|
|
rt.set_alarm(1);
|
|
rt.poll();
|
|
CHECK(rt.model().alarms.active(1));
|
|
rt.clear_alarm(1);
|
|
rt.poll();
|
|
CHECK_FALSE(rt.model().alarms.active(1));
|
|
}
|
|
|
|
TEST_CASE("EquipmentRuntime.on_command registers the behaviour hook on the model") {
|
|
gem::EquipmentRuntime rt(test_config());
|
|
bool ran = false;
|
|
rt.on_command("START", [&](const std::string& rcmd,
|
|
const std::vector<gem::CommandParameter>&) {
|
|
ran = (rcmd == "START");
|
|
return gem::HostCmdAck::Accept;
|
|
});
|
|
CHECK(rt.model().commands.has_handler("START"));
|
|
auto res = rt.model().commands.dispatch("START", {});
|
|
CHECK(ran);
|
|
CHECK(res.ack == gem::HostCmdAck::Accept);
|
|
}
|
|
|
|
TEST_CASE("EquipmentRuntime control-state mirror tracks transitions; observers coexist with default handlers") {
|
|
gem::EquipmentRuntime rt(test_config());
|
|
|
|
int observed = 0;
|
|
rt.add_control_state_observer(
|
|
[&](gem::ControlState, gem::ControlState, gem::ControlEvent) { ++observed; });
|
|
|
|
// Simulate what register_default_handlers does: claim the PRIMARY slot
|
|
// after the runtime (mirror) and the observer are already registered.
|
|
rt.control().set_state_change_handler(
|
|
[](gem::ControlState, gem::ControlState, gem::ControlEvent) {});
|
|
|
|
CHECK(rt.control_state() == gem::ControlState::HostOffline); // mirror initial
|
|
|
|
rt.control().on_host_request_online(); // HostOffline -> ... -> OnlineRemote
|
|
|
|
// The atomic mirror followed the FSM, and the added observer survived the
|
|
// primary set_ call — the exact scenario that used to be impossible.
|
|
CHECK(rt.control_state() == gem::ControlState::OnlineRemote);
|
|
CHECK(rt.control_state() == rt.control().state());
|
|
CHECK(observed == 2); // two chained transitions
|
|
}
|