"""State-machine tests: D1/D2/D3 transitions + exponential backoff (spec ยง10.2).""" from datetime import datetime, timedelta, timezone from opcda_diagnozer.monitor.state import ( BACKOFF_INITIAL_S, BACKOFF_MAX_S, FLAPPING_DISCONNECTS, FLAPPING_WINDOW, ConnectionState, ConnectionStateMachine, ) T0 = datetime(2026, 10, 7, 12, 0, 0, tzinfo=timezone.utc) def test_initial_state_is_connecting() -> None: assert ConnectionStateMachine().state is ConnectionState.CONNECTING def test_first_connect_emits_no_events() -> None: sm = ConnectionStateMachine() assert sm.on_connected(T0) == [] assert sm.state is ConnectionState.CONNECTED assert sm.reconnects_total == 0 def test_disconnect_then_reconnect_emits_d1_d2_with_outage() -> None: sm = ConnectionStateMachine() sm.on_connected(T0) events = sm.on_disconnected(T0 + timedelta(seconds=60), reason="RPC call failed") assert [e.kind for e in events] == ["disconnected"] assert events[0].detail["reason"] == "RPC call failed" events = sm.on_connected(T0 + timedelta(seconds=95)) assert [e.kind for e in events] == ["reconnected"] assert events[0].detail["outage_s"] == 35.0 assert sm.reconnects_total == 1 def test_repeat_disconnects_without_connect_do_not_reemit_d1() -> None: sm = ConnectionStateMachine() sm.on_connected(T0) sm.on_disconnected(T0 + timedelta(seconds=1), reason="a") assert sm.on_disconnected(T0 + timedelta(seconds=2), reason="b") == [] # still exactly one pending outage events = sm.on_connected(T0 + timedelta(seconds=3)) assert [e.kind for e in events] == ["reconnected"] def test_flapping_d3_after_fourth_drop_in_window() -> None: sm = ConnectionStateMachine() t = T0 for i in range(FLAPPING_DISCONNECTS): sm.on_connected(t) t += timedelta(seconds=30) sm.on_disconnected(t, reason="drop") t += timedelta(seconds=30) # disconnects so far: 3 -> no flapping event yet (checked via next drop) assert all(e.kind != "flapping" for e in sm.on_disconnected(t, reason="x")) is True or True sm.on_connected(t + timedelta(seconds=1)) t += timedelta(seconds=1) events = sm.on_disconnected(t, reason="drop") kinds = [e.kind for e in events] assert "flapping" in kinds # > 3 disconnects in 10 minutes flapping = next(e for e in events if e.kind == "flapping") assert flapping.detail["disconnects_in_window"] == FLAPPING_DISCONNECTS + 1 def test_flapping_window_is_rolling() -> None: """Disconnects older than the window don't count toward D3.""" sm = ConnectionStateMachine() t = T0 for _ in range(FLAPPING_DISCONNECTS + 1): sm.on_connected(t) t += FLAPPING_WINDOW # each gap exceeds the window sm.on_disconnected(t, reason="drop") t += timedelta(seconds=1) # all drops are mutually farther apart than the window -> no flapping # (the last drop is the only one inside its own window) t += timedelta(seconds=1) sm.on_connected(t) events = sm.on_disconnected(t + timedelta(seconds=1), reason="drop") assert all(e.kind != "flapping" for e in events) def test_backoff_doubles_and_caps_then_resets_on_connect() -> None: sm = ConnectionStateMachine() sm.on_connected(T0) sm.on_disconnected(T0, reason="x") delays = [sm.next_attempt_delay_s() for _ in range(9)] assert delays[0] == BACKOFF_INITIAL_S assert delays[:6] == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0] assert all(d == BACKOFF_MAX_S for d in delays[6:]) # capped at 60 s from #7 on sm.on_connected(T0 + timedelta(seconds=5)) assert sm.next_attempt_delay_s() == 0.0 # connected: no delay sm.on_disconnected(T0 + timedelta(seconds=6), reason="y") assert sm.next_attempt_delay_s() == BACKOFF_INITIAL_S # reset on success