mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
373 lines
11 KiB
Python
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
|