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