mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
176 lines
5.6 KiB
Python
176 lines
5.6 KiB
Python
|
|
"""Tests for NIFC fires adapter Phase 2.11 — to_event() method."""
|
||
|
|
|
||
|
|
import time
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from meshai.env.fires import NICFFiresAdapter
|
||
|
|
from meshai.notifications.events import Event
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# FIXTURES
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_config():
|
||
|
|
"""Create a mock NICFFiresConfig with real scalar fields."""
|
||
|
|
config = MagicMock()
|
||
|
|
config.state = "US-ID"
|
||
|
|
config.tick_seconds = 600
|
||
|
|
return config
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def adapter(mock_config):
|
||
|
|
"""Create a NICFFiresAdapter with mocked config."""
|
||
|
|
return NICFFiresAdapter(mock_config)
|
||
|
|
|
||
|
|
|
||
|
|
def make_fire_event(
|
||
|
|
name="Ross Fork Fire",
|
||
|
|
state="US-ID",
|
||
|
|
acres=12500,
|
||
|
|
pct_contained=40,
|
||
|
|
severity="priority",
|
||
|
|
lat=43.6,
|
||
|
|
lon=-114.9,
|
||
|
|
distance_km=18.0,
|
||
|
|
nearest_anchor="Twin Falls",
|
||
|
|
headline=None,
|
||
|
|
):
|
||
|
|
"""Helper to create a stored NIFC event dict (mirrors _fetch)."""
|
||
|
|
now = time.time()
|
||
|
|
if headline is None:
|
||
|
|
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
||
|
|
evt = {
|
||
|
|
"source": "nifc",
|
||
|
|
"event_id": f"nifc_{name.replace(' ', '_').lower()}_{state}",
|
||
|
|
"event_type": "Wildfire",
|
||
|
|
"severity": severity,
|
||
|
|
"headline": headline,
|
||
|
|
"name": name,
|
||
|
|
"acres": acres,
|
||
|
|
"pct_contained": pct_contained,
|
||
|
|
"lat": lat,
|
||
|
|
"lon": lon,
|
||
|
|
"distance_km": distance_km,
|
||
|
|
"nearest_anchor": nearest_anchor,
|
||
|
|
"state": state,
|
||
|
|
"expires": now + 21600,
|
||
|
|
"fetched_at": now,
|
||
|
|
}
|
||
|
|
return evt
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# CATEGORY TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_active_perimeter_is_wildfire_incident(adapter):
|
||
|
|
"""Any active perimeter with a size maps to wildfire_incident."""
|
||
|
|
event = adapter.to_event(make_fire_event())
|
||
|
|
assert event is not None
|
||
|
|
assert event.category == "wildfire_incident"
|
||
|
|
|
||
|
|
|
||
|
|
def test_category_independent_of_severity(adapter):
|
||
|
|
"""Category stays wildfire_incident regardless of severity."""
|
||
|
|
for sev in ["routine", "priority"]:
|
||
|
|
event = adapter.to_event(make_fire_event(severity=sev))
|
||
|
|
assert event is not None
|
||
|
|
assert event.category == "wildfire_incident"
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# SEVERITY PASS-THROUGH TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_severity_passes_through(adapter):
|
||
|
|
"""The adapter's proximity-based severity passes through unchanged."""
|
||
|
|
for sev in ["routine", "priority", "immediate"]:
|
||
|
|
event = adapter.to_event(make_fire_event(severity=sev))
|
||
|
|
assert event is not None
|
||
|
|
assert event.severity == sev
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# GROUP KEY / INHIBIT KEY TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_group_key_is_event_id(adapter):
|
||
|
|
"""Group key is the stable nifc_{name}_{state} key."""
|
||
|
|
event = adapter.to_event(make_fire_event(name="Ross Fork Fire", state="US-ID"))
|
||
|
|
assert event is not None
|
||
|
|
assert event.group_key == "nifc_ross_fork_fire_US-ID"
|
||
|
|
|
||
|
|
|
||
|
|
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_fire_event())
|
||
|
|
assert event is not None
|
||
|
|
assert event.inhibit_keys == [event.group_key]
|
||
|
|
|
||
|
|
|
||
|
|
def test_distinct_fires_get_distinct_keys(adapter):
|
||
|
|
"""Two different incidents get distinct group keys."""
|
||
|
|
e1 = adapter.to_event(make_fire_event(name="Ross Fork Fire"))
|
||
|
|
e2 = adapter.to_event(make_fire_event(name="Wapiti Fire"))
|
||
|
|
assert e1.group_key != e2.group_key
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# CONTENT / FIELD POPULATION TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_populates_core_fields(adapter):
|
||
|
|
"""Core Event fields are populated from the stored dict."""
|
||
|
|
evt = make_fire_event(lat=43.61, lon=-114.92)
|
||
|
|
event = adapter.to_event(evt)
|
||
|
|
assert event is not None
|
||
|
|
assert event.source == "nifc"
|
||
|
|
assert event.lat == 43.61
|
||
|
|
assert event.lon == -114.92
|
||
|
|
assert event.expires == evt["expires"]
|
||
|
|
assert event.timestamp == evt["fetched_at"]
|
||
|
|
assert event.id # auto-computed
|
||
|
|
|
||
|
|
|
||
|
|
def test_summary_includes_size_containment_proximity(adapter):
|
||
|
|
"""Summary includes acreage, containment, and nearest-anchor proximity."""
|
||
|
|
event = adapter.to_event(
|
||
|
|
make_fire_event(acres=12500, pct_contained=40, distance_km=18.0, nearest_anchor="Twin Falls")
|
||
|
|
)
|
||
|
|
assert event is not None
|
||
|
|
assert "12,500 ac" in event.summary
|
||
|
|
assert "40% contained" in event.summary
|
||
|
|
assert "18 km from Twin Falls" in event.summary
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================
|
||
|
|
# DEFENSIVE / NON-EMIT TESTS
|
||
|
|
# ============================================================
|
||
|
|
|
||
|
|
def test_missing_acres_returns_none(adapter):
|
||
|
|
"""A perimeter with no reported size (0 acres) is not emitted."""
|
||
|
|
assert adapter.to_event(make_fire_event(acres=0)) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_centroid_returns_none(adapter):
|
||
|
|
"""Missing centroid (lat/lon) returns None."""
|
||
|
|
evt = make_fire_event()
|
||
|
|
evt["lat"] = None
|
||
|
|
assert adapter.to_event(evt) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_event_id_returns_none(adapter):
|
||
|
|
"""Missing event_id returns None (no stable group key)."""
|
||
|
|
evt = make_fire_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
|