a1f7da4a7d
Per-EXID exception lifecycle for E5 §9. States mirror the wire flow: Posted equipment sent S5F9, awaiting host or autonomous clear Recovering host's S5F13 accepted; equipment running recovery RecoverFailed S5F15 reported a failed result; host may retry Cleared terminal — store removes the row Events: Created synthetic NoState->Posted observer signal Recover host's S5F13 (Posted/RecoverFailed -> Recovering) RecoveryComplete equipment internal (Recovering -> Cleared) RecoveryFailed equipment internal (Recovering -> RecoverFailed) RecoveryAbort host's S5F17 (Recovering -> Posted) Clear equipment internal (Posted/RecoverFailed -> Cleared) ExceptionStore mirrors ProcessJobStore: per-EXID FSMs heap-allocated via unique_ptr, non-movable to keep `this`-captures safe, synthetic Created fires after the row lands so observers can decide whether to emit S5F9 out of band. on_recover validates EXRECVRA against the candidates the post advertised. The store joins EquipmentDataModel alongside process_jobs / control_jobs. S5F9-F18 server-side dispatch lands in C2. Tests (12 cases) cover FSM transitions including retry, abort, and autonomous clear, plus store-level duplicate-rejection, EXRECVRA validation, and Cleared-removes-the-row semantics. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
105 lines
3.9 KiB
C++
105 lines
3.9 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <vector>
|
|
|
|
#include "secsgem/gem/exception_state.hpp"
|
|
#include "secsgem/gem/store/exceptions.hpp"
|
|
|
|
using namespace secsgem::gem;
|
|
|
|
TEST_CASE("ExceptionStateMachine: default starts Posted") {
|
|
ExceptionStateMachine fsm;
|
|
CHECK(fsm.state() == ExceptionState::Posted);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStateMachine: Posted -> Recovering via host Recover") {
|
|
ExceptionStateMachine fsm;
|
|
CHECK(fsm.on_host_command(ExceptionEvent::Recover) == AlarmAck::Accept);
|
|
CHECK(fsm.state() == ExceptionState::Recovering);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStateMachine: Recovering -> Cleared on RecoveryComplete") {
|
|
ExceptionStateMachine fsm;
|
|
fsm.on_host_command(ExceptionEvent::Recover);
|
|
REQUIRE(fsm.state() == ExceptionState::Recovering);
|
|
CHECK(fsm.on_internal(ExceptionEvent::RecoveryComplete));
|
|
CHECK(fsm.state() == ExceptionState::Cleared);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStateMachine: Recovering -> RecoverFailed -> Recovering retry") {
|
|
ExceptionStateMachine fsm;
|
|
fsm.on_host_command(ExceptionEvent::Recover);
|
|
CHECK(fsm.on_internal(ExceptionEvent::RecoveryFailed));
|
|
CHECK(fsm.state() == ExceptionState::RecoverFailed);
|
|
CHECK(fsm.on_host_command(ExceptionEvent::Recover) == AlarmAck::Accept);
|
|
CHECK(fsm.state() == ExceptionState::Recovering);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStateMachine: RecoveryAbort returns to Posted") {
|
|
ExceptionStateMachine fsm;
|
|
fsm.on_host_command(ExceptionEvent::Recover);
|
|
REQUIRE(fsm.state() == ExceptionState::Recovering);
|
|
CHECK(fsm.on_host_command(ExceptionEvent::RecoveryAbort) == AlarmAck::Accept);
|
|
CHECK(fsm.state() == ExceptionState::Posted);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStateMachine: autonomous Clear from Posted -> Cleared") {
|
|
ExceptionStateMachine fsm;
|
|
CHECK(fsm.on_internal(ExceptionEvent::Clear));
|
|
CHECK(fsm.state() == ExceptionState::Cleared);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStateMachine: illegal host event from Cleared returns Error") {
|
|
ExceptionStateMachine fsm(ExceptionTransitionTable::default_table(),
|
|
ExceptionState::Cleared);
|
|
CHECK(fsm.on_host_command(ExceptionEvent::Recover) == AlarmAck::Error);
|
|
}
|
|
|
|
// ---- ExceptionStore -----------------------------------------------------
|
|
|
|
TEST_CASE("ExceptionStore: post rejects duplicate EXID") {
|
|
ExceptionStore store;
|
|
CHECK(store.post(42, "FATAL", "vacuum lost", {"RETRY"}) ==
|
|
ExceptionStore::PostResult::Posted);
|
|
CHECK(store.post(42, "FATAL", "vacuum lost", {"RETRY"}) ==
|
|
ExceptionStore::PostResult::Denied_AlreadyExists);
|
|
CHECK(store.size() == 1);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStore: on_recover validates EXRECVRA against post list") {
|
|
ExceptionStore store;
|
|
store.post(1, "WARN", "fan stalled", {"RETRY", "RESTART"});
|
|
CHECK(store.on_recover(1, "RETRY") == AlarmAck::Accept);
|
|
// Same EXID, different action — must come from the original list.
|
|
store.post(2, "WARN", "x", {"ONLY"});
|
|
CHECK(store.on_recover(2, "BOGUS") == AlarmAck::Error);
|
|
CHECK(store.on_recover(99, "RETRY") == AlarmAck::Error); // unknown EXID
|
|
}
|
|
|
|
TEST_CASE("ExceptionStore: Cleared removes the entry") {
|
|
ExceptionStore store;
|
|
store.post(7, "INFO", "x", {"OK"});
|
|
REQUIRE(store.has(7));
|
|
store.on_recover(7, "OK");
|
|
store.fire_internal(7, ExceptionEvent::RecoveryComplete);
|
|
CHECK_FALSE(store.has(7));
|
|
CHECK(store.size() == 0);
|
|
}
|
|
|
|
TEST_CASE("ExceptionStore: state-change handler observes Created + transitions") {
|
|
ExceptionStore store;
|
|
std::vector<std::tuple<uint32_t, ExceptionState, ExceptionState>> log;
|
|
store.set_state_change_handler(
|
|
[&](uint32_t exid, ExceptionState f, ExceptionState t, ExceptionEvent) {
|
|
log.emplace_back(exid, f, t);
|
|
});
|
|
store.post(42, "FATAL", "x", {"R"});
|
|
store.on_recover(42, "R");
|
|
store.fire_internal(42, ExceptionEvent::RecoveryComplete);
|
|
REQUIRE(log.size() == 3);
|
|
CHECK(std::get<1>(log[0]) == ExceptionState::NoState);
|
|
CHECK(std::get<2>(log[0]) == ExceptionState::Posted);
|
|
CHECK(std::get<2>(log[1]) == ExceptionState::Recovering);
|
|
CHECK(std::get<2>(log[2]) == ExceptionState::Cleared);
|
|
}
|