This commit is contained in:
Jonas Bengtsson
2026-10-08 07:26:04 +02:00
parent a1af8d68c9
commit 406fdc61ac
13 changed files with 1750 additions and 89 deletions
+94 -8
View File
@@ -1,11 +1,97 @@
"""State machine tests (SPECIFICATION §10.2). Transitions land in Phase 1."""
"""State-machine tests: D1/D2/D3 transitions + exponential backoff (spec §10.2)."""
from opcda_diagnozer.monitor.state import ConnectionState
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_connection_states_match_spec() -> None:
assert {s.name for s in ConnectionState} == {
"CONNECTING",
"CONNECTED",
"RECONNECT_WAIT",
}
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