Files
PingWatch/backend/tests/test_state_machine.py
T

140 lines
3.8 KiB
Python

"""Unit tests for the connectivity health state machine."""
from dataclasses import dataclass
import pytest
from app.models.device import DeviceMonitoringPolicy
from app.services.state_machine import evaluate_health
@dataclass(frozen=True)
class RecentProbe:
"""Minimal real-data-shaped record consumed by the pure state machine."""
sent_count: int
received_count: int
is_valid: bool = True
def loss(sent_count: int) -> RecentProbe:
return RecentProbe(sent_count=sent_count, received_count=0)
def partial(sent_count: int, received_count: int) -> RecentProbe:
return RecentProbe(
sent_count=sent_count,
received_count=received_count,
)
def clean(sent_count: int) -> RecentProbe:
return RecentProbe(sent_count=sent_count, received_count=sent_count)
@pytest.fixture
def policy() -> DeviceMonitoringPolicy:
return DeviceMonitoringPolicy(
probe_packets_per_round=3,
offline_consecutive_rounds=2,
degraded_window_rounds=5,
degraded_loss_percent=20.0,
recovery_consecutive_clean_rounds=3,
)
def test_two_full_loss_rounds_take_device_offline(policy):
"""Configured consecutive full-loss rounds transition to offline once."""
decision = evaluate_health("online", [loss(3), loss(3)], policy)
assert (decision.next_status, decision.event_type) == (
"offline",
"offline",
)
assert decision.should_notify is True
def test_offline_priority_wins_over_aggregate_degraded_loss(policy):
"""Full-loss streaks are classified offline even when the window also degrades."""
recent = [partial(3, 2)] * 3 + [loss(3), loss(3)]
decision = evaluate_health("online", recent, policy)
assert decision.next_status == "offline"
assert decision.event_type == "offline"
def test_five_round_window_with_loss_is_degraded(policy):
"""Aggregate loss at the configured window threshold is degraded."""
decision = evaluate_health(
"online",
[partial(3, 2)] * 5,
policy,
)
assert decision.next_status == "degraded"
assert decision.event_type == "degraded"
def test_degraded_requires_a_complete_window(policy):
"""A partial history cannot satisfy an aggregate-window decision."""
decision = evaluate_health(
"online",
[partial(3, 2)] * 4,
policy,
)
assert decision.next_status == "online"
assert decision.event_type is None
def test_three_clean_rounds_recovers(policy):
"""An alerted device recovers only after the configured clean streak."""
decision = evaluate_health(
"offline",
[clean(3)] * 3,
policy,
)
assert (decision.next_status, decision.event_type) == (
"online",
"recovered",
)
assert decision.should_notify is True
def test_clean_online_device_does_not_emit_duplicate_event(policy):
"""Clean probes retain an already-online state without notifying."""
decision = evaluate_health(
"online",
[clean(3)] * 5,
policy,
)
assert decision.next_status == "online"
assert decision.event_type is None
assert decision.should_notify is False
def test_latest_invalid_probe_retains_state(policy):
"""A malformed current result cannot reuse old loss to trigger a fault."""
recent = [loss(3), RecentProbe(3, 0, is_valid=False)]
decision = evaluate_health("online", recent, policy)
assert decision.next_status == "online"
assert decision.event_type is None
assert decision.should_notify is False
def test_offline_does_not_recover_before_clean_streak_is_complete(policy):
"""Recovery debounce retains offline until enough clean rounds exist."""
decision = evaluate_health(
"offline",
[clean(3), clean(3)],
policy,
)
assert decision.next_status == "offline"
assert decision.event_type is None