mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
|
|
"""Tests for SWPC space weather adapter Phase 2.12 — to_event() + dedup fix."""
|
||
|
|
|
||
|
|
import time
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from meshai.env.swpc import SWPCAdapter
|
||
|
|
from meshai.notifications.events import Event
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FIXTURES
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_config():
|
||
|
|
"""Create a mock SWPCConfig."""
|
||
|
|
return MagicMock()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def adapter(mock_config):
|
||
|
|
"""Create a SWPCAdapter with mocked config."""
|
||
|
|
return SWPCAdapter(mock_config)
|
||
|
|
|
||
|
|
|
||
|
|
def make_swpc_event(
|
||
|
|
scale="G",
|
||
|
|
level=3,
|
||
|
|
severity="priority",
|
||
|
|
headline=None,
|
||
|
|
):
|
||
|
|
"""Helper to create a stored SWPC event dict (mirrors _update_events)."""
|
||
|
|
now = time.time()
|
||
|
|
label = {"R": "Radio Blackout", "S": "Solar Radiation Storm", "G": "Geomagnetic Storm"}[scale]
|
||
|
|
if headline is None:
|
||
|
|
headline = f"{scale}{level} {label} in progress"
|
||
|
|
return {
|
||
|
|
"source": "swpc",
|
||
|
|
"event_id": f"swpc_{scale.lower()}{level}",
|
||
|
|
"event_type": f"{scale}{level} {label}",
|
||
|
|
"scale": scale,
|
||
|
|
"level": level,
|
||
|
|
"severity": severity,
|
||
|
|
"headline": headline,
|
||
|
|
"expires": now + 3600,
|
||
|
|
"areas": [],
|
||
|
|
"fetched_at": now,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# CATEGORY TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_scale_categories(adapter):
|
||
|
|
"""Each NOAA scale maps to its category."""
|
||
|
|
cases = {
|
||
|
|
"R": "rf_propagation_alert",
|
||
|
|
"S": "solar_radiation_storm",
|
||
|
|
"G": "geomagnetic_storm",
|
||
|
|
}
|
||
|
|
for scale, category in cases.items():
|
||
|
|
event = adapter.to_event(make_swpc_event(scale=scale, level=3))
|
||
|
|
assert event is not None
|
||
|
|
assert event.category == category
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# SEVERITY TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_severity_passes_through(adapter):
|
||
|
|
"""Severity from the stored event passes through unchanged."""
|
||
|
|
for sev in ["routine", "priority", "immediate"]:
|
||
|
|
event = adapter.to_event(make_swpc_event(severity=sev))
|
||
|
|
assert event is not None
|
||
|
|
assert event.severity == sev
|
||
|
|
|
||
|
|
|
||
|
|
def test_update_events_severity_tiering(adapter):
|
||
|
|
"""_update_events tiers severity: 1-2 routine, 3-4 priority, 5 immediate."""
|
||
|
|
expected = {1: "routine", 2: "routine", 3: "priority", 4: "priority", 5: "immediate"}
|
||
|
|
for level, sev in expected.items():
|
||
|
|
adapter._status = {"g_scale": level}
|
||
|
|
adapter._update_events()
|
||
|
|
evs = adapter.get_events()
|
||
|
|
assert len(evs) == 1
|
||
|
|
assert evs[0]["severity"] == sev
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# DEDUP REGRESSION TEST (the bug we are fixing)
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_dedup_id_stable_across_ticks(adapter):
|
||
|
|
"""REGRESSION: a sustained condition keeps the SAME event_id across ticks.
|
||
|
|
|
||
|
|
The old code embedded int(time.time()) in event_id, making every tick
|
||
|
|
unique and defeating the store's (source, event_id) dedup.
|
||
|
|
"""
|
||
|
|
adapter._status = {"r_scale": 3, "s_scale": 0, "g_scale": 0}
|
||
|
|
adapter._update_events()
|
||
|
|
id1 = adapter.get_events()[0]["event_id"]
|
||
|
|
time.sleep(0.01) # wall clock advances
|
||
|
|
adapter._update_events()
|
||
|
|
id2 = adapter.get_events()[0]["event_id"]
|
||
|
|
assert id1 == id2
|
||
|
|
assert id1 == "swpc_r3"
|
||
|
|
|
||
|
|
|
||
|
|
def test_event_id_changes_with_level(adapter):
|
||
|
|
"""An escalation to a new level produces a new (stable) event_id."""
|
||
|
|
adapter._status = {"g_scale": 3}
|
||
|
|
adapter._update_events()
|
||
|
|
id_g3 = adapter.get_events()[0]["event_id"]
|
||
|
|
adapter._status = {"g_scale": 5}
|
||
|
|
adapter._update_events()
|
||
|
|
id_g5 = adapter.get_events()[0]["event_id"]
|
||
|
|
assert id_g3 == "swpc_g3"
|
||
|
|
assert id_g5 == "swpc_g5"
|
||
|
|
assert id_g3 != id_g5
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# EMISSION SCOPE TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_all_three_scales_emit_when_active(adapter):
|
||
|
|
"""R, S, and G each produce an event when active (level >= 1)."""
|
||
|
|
adapter._status = {"r_scale": 2, "s_scale": 1, "g_scale": 4}
|
||
|
|
adapter._update_events()
|
||
|
|
scales = {e["scale"] for e in adapter.get_events()}
|
||
|
|
assert scales == {"R", "S", "G"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_quiet_conditions_emit_nothing(adapter):
|
||
|
|
"""All scales at 0 (quiet) produce no events."""
|
||
|
|
adapter._status = {"r_scale": 0, "s_scale": 0, "g_scale": 0}
|
||
|
|
adapter._update_events()
|
||
|
|
assert adapter.get_events() == []
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# GROUP KEY / INHIBIT KEY TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_group_key_is_event_id(adapter):
|
||
|
|
"""Group key is the stable swpc_{scale}{level} key."""
|
||
|
|
event = adapter.to_event(make_swpc_event(scale="G", level=3))
|
||
|
|
assert event is not None
|
||
|
|
assert event.group_key == "swpc_g3"
|
||
|
|
|
||
|
|
|
||
|
|
def test_inhibit_keys_match_group_key(adapter):
|
||
|
|
"""The sole inhibit key equals the group key (Inhibitor does severity tiering)."""
|
||
|
|
event = adapter.to_event(make_swpc_event())
|
||
|
|
assert event is not None
|
||
|
|
assert event.inhibit_keys == [event.group_key]
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# CONTENT / FIELD POPULATION TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_populates_core_fields_global(adapter):
|
||
|
|
"""Core fields populate; SWPC is global so lat/lon are None, region set."""
|
||
|
|
evt = make_swpc_event(scale="G", level=4)
|
||
|
|
event = adapter.to_event(evt)
|
||
|
|
assert event is not None
|
||
|
|
assert event.source == "swpc"
|
||
|
|
assert event.lat is None
|
||
|
|
assert event.lon is None
|
||
|
|
assert event.region == "global"
|
||
|
|
assert event.expires == evt["expires"]
|
||
|
|
assert event.timestamp == evt["fetched_at"]
|
||
|
|
assert event.id # auto-computed
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# DEFENSIVE / NON-EMIT TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_missing_scale_returns_none(adapter):
|
||
|
|
"""Missing scale discriminator returns None."""
|
||
|
|
evt = make_swpc_event()
|
||
|
|
evt["scale"] = None
|
||
|
|
assert adapter.to_event(evt) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_level_zero_returns_none(adapter):
|
||
|
|
"""A level-0 (quiet) condition returns None."""
|
||
|
|
assert adapter.to_event(make_swpc_event(level=0, severity="routine")) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_event_id_returns_none(adapter):
|
||
|
|
"""Missing event_id returns None (no stable group key)."""
|
||
|
|
evt = make_swpc_event()
|
||
|
|
evt["event_id"] = None
|
||
|
|
assert adapter.to_event(evt) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_does_not_raise_on_corrupted_dict(adapter):
|
||
|
|
"""Corrupted dict returns None without raising."""
|
||
|
|
assert adapter.to_event({"garbage": True}) is None
|