97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
"""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 |