// E84 §6 handshake timer tests. The FSM is I/O-free — it requests // arm / cancel through callbacks and the application drives the clock. // These tests use a fake "clock" that just records arm/cancel events // so we can assert (a) that the FSM arms the right timer in each state // (b) that the FSM cancels the timer when leaving that state, and // (c) that expiry transitions to HandoffFault with the correct reason. #include #include #include #include "secsgem/gem/e84_state.hpp" using namespace secsgem::gem; using namespace std::chrono_literals; namespace { // Captures the arm/cancel call stream so tests can assert ordering. struct TimerLog { struct ArmEvent { E84TimerId id; std::chrono::milliseconds duration; }; std::vector arms; std::vector cancels; }; void wire(E84StateMachine& fsm, TimerLog& log) { fsm.set_timer_handlers( [&log](E84TimerId id, std::chrono::milliseconds d) { log.arms.push_back({id, d}); }, [&log](E84TimerId id) { log.cancels.push_back(id); }); } } // namespace TEST_CASE("E84 timers: zero timeout disables arming") { E84StateMachine fsm; TimerLog log; wire(fsm, log); // Default E84Timeouts is all-zero; the legacy back-to-back tests // depend on this behaviour to stay unchanged. fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); CHECK(fsm.state() == E84State::ValidAsserted); CHECK(log.arms.empty()); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1)); } TEST_CASE("E84 timers: TA1 armed on ValidAsserted, cancelled on LoadReady") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({2000ms, 2000ms, 60000ms}); fsm.on_signal_change(E84Signal::CS_0, true); CHECK(log.arms.empty()); // CarrierPresent doesn't arm anything fsm.on_signal_change(E84Signal::VALID, true); REQUIRE(fsm.state() == E84State::ValidAsserted); REQUIRE(log.arms.size() == 1); CHECK(log.arms[0].id == E84TimerId::TA1); CHECK(log.arms[0].duration == 2000ms); CHECK(fsm.timer_armed(E84TimerId::TA1)); fsm.on_signal_change(E84Signal::L_REQ, true); REQUIRE(fsm.state() == E84State::LoadReady); // TA1 cancelled on transition, TA2 armed in its place. CHECK(std::count(log.cancels.begin(), log.cancels.end(), E84TimerId::TA1) == 1); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1)); CHECK(fsm.timer_armed(E84TimerId::TA2)); } TEST_CASE("E84 timers: TA1 expiry transitions to HandoffFault") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({500ms, 0ms, 0ms}); E84Fault observed_fault = E84Fault::None; fsm.set_fault_handler([&](E84Fault f) { observed_fault = f; }); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); REQUIRE(fsm.timer_armed(E84TimerId::TA1)); fsm.on_timeout(E84TimerId::TA1); CHECK(fsm.state() == E84State::HandoffFault); CHECK(fsm.fault() == E84Fault::TA1Expired); CHECK(observed_fault == E84Fault::TA1Expired); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1)); } TEST_CASE("E84 timers: TA2 armed on LoadReady, cancelled on Transferring") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({2000ms, 2000ms, 60000ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::L_REQ, true); REQUIRE(fsm.state() == E84State::LoadReady); CHECK(fsm.timer_armed(E84TimerId::TA2)); fsm.on_signal_change(E84Signal::BUSY, true); REQUIRE(fsm.state() == E84State::Transferring); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA2)); CHECK(fsm.timer_armed(E84TimerId::TA3)); } TEST_CASE("E84 timers: TA2 also armed on UnloadReady") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({0ms, 1500ms, 0ms}); fsm.on_signal_change(E84Signal::CS_1, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::U_REQ, true); REQUIRE(fsm.state() == E84State::UnloadReady); REQUIRE(log.arms.size() == 1); CHECK(log.arms.back().id == E84TimerId::TA2); CHECK(log.arms.back().duration == 1500ms); } TEST_CASE("E84 timers: TA2 expiry transitions to HandoffFault") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({0ms, 500ms, 0ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::L_REQ, true); REQUIRE(fsm.timer_armed(E84TimerId::TA2)); fsm.on_timeout(E84TimerId::TA2); CHECK(fsm.state() == E84State::HandoffFault); CHECK(fsm.fault() == E84Fault::TA2Expired); } TEST_CASE("E84 timers: TA3 armed on Transferring, cancelled on Complete") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({2000ms, 2000ms, 60000ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::L_REQ, true); fsm.on_signal_change(E84Signal::BUSY, true); REQUIRE(fsm.state() == E84State::Transferring); CHECK(fsm.timer_armed(E84TimerId::TA3)); // BUSY drops then COMPT rises — the existing FSM contract. TA3 was // armed while BUSY held; transient drop returns to LoadReady (where // TA2 re-arms), then COMPT pushes Complete where no timer is armed. fsm.on_signal_change(E84Signal::BUSY, false); fsm.on_signal_change(E84Signal::COMPT, true); REQUIRE(fsm.state() == E84State::Complete); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1)); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA2)); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA3)); } TEST_CASE("E84 timers: TA3 expiry transitions to HandoffFault") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({0ms, 0ms, 30000ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::L_REQ, true); fsm.on_signal_change(E84Signal::BUSY, true); REQUIRE(fsm.timer_armed(E84TimerId::TA3)); fsm.on_timeout(E84TimerId::TA3); CHECK(fsm.state() == E84State::HandoffFault); CHECK(fsm.fault() == E84Fault::TA3Expired); } TEST_CASE("E84 timers: ES cancels every armed timer") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({2000ms, 2000ms, 60000ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::L_REQ, true); REQUIRE(fsm.timer_armed(E84TimerId::TA2)); fsm.on_signal_change(E84Signal::ES, true); CHECK(fsm.state() == E84State::EmergencyStop); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1)); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA2)); CHECK_FALSE(fsm.timer_armed(E84TimerId::TA3)); } TEST_CASE("E84 timers: stale on_timeout after cancel is a no-op") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({2000ms, 0ms, 0ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); REQUIRE(fsm.timer_armed(E84TimerId::TA1)); // L_REQ arrives, FSM cancels TA1. The app's already-fired asio // timer might still call on_timeout(TA1) before its cancellation // landed; that race must not promote the FSM into HandoffFault. fsm.on_signal_change(E84Signal::L_REQ, true); REQUIRE(fsm.state() == E84State::LoadReady); fsm.on_timeout(E84TimerId::TA1); CHECK(fsm.state() == E84State::LoadReady); CHECK(fsm.fault() == E84Fault::None); } TEST_CASE("E84 timers: HandoffFault is latched until reset()") { E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({500ms, 0ms, 0ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_timeout(E84TimerId::TA1); REQUIRE(fsm.state() == E84State::HandoffFault); // Signal jitter shouldn't silently clear the fault. fsm.on_signal_change(E84Signal::VALID, false); fsm.on_signal_change(E84Signal::CS_0, false); CHECK(fsm.state() == E84State::HandoffFault); CHECK(fsm.fault() == E84Fault::TA1Expired); fsm.reset(); CHECK(fsm.state() == E84State::Idle); CHECK(fsm.fault() == E84Fault::None); } TEST_CASE("E84 timers: per-port store wiring relays timers") { // The per-port store delegates to underlying E84StateMachine objects; // a quick smoke-test that the application can wire timers per port // without the store getting in the way. E84StateMachine fsm; TimerLog log; wire(fsm, log); fsm.set_timeouts({100ms, 100ms, 100ms}); fsm.on_signal_change(E84Signal::CS_0, true); fsm.on_signal_change(E84Signal::VALID, true); fsm.on_signal_change(E84Signal::L_REQ, true); fsm.on_signal_change(E84Signal::BUSY, true); fsm.on_signal_change(E84Signal::BUSY, false); fsm.on_signal_change(E84Signal::COMPT, true); // Across the full happy path: TA1 → TA2 → TA3 → TA2 (transient // BUSY drop) → no timer (Complete). Five arms, five cancels. CHECK(log.arms.size() == 4); CHECK(log.arms[0].id == E84TimerId::TA1); CHECK(log.arms[1].id == E84TimerId::TA2); CHECK(log.arms[2].id == E84TimerId::TA3); CHECK(log.arms[3].id == E84TimerId::TA2); CHECK(log.cancels.size() == 4); }