C1: ExceptionStateMachine FSM + ExceptionStore

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>
This commit is contained in:
2026-06-07 23:41:51 +02:00
parent 4fec4297e2
commit a1f7da4a7d
6 changed files with 465 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
#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);
}