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>
207 lines
7.0 KiB
C++
207 lines
7.0 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <vector>
|
|
|
|
#include "secsgem/gem/control_state.hpp"
|
|
|
|
using namespace secsgem::gem;
|
|
|
|
namespace {
|
|
|
|
struct Recorder {
|
|
std::vector<std::tuple<ControlState, ControlState, ControlEvent>> changes;
|
|
ControlStateMachine::StateChangeHandler handler() {
|
|
return [this](ControlState from, ControlState to, ControlEvent ev) {
|
|
changes.emplace_back(from, to, ev);
|
|
};
|
|
}
|
|
};
|
|
|
|
ControlStateMachine make_sm(ControlState initial) {
|
|
return ControlStateMachine(ControlTransitionTable::default_table(), initial);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("default initial state is HostOffline") {
|
|
ControlStateMachine sm;
|
|
CHECK(sm.state() == ControlState::HostOffline);
|
|
CHECK_FALSE(sm.online());
|
|
}
|
|
|
|
TEST_CASE("custom initial state") {
|
|
auto sm = make_sm(ControlState::EquipmentOffline);
|
|
CHECK(sm.state() == ControlState::EquipmentOffline);
|
|
}
|
|
|
|
TEST_CASE("host request online from HostOffline -> OnlineRemote") {
|
|
Recorder rec;
|
|
ControlStateMachine sm;
|
|
sm.set_state_change_handler(rec.handler());
|
|
|
|
CHECK(sm.on_host_request_online() == OnlineAck::Accept);
|
|
CHECK(sm.state() == ControlState::OnlineRemote);
|
|
CHECK(sm.online());
|
|
|
|
REQUIRE(rec.changes.size() == 2);
|
|
CHECK(std::get<1>(rec.changes[0]) == ControlState::AttemptOnline);
|
|
CHECK(std::get<2>(rec.changes[0]) == ControlEvent::HostRequestOnline);
|
|
CHECK(std::get<1>(rec.changes[1]) == ControlState::OnlineRemote);
|
|
CHECK(std::get<2>(rec.changes[1]) == ControlEvent::AttemptComplete);
|
|
}
|
|
|
|
TEST_CASE("host request online when already online -> AlreadyOnline, no transition") {
|
|
ControlStateMachine sm;
|
|
sm.on_host_request_online();
|
|
REQUIRE(sm.state() == ControlState::OnlineRemote);
|
|
|
|
Recorder rec;
|
|
sm.set_state_change_handler(rec.handler());
|
|
CHECK(sm.on_host_request_online() == OnlineAck::AlreadyOnline);
|
|
CHECK(sm.state() == ControlState::OnlineRemote);
|
|
CHECK(rec.changes.empty());
|
|
}
|
|
|
|
TEST_CASE("host request online from EquipmentOffline -> NotAccept") {
|
|
auto sm = make_sm(ControlState::EquipmentOffline);
|
|
CHECK(sm.on_host_request_online() == OnlineAck::NotAccept);
|
|
CHECK(sm.state() == ControlState::EquipmentOffline);
|
|
}
|
|
|
|
TEST_CASE("host request offline from OnlineRemote -> HostOffline") {
|
|
ControlStateMachine sm;
|
|
sm.on_host_request_online();
|
|
REQUIRE(sm.state() == ControlState::OnlineRemote);
|
|
|
|
Recorder rec;
|
|
sm.set_state_change_handler(rec.handler());
|
|
CHECK(sm.on_host_request_offline() == OfflineAck::Accept);
|
|
CHECK(sm.state() == ControlState::HostOffline);
|
|
|
|
REQUIRE(rec.changes.size() == 1);
|
|
CHECK(std::get<1>(rec.changes[0]) == ControlState::HostOffline);
|
|
CHECK(std::get<2>(rec.changes[0]) == ControlEvent::HostRequestOffline);
|
|
}
|
|
|
|
TEST_CASE("host request offline when already offline is idempotent Accept") {
|
|
ControlStateMachine sm;
|
|
Recorder rec;
|
|
sm.set_state_change_handler(rec.handler());
|
|
CHECK(sm.on_host_request_offline() == OfflineAck::Accept);
|
|
CHECK(sm.state() == ControlState::HostOffline);
|
|
CHECK(rec.changes.empty());
|
|
}
|
|
|
|
TEST_CASE("operator online from EquipmentOffline -> OnlineLocal (default table)") {
|
|
auto sm = make_sm(ControlState::EquipmentOffline);
|
|
CHECK(sm.operator_online());
|
|
CHECK(sm.state() == ControlState::OnlineLocal);
|
|
}
|
|
|
|
TEST_CASE("operator online when already online is rejected") {
|
|
auto sm = make_sm(ControlState::OnlineLocal);
|
|
CHECK_FALSE(sm.operator_online());
|
|
CHECK(sm.state() == ControlState::OnlineLocal);
|
|
}
|
|
|
|
TEST_CASE("operator offline from any online state -> HostOffline") {
|
|
auto sm = make_sm(ControlState::OnlineRemote);
|
|
CHECK(sm.operator_offline());
|
|
CHECK(sm.state() == ControlState::HostOffline);
|
|
}
|
|
|
|
TEST_CASE("operator local toggles only from OnlineRemote") {
|
|
auto sm = make_sm(ControlState::OnlineRemote);
|
|
CHECK(sm.operator_local());
|
|
CHECK(sm.state() == ControlState::OnlineLocal);
|
|
CHECK_FALSE(sm.operator_local());
|
|
}
|
|
|
|
TEST_CASE("operator remote toggles only from OnlineLocal") {
|
|
auto sm = make_sm(ControlState::OnlineLocal);
|
|
CHECK(sm.operator_remote());
|
|
CHECK(sm.state() == ControlState::OnlineRemote);
|
|
CHECK_FALSE(sm.operator_remote());
|
|
}
|
|
|
|
TEST_CASE("is_online classifier") {
|
|
CHECK_FALSE(is_online(ControlState::EquipmentOffline));
|
|
CHECK_FALSE(is_online(ControlState::AttemptOnline));
|
|
CHECK_FALSE(is_online(ControlState::HostOffline));
|
|
CHECK(is_online(ControlState::OnlineLocal));
|
|
CHECK(is_online(ControlState::OnlineRemote));
|
|
}
|
|
|
|
TEST_CASE("default table covers all expected (state, event) pairs") {
|
|
auto t = ControlTransitionTable::default_table();
|
|
// Every state must have an entry for each host event.
|
|
for (auto s : {ControlState::EquipmentOffline, ControlState::AttemptOnline,
|
|
ControlState::HostOffline, ControlState::OnlineLocal,
|
|
ControlState::OnlineRemote}) {
|
|
CHECK(t.find(s, ControlEvent::HostRequestOnline) != nullptr);
|
|
CHECK(t.find(s, ControlEvent::HostRequestOffline) != nullptr);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("custom table: a row that only sets ack, no transition") {
|
|
ControlTransitionTable t;
|
|
t.add({ControlState::HostOffline, ControlEvent::HostRequestOnline, std::nullopt,
|
|
std::nullopt, static_cast<uint8_t>(OnlineAck::NotAccept)});
|
|
ControlStateMachine sm(t, ControlState::HostOffline);
|
|
CHECK(sm.on_host_request_online() == OnlineAck::NotAccept);
|
|
CHECK(sm.state() == ControlState::HostOffline);
|
|
}
|
|
|
|
// ---- multi-observer (HandlerSlot) ------------------------------------------
|
|
|
|
TEST_CASE("add_state_change_handler observers fire alongside the primary, in order") {
|
|
ControlStateMachine sm;
|
|
std::vector<int> order;
|
|
Recorder primary, obs1, obs2;
|
|
|
|
sm.set_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
|
|
order.push_back(0);
|
|
primary.handler()(f, t, e);
|
|
});
|
|
sm.add_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
|
|
order.push_back(1);
|
|
obs1.handler()(f, t, e);
|
|
});
|
|
sm.add_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
|
|
order.push_back(2);
|
|
obs2.handler()(f, t, e);
|
|
});
|
|
|
|
CHECK(sm.on_host_request_online() == OnlineAck::Accept); // 2 transitions
|
|
|
|
// All three saw both transitions, primary before observers each time.
|
|
CHECK(primary.changes.size() == 2);
|
|
CHECK(obs1.changes.size() == 2);
|
|
CHECK(obs2.changes.size() == 2);
|
|
REQUIRE(order.size() == 6);
|
|
CHECK(order == std::vector<int>{0, 1, 2, 0, 1, 2});
|
|
}
|
|
|
|
TEST_CASE("set_state_change_handler replaces the primary but observers survive") {
|
|
ControlStateMachine sm;
|
|
Recorder old_primary, new_primary, observer;
|
|
|
|
sm.set_state_change_handler(old_primary.handler());
|
|
sm.add_state_change_handler(observer.handler());
|
|
sm.set_state_change_handler(new_primary.handler()); // replaces old_primary ONLY
|
|
|
|
sm.on_host_request_online();
|
|
|
|
CHECK(old_primary.changes.empty()); // replaced
|
|
CHECK(new_primary.changes.size() == 2); // active primary
|
|
CHECK(observer.changes.size() == 2); // survived the re-set
|
|
}
|
|
|
|
TEST_CASE("observers work with no primary handler installed") {
|
|
ControlStateMachine sm;
|
|
Recorder observer;
|
|
sm.add_state_change_handler(observer.handler());
|
|
sm.on_host_request_online();
|
|
CHECK(observer.changes.size() == 2);
|
|
}
|