Files
secs-gem/tests/test_e84_asio_timers.cpp
T
raphael 54dcf6c532 e84: asio adapter for handshake timers + wall-clock test
The E84StateMachine timers landed last commit but stayed theoretical —
arming was delivered via abstract callbacks the application had to
glue to a real clock.  This commit ships the canonical glue:

- include/secsgem/gem/e84_asio_timers.hpp: header-only
  E84AsioTimers wraps three asio::steady_timers, wires set_timer_handlers
  on attach(), routes async_wait expiry back into fsm.on_timeout().
  detach() cancels everything cleanly.

- tests/test_e84_asio_timers.cpp: four scenarios exercised through a
  real asio::io_context with wall-clock timers — TA1 expiry,
  signal-driven cancel before TA1 fires, TA3 expiry from the
  Transferring state, and detach() halting further transitions.
  These cover the integration the synthetic unit tests in
  test_e84_timers.cpp can't reach.

- INTEGRATION.md §4.6: the vendor-side recipe — create the port,
  set timeouts, make_shared<E84AsioTimers>(...)::attach(), feed signals
  from your I/O bridge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:08:16 +02:00

139 lines
4.2 KiB
C++

// Wire-level test for the E84 asio adapter. Drives the FSM through a
// real asio::io_context with wall-clock timers and asserts that
// HandoffFault fires on actual elapsed time — the integration the unit
// tests in test_e84_timers.cpp can't reach because they call
// fsm.on_timeout() synthetically.
#include <doctest/doctest.h>
#include <asio.hpp>
#include <chrono>
#include <memory>
#include "secsgem/gem/e84_asio_timers.hpp"
#include "secsgem/gem/e84_state.hpp"
using namespace secsgem::gem;
using namespace std::chrono_literals;
namespace {
// Run io until either the predicate is true or `cap` elapses. Stops
// the harness as soon as the FSM reaches the expected state so the test
// doesn't sit on the deadline.
template <typename Pred>
void run_until(asio::io_context& io, Pred p, std::chrono::milliseconds cap) {
asio::steady_timer cap_timer(io);
cap_timer.expires_after(cap);
cap_timer.async_wait([&io](std::error_code) { io.stop(); });
asio::steady_timer poll(io);
std::function<void(std::error_code)> tick =
[&](std::error_code ec) {
if (ec) return;
if (p()) { io.stop(); return; }
poll.expires_after(5ms);
poll.async_wait(tick);
};
poll.expires_after(5ms);
poll.async_wait(tick);
io.run();
}
} // namespace
TEST_CASE("E84 asio: TA1 expires on real wall clock and faults the FSM") {
asio::io_context io;
E84StateMachine fsm;
fsm.set_timeouts({50ms, 0ms, 0ms});
auto driver = std::make_shared<E84AsioTimers>(io.get_executor(), fsm);
driver->attach();
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
REQUIRE(fsm.state() == E84State::ValidAsserted);
REQUIRE(fsm.timer_armed(E84TimerId::TA1));
run_until(io, [&] { return fsm.state() == E84State::HandoffFault; }, 1s);
CHECK(fsm.state() == E84State::HandoffFault);
CHECK(fsm.fault() == E84Fault::TA1Expired);
}
TEST_CASE("E84 asio: signal-driven cancel before TA1 expires") {
asio::io_context io;
E84StateMachine fsm;
fsm.set_timeouts({200ms, 0ms, 0ms});
auto driver = std::make_shared<E84AsioTimers>(io.get_executor(), fsm);
driver->attach();
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
// Schedule the L_REQ assertion well before TA1 fires. After it lands,
// the FSM is in LoadReady (TA2 takes over but timeouts.ta2 is 0, so
// nothing is armed) and TA1 must NOT have promoted to HandoffFault.
asio::steady_timer ack(io);
ack.expires_after(20ms);
ack.async_wait([&](std::error_code) {
fsm.on_signal_change(E84Signal::L_REQ, true);
});
run_until(io,
[&] { return fsm.state() == E84State::HandoffFault; },
400ms);
CHECK(fsm.state() == E84State::LoadReady);
CHECK(fsm.fault() == E84Fault::None);
}
TEST_CASE("E84 asio: TA3 fires after Transferring exceeds its budget") {
asio::io_context io;
E84StateMachine fsm;
fsm.set_timeouts({0ms, 0ms, 60ms});
auto driver = std::make_shared<E84AsioTimers>(io.get_executor(), fsm);
driver->attach();
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);
REQUIRE(fsm.timer_armed(E84TimerId::TA3));
run_until(io,
[&] { return fsm.state() == E84State::HandoffFault; },
1s);
CHECK(fsm.state() == E84State::HandoffFault);
CHECK(fsm.fault() == E84Fault::TA3Expired);
}
TEST_CASE("E84 asio: detach() halts further timer-driven transitions") {
asio::io_context io;
E84StateMachine fsm;
fsm.set_timeouts({40ms, 0ms, 0ms});
auto driver = std::make_shared<E84AsioTimers>(io.get_executor(), fsm);
driver->attach();
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
driver->detach();
// detach cancels the timer; pending asio handler sees aborted and
// returns without calling on_timeout. Let the io drain for double
// the timeout to confirm.
asio::steady_timer wait(io);
wait.expires_after(120ms);
wait.async_wait([&](std::error_code) { io.stop(); });
io.run();
CHECK(fsm.state() == E84State::ValidAsserted);
CHECK(fsm.fault() == E84Fault::None);
}