central/tests/test_nws_normalization.py
Matt Johnson 31be17430d runtime: NWS adapter, supervisor, archive consumer, systemd units
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-15 21:29:08 +00:00

373 lines
11 KiB
Python

"""Tests for NWS adapter normalization."""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from central.adapters.nws import (
NWSAdapter,
_snake_case,
_parse_datetime,
_extract_states_from_codes,
_build_regions,
_compute_centroid,
_compute_bbox,
SEVERITY_MAP,
)
from central.config import NWSAdapterConfig
from central.models import subject_for_event
# Sample NWS GeoJSON features for testing
# SAME codes: 6 digits, first 2 are state FIPS (ID=16, OR=41, CA=06, WA=53)
SAMPLE_FEATURE_ID = {
"id": "urn:oid:2.49.0.1.840.0.a1b2c3d4e5f6",
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-116.5, 43.5],
[-116.0, 43.5],
[-116.0, 44.0],
[-116.5, 44.0],
[-116.5, 43.5],
]]
},
"properties": {
"id": "urn:oid:2.49.0.1.840.0.a1b2c3d4e5f6",
"event": "Severe Thunderstorm Warning",
"sent": "2026-05-15T12:00:00-06:00",
"expires": "2026-05-15T14:00:00-06:00",
"severity": "Severe",
"geocode": {
"SAME": ["160001"], # Idaho state FIPS 16
"UGC": ["IDC001", "IDZ033"],
},
},
}
SAMPLE_FEATURE_OR = {
"id": "urn:oid:2.49.0.1.840.0.x1y2z3w4",
"type": "Feature",
"geometry": None,
"properties": {
"id": "urn:oid:2.49.0.1.840.0.x1y2z3w4",
"event": "Winter Storm Warning",
"sent": "2026-05-15T08:00:00Z",
"expires": "2026-05-16T08:00:00Z",
"severity": "Moderate",
"geocode": {
"SAME": ["410051"], # Oregon state FIPS 41
"UGC": ["ORC051"],
},
},
}
SAMPLE_FEATURE_CA = {
"id": "urn:oid:2.49.0.1.840.0.ca1234",
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-118.25, 34.05],
},
"properties": {
"id": "urn:oid:2.49.0.1.840.0.ca1234",
"event": "Fire Weather Watch",
"sent": "2026-05-15T10:00:00-07:00",
"expires": "2026-05-16T18:00:00-07:00",
"severity": "Minor",
"geocode": {
"SAME": ["060037"], # California state FIPS 06
"UGC": ["CAZ568"],
},
},
}
SAMPLE_FEATURE_UNKNOWN_SEVERITY = {
"id": "urn:oid:2.49.0.1.840.0.unk123",
"type": "Feature",
"geometry": None,
"properties": {
"id": "urn:oid:2.49.0.1.840.0.unk123",
"event": "Test Alert",
"sent": "2026-05-15T12:00:00Z",
"expires": None,
"severity": "Unknown",
"geocode": {
"SAME": ["530033"], # Washington state FIPS 53
"UGC": ["WAC033"],
},
},
}
class TestSnakeCase:
"""Tests for snake_case conversion."""
def test_spaces_to_underscores(self) -> None:
assert _snake_case("Severe Thunderstorm Warning") == "severe_thunderstorm_warning"
def test_removes_special_chars(self) -> None:
assert _snake_case("Fire Weather (Red Flag)") == "fire_weather_red_flag"
def test_lowercase(self) -> None:
assert _snake_case("TORNADO WARNING") == "tornado_warning"
class TestParseDatetime:
"""Tests for datetime parsing."""
def test_iso_with_offset(self) -> None:
result = _parse_datetime("2026-05-15T12:00:00-06:00")
assert result is not None
assert result.tzinfo == timezone.utc
assert result.hour == 18 # 12:00 MDT = 18:00 UTC
def test_iso_with_z(self) -> None:
result = _parse_datetime("2026-05-15T12:00:00Z")
assert result is not None
assert result.hour == 12
def test_none_input(self) -> None:
assert _parse_datetime(None) is None
def test_invalid_input(self) -> None:
assert _parse_datetime("not a date") is None
class TestExtractStates:
"""Tests for state extraction from geocodes."""
def test_same_codes(self) -> None:
# Idaho FIPS is 16
states = _extract_states_from_codes(["160001", "160003"], [])
assert states == {"ID"}
def test_ugc_codes(self) -> None:
states = _extract_states_from_codes([], ["IDC001", "ORC051"])
assert states == {"ID", "OR"}
def test_combined(self) -> None:
# Idaho FIPS is 16
states = _extract_states_from_codes(["160001"], ["WAC033"])
assert states == {"ID", "WA"}
def test_empty(self) -> None:
states = _extract_states_from_codes([], [])
assert states == set()
class TestBuildRegions:
"""Tests for region string building."""
def test_same_to_fips_region(self) -> None:
# Idaho FIPS is 16
regions = _build_regions(["160001"], [])
assert "US-ID-FIPS160001" in regions
def test_ugc_county(self) -> None:
regions = _build_regions([], ["IDC001"])
assert "US-ID-C001" in regions
def test_ugc_zone(self) -> None:
regions = _build_regions([], ["IDZ033"])
assert "US-ID-Z033" in regions
def test_sorted_alphabetically(self) -> None:
regions = _build_regions(["160001"], ["IDC001", "IDZ033"])
assert regions == sorted(regions)
class TestStateFilter:
"""Tests for state filtering."""
@pytest.fixture
def adapter(self, tmp_path: Path) -> NWSAdapter:
"""Create adapter with ID/OR/WA states."""
config = NWSAdapterConfig(
enabled=True,
cadence_s=60,
states=["ID", "OR", "WA", "MT", "WY", "UT", "NV"],
contact_email="test@example.com",
)
return NWSAdapter(config, tmp_path / "test.db")
def test_accepts_id_feature(self, adapter: NWSAdapter) -> None:
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
assert event is not None
assert event.id == SAMPLE_FEATURE_ID["id"]
def test_accepts_or_feature(self, adapter: NWSAdapter) -> None:
event = adapter._normalize_feature(SAMPLE_FEATURE_OR)
assert event is not None
assert event.id == SAMPLE_FEATURE_OR["id"]
def test_rejects_ca_feature(self, adapter: NWSAdapter) -> None:
event = adapter._normalize_feature(SAMPLE_FEATURE_CA)
assert event is None
class TestSeverityMapping:
"""Tests for severity mapping."""
def test_extreme(self) -> None:
assert SEVERITY_MAP["Extreme"] == 4
def test_severe(self) -> None:
assert SEVERITY_MAP["Severe"] == 3
def test_moderate(self) -> None:
assert SEVERITY_MAP["Moderate"] == 2
def test_minor(self) -> None:
assert SEVERITY_MAP["Minor"] == 1
def test_unknown(self) -> None:
assert SEVERITY_MAP["Unknown"] is None
def test_unknown_severity_in_feature(self, tmp_path: Path) -> None:
config = NWSAdapterConfig(
enabled=True,
cadence_s=60,
states=["WA"],
contact_email="test@example.com",
)
adapter = NWSAdapter(config, tmp_path / "test.db")
event = adapter._normalize_feature(SAMPLE_FEATURE_UNKNOWN_SEVERITY)
assert event is not None
assert event.severity is None
class TestSubjectDerivation:
"""Tests for NATS subject derivation."""
@pytest.fixture
def adapter(self, tmp_path: Path) -> NWSAdapter:
config = NWSAdapterConfig(
enabled=True,
cadence_s=60,
states=["ID", "OR", "WA"],
contact_email="test@example.com",
)
return NWSAdapter(config, tmp_path / "test.db")
def test_county_subject(self, adapter: NWSAdapter) -> None:
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
assert event is not None
subject = subject_for_event(event)
# Primary region should be alphabetically first
# Could be county or zone depending on sort order
assert subject.startswith("central.wx.alert.us.id.")
def test_zone_subject(self, adapter: NWSAdapter) -> None:
# Create feature with only zone codes
feature = {
"id": "urn:test:zone",
"geometry": None,
"properties": {
"event": "Test Alert",
"sent": "2026-05-15T12:00:00Z",
"severity": "Minor",
"geocode": {
"SAME": [],
"UGC": ["IDZ033"],
},
},
}
event = adapter._normalize_feature(feature)
assert event is not None
subject = subject_for_event(event)
assert "zone" in subject
class TestRegionsSorted:
"""Tests for regions list sorting."""
@pytest.fixture
def adapter(self, tmp_path: Path) -> NWSAdapter:
config = NWSAdapterConfig(
enabled=True,
cadence_s=60,
states=["ID"],
contact_email="test@example.com",
)
return NWSAdapter(config, tmp_path / "test.db")
def test_regions_alphabetically_sorted(self, adapter: NWSAdapter) -> None:
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
assert event is not None
assert event.geo.regions == sorted(event.geo.regions)
def test_primary_region_is_first(self, adapter: NWSAdapter) -> None:
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
assert event is not None
assert len(event.geo.regions) > 0
assert event.geo.primary_region == event.geo.regions[0]
class TestDeduplication:
"""Tests for event deduplication."""
@pytest.fixture
def adapter(self, tmp_path: Path) -> NWSAdapter:
config = NWSAdapterConfig(
enabled=True,
cadence_s=60,
states=["ID"],
contact_email="test@example.com",
)
return NWSAdapter(config, tmp_path / "test.db")
def test_same_feature_same_id(self, adapter: NWSAdapter) -> None:
"""Normalizing the same feature twice returns same Event.id."""
event1 = adapter._normalize_feature(SAMPLE_FEATURE_ID)
event2 = adapter._normalize_feature(SAMPLE_FEATURE_ID)
assert event1 is not None
assert event2 is not None
assert event1.id == event2.id
class TestGeometry:
"""Tests for geometry computation."""
def test_centroid_polygon(self) -> None:
geom = {
"type": "Polygon",
"coordinates": [[
[-116.5, 43.5],
[-116.0, 43.5],
[-116.0, 44.0],
[-116.5, 44.0],
[-116.5, 43.5],
]]
}
centroid = _compute_centroid(geom)
assert centroid is not None
# Average of 5 vertices (including closing point)
# lon: (-116.5 + -116.0 + -116.0 + -116.5 + -116.5) / 5 = -116.3
# lat: (43.5 + 43.5 + 44.0 + 44.0 + 43.5) / 5 = 43.7
assert -116.4 < centroid[0] < -116.2
assert 43.6 < centroid[1] < 43.8
def test_bbox_polygon(self) -> None:
geom = {
"type": "Polygon",
"coordinates": [[
[-116.5, 43.5],
[-116.0, 43.5],
[-116.0, 44.0],
[-116.5, 44.0],
[-116.5, 43.5],
]]
}
bbox = _compute_bbox(geom)
assert bbox is not None
assert bbox == (-116.5, 43.5, -116.0, 44.0)
def test_centroid_none_geometry(self) -> None:
assert _compute_centroid(None) is None
def test_bbox_none_geometry(self) -> None:
assert _compute_bbox(None) is None