mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
traffic and firms no longer idle when their key is blank — they build a keyless request (traffic: omit key= param; firms: omit the map_key path segment), matching roads511's existing optional-key pattern. Enables routing these feeds through a key-injecting proxy (Conduit) with the key held only there. Key-set behavior is byte-identical. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
8 KiB
Python
249 lines
8 KiB
Python
"""Tests for FIRMS adapter Phase 2.6 — to_event() method."""
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from meshai.env.firms import FIRMSAdapter
|
|
from meshai.notifications.events import Event
|
|
|
|
|
|
# ============================================================
|
|
# FIXTURES
|
|
# ============================================================
|
|
|
|
@pytest.fixture
|
|
def mock_config():
|
|
"""Create a mock FIRMSConfig."""
|
|
config = MagicMock()
|
|
config.map_key = "test-key"
|
|
config.source = "VIIRS_SNPP_NRT"
|
|
config.bbox = [-117, 42, -114, 44]
|
|
config.day_range = 1
|
|
config.tick_seconds = 1800
|
|
config.confidence_min = "nominal"
|
|
config.proximity_km = 10.0
|
|
return config
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(mock_config):
|
|
"""Create a FIRMSAdapter with mocked dependencies."""
|
|
return FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None)
|
|
|
|
|
|
def make_firms_event(
|
|
lat=42.5,
|
|
lon=-114.5,
|
|
new_ignition=False,
|
|
severity="routine",
|
|
headline="Test Hotspot",
|
|
frp=None,
|
|
confidence="n",
|
|
distance_km=None,
|
|
nearest_anchor=None,
|
|
near_fire=None,
|
|
):
|
|
"""Helper to create a FIRMS event dict."""
|
|
now = time.time()
|
|
return {
|
|
"source": "firms",
|
|
"event_id": f"firms_{lat:.4f}_{lon:.4f}_2026-05-15_1200",
|
|
"event_type": "Fire Hotspot",
|
|
"severity": severity,
|
|
"headline": headline,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"expires": now + 21600,
|
|
"fetched_at": now,
|
|
"properties": {
|
|
"new_ignition": new_ignition,
|
|
"confidence": confidence,
|
|
"frp": frp,
|
|
"brightness": 350.0,
|
|
"acq_date": "2026-05-15",
|
|
"acq_time": "1200",
|
|
"near_fire": near_fire,
|
|
"distance_to_fire_km": 5.0 if near_fire else None,
|
|
"distance_km": distance_km,
|
|
"nearest_anchor": nearest_anchor,
|
|
},
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# ATTRIBUTION-ONLY CONTRACT
|
|
# ============================================================
|
|
#
|
|
# Firm rule (Matt): "we do NOT broadcast hotspots." The native FIRMS adapter
|
|
# must NEVER produce a broadcastable Event from a raw satellite pixel --
|
|
# neither a near-known-fire `wildfire_hotspot` nor a standalone `new_ignition`.
|
|
# `to_event()` is now attribution-only and ALWAYS returns None, mirroring the
|
|
# storage-only Central path (central/firms_handler.py). The only FIRMS signals
|
|
# that ever reach the mesh are the fusion outputs (wildfire_growth /
|
|
# wildfire_spotting / wildfire_halted) produced by the Central handler.
|
|
#
|
|
# These tests lock that contract: NOTHING the adapter emits reaches the bus.
|
|
|
|
def test_to_event_hotspot_returns_none(adapter):
|
|
"""A representative hotspot (near a known fire) never broadcasts."""
|
|
evt = make_firms_event(new_ignition=False, near_fire="Snake River Fire",
|
|
frp=85.5, confidence="h")
|
|
assert adapter.to_event(evt) is None
|
|
|
|
|
|
def test_to_event_new_ignition_returns_none(adapter):
|
|
"""A representative new-ignition hotspot never broadcasts."""
|
|
evt = make_firms_event(new_ignition=True, frp=120.0, confidence="h",
|
|
distance_km=12, nearest_anchor="TFL")
|
|
assert adapter.to_event(evt) is None
|
|
|
|
|
|
def test_to_event_returns_none_across_severities(adapter):
|
|
"""No severity tier re-opens the hotspot broadcast path."""
|
|
for sev in ["routine", "priority", "immediate"]:
|
|
evt = make_firms_event(severity=sev)
|
|
assert adapter.to_event(evt) is None
|
|
|
|
|
|
def test_to_event_never_broadcasts_even_with_full_payload(adapter):
|
|
"""A fully-populated hotspot dict still produces no Event."""
|
|
evt = make_firms_event(
|
|
lat=42.5678, lon=-114.3456, new_ignition=True, severity="immediate",
|
|
headline="NEW HOTSPOT detected", frp=250.0, confidence="h",
|
|
distance_km=3, nearest_anchor="MHR",
|
|
)
|
|
assert adapter.to_event(evt) is None
|
|
|
|
|
|
# ============================================================
|
|
# DEFENSIVE TESTS
|
|
# ============================================================
|
|
|
|
def test_to_event_missing_coords_returns_none(adapter):
|
|
"""Missing coordinates returns None."""
|
|
evt = make_firms_event()
|
|
evt["lat"] = None
|
|
event = adapter.to_event(evt)
|
|
assert event is None
|
|
|
|
|
|
def test_to_event_missing_properties_returns_none(adapter):
|
|
"""Missing properties dict still produces no broadcast."""
|
|
evt = {
|
|
"source": "firms",
|
|
"event_id": "test",
|
|
"event_type": "Fire Hotspot",
|
|
"severity": "routine",
|
|
"headline": "Test",
|
|
"lat": 42.5,
|
|
"lon": -114.5,
|
|
"fetched_at": time.time(),
|
|
}
|
|
# No "properties" key at all
|
|
event = adapter.to_event(evt)
|
|
assert event is None
|
|
|
|
|
|
def test_to_event_does_not_raise_on_corrupted_dict(adapter):
|
|
"""Corrupted dict returns None without raising."""
|
|
evt = {"garbage": True}
|
|
# Should not raise
|
|
event = adapter.to_event(evt)
|
|
assert event is None
|
|
|
|
|
|
# ============================================================
|
|
# OPTIONAL MAP_KEY — Conduit keyless-request support
|
|
#
|
|
# The FIRMS MAP_KEY moves to Conduit's keystore; meshai sends a keyless
|
|
# request and Conduit injects the key into the URL path downstream. The
|
|
# key gate must not idle the adapter, and a blank key must build a URL
|
|
# with NO map_key path segment. A configured key must produce a
|
|
# byte-identical URL to before (backward compat).
|
|
# ============================================================
|
|
|
|
class _FakeCM:
|
|
"""Minimal context manager mimicking urlopen()'s return value."""
|
|
|
|
def __init__(self, body: bytes):
|
|
self._body = body
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def read(self):
|
|
return self._body
|
|
|
|
|
|
# Empty-body CSV (header only, no data rows) short-circuits _parse_csv
|
|
# before any fusion/persistence path is touched.
|
|
_EMPTY_CSV = b"latitude,longitude,confidence,frp,acq_date,acq_time\n"
|
|
|
|
|
|
def test_fetch_url_with_map_key_is_byte_identical(mock_config):
|
|
"""With MAP_KEY configured, the built URL is unchanged (backward compat)."""
|
|
adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None)
|
|
captured = {}
|
|
|
|
def fake_urlopen(req, timeout=30):
|
|
captured["url"] = req.full_url
|
|
return _FakeCM(_EMPTY_CSV)
|
|
|
|
with patch("meshai.env.firms.urlopen", side_effect=fake_urlopen):
|
|
adapter._fetch()
|
|
|
|
assert captured["url"] == (
|
|
"https://firms.modaps.eosdis.nasa.gov/api/area/csv/"
|
|
"test-key/VIIRS_SNPP_NRT/-117,42,-114,44/1"
|
|
)
|
|
|
|
|
|
def test_fetch_url_blank_map_key_omits_path_segment(mock_config):
|
|
"""With a blank MAP_KEY, the URL is built keyless (no map_key path
|
|
segment) for a key-injecting proxy (Conduit) to complete downstream."""
|
|
mock_config.map_key = ""
|
|
adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None)
|
|
captured = {}
|
|
|
|
def fake_urlopen(req, timeout=30):
|
|
captured["url"] = req.full_url
|
|
return _FakeCM(_EMPTY_CSV)
|
|
|
|
with patch("meshai.env.firms.urlopen", side_effect=fake_urlopen):
|
|
adapter._fetch()
|
|
|
|
assert captured["url"] == (
|
|
"https://firms.modaps.eosdis.nasa.gov/api/area/csv/"
|
|
"VIIRS_SNPP_NRT/-117,42,-114,44/1"
|
|
)
|
|
assert "test-key" not in captured["url"]
|
|
|
|
|
|
def test_tick_does_not_idle_when_map_key_blank(mock_config):
|
|
"""A blank MAP_KEY must not gate tick(); bbox is the real prerequisite."""
|
|
mock_config.map_key = ""
|
|
adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None)
|
|
|
|
with patch.object(adapter, "_fetch", return_value=False) as fetch:
|
|
result = adapter.tick()
|
|
|
|
fetch.assert_called_once()
|
|
assert result is False
|
|
|
|
|
|
def test_tick_still_gates_on_no_bbox(mock_config):
|
|
"""bbox remains a real prerequisite — tick() still idles without it,
|
|
even with MAP_KEY configured."""
|
|
mock_config.bbox = []
|
|
adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None)
|
|
|
|
with patch.object(adapter, "_fetch") as fetch:
|
|
result = adapter.tick()
|
|
|
|
fetch.assert_not_called()
|
|
assert result is False
|