feat: classify offline and degraded connectivity
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user