central/tests/test_wfigs.py

444 lines
16 KiB
Python
Raw Normal View History

"""Tests for WFIGS adapters."""
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.config_models import AdapterConfig, RegionConfig
from central.models import Event, Geo
# Sample GeoJSON response with incidents
SAMPLE_INCIDENTS_RESPONSE = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-116.5, 43.5]},
"properties": {
"IrwinID": "GUID-001-BOISE",
"IncidentName": "Test Fire 1",
"IncidentTypeCategory": "Wildfire",
"DailyAcres": 150,
"PercentContained": 25,
"FireDiscoveryDateTime": 1716000000000,
"ModifiedOnDateTime": 1716100000000,
"POOState": "ID",
"POOCounty": "Ada",
},
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-117.0, 44.0]},
"properties": {
"IrwinID": "GUID-002-CANYON",
"IncidentName": "Test Fire 2",
"IncidentTypeCategory": "PrescribedFire",
"DailyAcres": 5,
"PercentContained": 100,
"FireDiscoveryDateTime": 1716200000000,
"ModifiedOnDateTime": 1716300000000,
"POOState": "ID",
"POOCounty": "Canyon",
},
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-80.0, 26.0]},
"properties": {
"IrwinID": "GUID-003-FLORIDA",
"IncidentName": "Florida Fire",
"IncidentTypeCategory": "Wildfire",
"DailyAcres": 50,
"PercentContained": 0,
"FireDiscoveryDateTime": 1716400000000,
"ModifiedOnDateTime": 1716500000000,
"POOState": "FL",
"POOCounty": "Miami-Dade",
},
},
],
}
# Perimeters API uses prefixed field names (attr_*, poly_*)
SAMPLE_PERIMETERS_RESPONSE = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-116.6, 43.4],
[-116.4, 43.4],
[-116.4, 43.6],
[-116.6, 43.6],
[-116.6, 43.4],
]],
},
"properties": {
"attr_IrwinID": "GUID-001-BOISE",
"attr_IncidentName": "Test Fire 1",
"attr_IncidentTypeCategory": "Wildfire",
"attr_IncidentSize": 150,
"poly_GISAcres": 148.5,
"attr_PercentContained": 25,
"attr_FireDiscoveryDateTime": 1716000000000,
"attr_ModifiedOnDateTime_dt": 1716100000000,
"attr_POOState": "ID",
"attr_POOCounty": "Ada",
},
},
],
}
class TestWFIGSCommon:
"""Tests for WFIGS common utilities."""
def test_severity_from_acres_none(self):
from central.adapters.wfigs_common import severity_from_acres
assert severity_from_acres(None) == 0
assert severity_from_acres(0) == 0
def test_severity_from_acres_small(self):
from central.adapters.wfigs_common import severity_from_acres
assert severity_from_acres(5) == 1
assert severity_from_acres(9.9) == 1
def test_severity_from_acres_medium(self):
from central.adapters.wfigs_common import severity_from_acres
assert severity_from_acres(10) == 2
assert severity_from_acres(99) == 2
def test_severity_from_acres_large(self):
from central.adapters.wfigs_common import severity_from_acres
assert severity_from_acres(100) == 3
assert severity_from_acres(999) == 3
def test_severity_from_acres_very_large(self):
from central.adapters.wfigs_common import severity_from_acres
assert severity_from_acres(1000) == 4
assert severity_from_acres(100000) == 4
def test_parse_wfigs_timestamp(self):
from central.adapters.wfigs_common import parse_wfigs_timestamp
ts = parse_wfigs_timestamp(1716000000000)
assert ts is not None
assert ts.tzinfo == timezone.utc
assert ts.year == 2024
def test_parse_wfigs_timestamp_none(self):
from central.adapters.wfigs_common import parse_wfigs_timestamp
assert parse_wfigs_timestamp(None) is None
def test_build_regions_full(self):
from central.adapters.wfigs_common import build_regions
regions, primary = build_regions("ID", "Ada")
assert regions == ["US-ID-ADA"]
assert primary == "US-ID-ADA"
def test_build_regions_state_only(self):
from central.adapters.wfigs_common import build_regions
regions, primary = build_regions("ID", None)
assert regions == ["US-ID"]
assert primary == "US-ID"
def test_build_regions_none(self):
from central.adapters.wfigs_common import build_regions
regions, primary = build_regions(None, None)
assert regions == []
assert primary is None
def test_subject_suffix(self):
from central.adapters.wfigs_common import subject_suffix
assert subject_suffix("ID", "Ada") == "id.ada"
assert subject_suffix("ID", "Ada County") == "id.ada_county"
assert subject_suffix("ID", None) == "id"
assert subject_suffix(None, None) == "unknown"
def test_point_in_bbox(self):
from central.adapters.wfigs_common import point_in_bbox
assert point_in_bbox(-116.5, 43.5, -124, 31, -102, 49) is True
assert point_in_bbox(-80.0, 26.0, -124, 31, -102, 49) is False
class TestWFIGSIncidentsAdapter:
"""Tests for WFIGS Incidents adapter."""
@pytest.fixture
def mock_config(self) -> AdapterConfig:
return AdapterConfig(
name="wfigs_incidents",
enabled=True,
cadence_s=300,
settings={
"region": {"north": 49.0, "south": 31.0, "east": -102.0, "west": -124.0}
},
updated_at=datetime.now(timezone.utc),
)
@pytest.fixture
def mock_config_store(self) -> MagicMock:
return MagicMock()
@pytest.fixture
def cursor_db_path(self, tmp_path: Path) -> Path:
return tmp_path / "cursors.db"
@pytest.mark.asyncio
async def test_normalization_incidents(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
"""Incidents are correctly normalized to Events."""
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
await adapter.startup()
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = AsyncMock(return_value=SAMPLE_INCIDENTS_RESPONSE)
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response), __aexit__=AsyncMock())):
events = [e async for e in adapter.poll()]
await adapter.shutdown()
# Should have 2 events (Florida filtered out by bbox)
assert len(events) == 2
event = events[0]
assert event.id == "GUID-001-BOISE"
assert event.adapter == "wfigs_incidents"
assert event.category == "fire.incident.wildfire"
assert event.severity == 3 # 150 acres = severity 3 (100-999 range)
assert event.geo.primary_region == "US-ID-ADA"
assert event.data["IrwinID"] == "GUID-001-BOISE"
@pytest.mark.asyncio
async def test_is_published_dedup(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
"""is_published/mark_published provides dedup functionality."""
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
await adapter.startup()
# Initially not published
assert adapter.is_published("test-id") is False
# Mark as published
adapter.mark_published("test-id")
# Now it should be published
assert adapter.is_published("test-id") is True
await adapter.shutdown()
@pytest.mark.asyncio
async def test_fall_off_emits_removal(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
"""Fall-off detection emits removal events."""
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
await adapter.startup()
# First poll with 2 incidents
mock_response1 = AsyncMock()
mock_response1.raise_for_status = MagicMock()
mock_response1.json = AsyncMock(return_value=SAMPLE_INCIDENTS_RESPONSE)
# Second poll with only 1 incident (GUID-002 fell off)
reduced_response = {
"type": "FeatureCollection",
"features": [SAMPLE_INCIDENTS_RESPONSE["features"][0]],
}
mock_response2 = AsyncMock()
mock_response2.raise_for_status = MagicMock()
mock_response2.json = AsyncMock(return_value=reduced_response)
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response1), __aexit__=AsyncMock())):
events1 = [e async for e in adapter.poll()]
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response2), __aexit__=AsyncMock())):
events2 = [e async for e in adapter.poll()]
await adapter.shutdown()
# First poll: 2 incident events
assert len(events1) == 2
# Second poll: 1 incident (seen again) + 1 removal for GUID-002
# The incident event is yielded (supervisor does dedup via is_published)
# The removal is yielded for GUID-002
removal_events = [e for e in events2 if e.category == "fire.incident.removed"]
assert len(removal_events) == 1
assert removal_events[0].data["irwin_id"] == "GUID-002-CANYON"
def test_subject_for_incidents(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
event = Event(
id="test-id",
adapter="wfigs_incidents",
category="fire.incident.wildfire",
time=datetime.now(timezone.utc),
severity=2,
geo=Geo(primary_region="US-ID-ADA"),
data={"POOState": "ID", "POOCounty": "Ada"},
)
subject = adapter.subject_for(event)
assert subject == "central.fire.incident.id.ada"
def test_subject_for_removal(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
event = Event(
id="test-id:removed:2024-01-01",
adapter="wfigs_incidents",
category="fire.incident.removed",
time=datetime.now(timezone.utc),
severity=0,
geo=Geo(),
data={"irwin_id": "test-id", "state": "ID"},
)
subject = adapter.subject_for(event)
assert subject == "central.fire.incident.removed.id"
@pytest.mark.asyncio
async def test_bbox_post_filter(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
"""Features outside bbox are filtered out."""
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
await adapter.startup()
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = AsyncMock(return_value=SAMPLE_INCIDENTS_RESPONSE)
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response), __aexit__=AsyncMock())):
events = [e async for e in adapter.poll()]
await adapter.shutdown()
# Florida incident should be filtered out
assert len(events) == 2
irwin_ids = {e.id for e in events}
assert "GUID-003-FLORIDA" not in irwin_ids
@pytest.mark.asyncio
async def test_apply_config_region_change(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
assert adapter.region.north == 49.0
new_config = AdapterConfig(
name="wfigs_incidents",
enabled=True,
cadence_s=300,
settings={
"region": {"north": 50.0, "south": 35.0, "east": -100.0, "west": -120.0}
},
updated_at=datetime.now(timezone.utc),
)
await adapter.apply_config(new_config)
assert adapter.region.north == 50.0
assert adapter.region.south == 35.0
class TestWFIGSPerimetersAdapter:
"""Tests for WFIGS Perimeters adapter."""
@pytest.fixture
def mock_config(self) -> AdapterConfig:
return AdapterConfig(
name="wfigs_perimeters",
enabled=True,
cadence_s=300,
settings={
"region": {"north": 49.0, "south": 31.0, "east": -102.0, "west": -124.0}
},
updated_at=datetime.now(timezone.utc),
)
@pytest.fixture
def mock_config_store(self) -> MagicMock:
return MagicMock()
@pytest.fixture
def cursor_db_path(self, tmp_path: Path) -> Path:
return tmp_path / "cursors.db"
@pytest.mark.asyncio
async def test_normalization_perimeters(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
"""Perimeters are correctly normalized to Events with geometry."""
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path)
await adapter.startup()
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = AsyncMock(return_value=SAMPLE_PERIMETERS_RESPONSE)
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response), __aexit__=AsyncMock())):
events = [e async for e in adapter.poll()]
await adapter.shutdown()
assert len(events) == 1
event = events[0]
assert event.id == "GUID-001-BOISE"
assert event.adapter == "wfigs_perimeters"
assert event.category == "fire.perimeter.wildfire"
assert event.geo.primary_region == "US-ID-ADA"
assert "geometry" in event.data
assert event.data["geometry"]["type"] == "Polygon"
def test_subject_for_perimeters(
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
):
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path)
event = Event(
id="test-id",
adapter="wfigs_perimeters",
category="fire.perimeter.wildfire",
time=datetime.now(timezone.utc),
severity=2,
geo=Geo(primary_region="US-ID-ADA"),
data={"POOState": "ID", "POOCounty": "Ada", "geometry": {}},
)
subject = adapter.subject_for(event)
assert subject == "central.fire.perimeter.id.ada"