2ea3ab796a
E84StateMachine had the full signal-level handshake but no timer
enforcement. In a real AMHS that's a deadlock: if equipment is slow to
assert L_REQ / U_REQ, or AMHS is slow to assert BUSY / COMPT, neither
side notices — the wires just sit stuck. SEMI E84 §6 mandates three
timers that bound each leg of the dance.
TA1 — armed in ValidAsserted, cancelled in Load/UnloadReady.
AMHS bounds how long equipment takes to acknowledge VALID.
TA2 — armed in Load/UnloadReady, cancelled in Transferring.
Equipment bounds how long AMHS takes to start the transfer.
TA3 — armed in Transferring, cancelled on Complete.
Equipment bounds the BUSY-phase duration.
The FSM stays I/O-free (it's the design invariant): arm/cancel are
delivered via callbacks, the application owns the asio::steady_timer,
and the application calls `fsm.on_timeout(id)` when its real clock
fires. Stale on_timeout calls (post-cancel race) are no-ops.
On expiry, the FSM transitions to a new `HandoffFault` state, records
the `E84Fault` reason, fires the optional fault_handler, and latches
the fault until `reset()`. Signal jitter on the wires cannot silently
clear a recorded handshake timeout — once you've crossed the timer,
you stop.
Defaults are all-zero, which disables arming. This is what every
existing test relies on, and what back-to-back simulation (no
wall-clock) needs. Production tools call `set_timeouts({2s, 2s, 60s})`
or whatever their port spec dictates.
12 new test cases / 59 assertions: arming per state, cancelling per
exit, expiry-to-fault for all three timers, ES cancels everything,
stale-expiry no-op, fault latching across signal jitter, and a
full-cycle arm/cancel trace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
269 lines
9.0 KiB
C++
269 lines
9.0 KiB
C++
// 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 <doctest/doctest.h>
|
|
|
|
#include <chrono>
|
|
#include <vector>
|
|
|
|
#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<ArmEvent> arms;
|
|
std::vector<E84TimerId> 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);
|
|
}
|