"""Tests for NWS adapter normalization.""" from datetime import datetime, timezone from pathlib import Path import sqlite3 from unittest.mock import MagicMock import pytest from central.adapters.nws import ( NWSAdapter, _snake_case, _parse_datetime, _build_regions, _compute_centroid, _compute_bbox, SEVERITY_MAP, ) from central.config_models import AdapterConfig # Sample NWS GeoJSON features for testing # SAME codes are 6 digits in PSSCCC form (P=area-type indicator, SS=state # FIPS, CCC=county FIPS). ID=16, OR=41, CA=06, WA=53 — so the SAME values # below use the standard 0SSCCC form (no area-subset, full region). 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": ["016001"], # Idaho (state FIPS 16) county 001 (Ada) "UGC": ["IDC001", "IDZ033"], }, }, } SAMPLE_FEATURE_OR = { "id": "urn:oid:2.49.0.1.840.0.x1y2z3w4", "type": "Feature", "geometry": {"type": "Point", "coordinates": [-122.7, 45.5]}, # Portland, OR "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": ["041051"], # Oregon (state FIPS 41) county 051 (Multnomah) "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": ["006037"], # California (state FIPS 06) county 037 (Los Angeles) "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": ["053033"], # Washington (state FIPS 53) county 033 (King) "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 TestBuildRegions: """Tests for region string building.""" def test_same_to_fips_region(self) -> None: # SAME 016001 = P=0, SS=16 (Idaho), CCC=001 (Ada County); # emitted region uses the 5-digit ANSI county FIPS (drops P). regions = _build_regions(["016001"], []) assert "US-ID-FIPS16001" 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(["016001"], ["IDC001", "IDZ033"]) assert regions == sorted(regions) class TestSameStateParse: """v0.10.7 regression guard: SAME PSSCCC parsing. The pre-v0.10.7 ``_build_regions`` read ``code[:2]`` (``PS``) as the state FIPS, so for ``P=0`` (the common case) every SAME code parsed as Alabama (``01``) regardless of the real state. Fix slices ``code[1:3]`` for the state and emits the 5-digit ANSI county FIPS (``code[1:]``). """ @pytest.mark.parametrize("same_code, expected_region", [ ("016005", "US-ID-FIPS16005"), # Bannock County, Idaho ("001005", "US-AL-FIPS01005"), # Autauga area, Alabama ("056005", "US-WY-FIPS56005"), # Carbon area, Wyoming ("049005", "US-UT-FIPS49005"), # Cache area, Utah ]) def test_state_derived_from_positions_1_2( self, same_code: str, expected_region: str ) -> None: """Real SAME codes from four distinct states parse to the correct state.""" regions = _build_regions([same_code], []) assert expected_region in regions assert len(regions) == 1 def test_area_subset_indicator_is_dropped_from_emitted_region(self) -> None: """SAME with P>=1 (designated portion) parses the same state but the emitted 5-digit county FIPS drops the P digit -- area-subset info lives upstream in ``data.geocode.SAME`` for power users.""" # 116005: P=1 (subset of region), SS=16 (Idaho), CCC=005 (Bannock) regions = _build_regions(["116005"], []) assert "US-ID-FIPS16005" in regions @pytest.mark.parametrize("malformed", [ "", # empty "01", # too short "0160", # too short "0160050", # too long (7 digits) "016X05", # non-digit char "abcdef", # all alpha None, # missing entry ]) def test_malformed_same_is_silently_skipped(self, malformed) -> None: """Garbage in SAME never crashes and never produces a region.""" regions = _build_regions([malformed], []) assert regions == [] def test_unknown_state_fips_is_silently_skipped(self) -> None: """SAME with valid format but unrecognized state FIPS produces nothing.""" # SS=99 is not a real state FIPS regions = _build_regions(["099001"], []) assert regions == [] def test_mixed_good_and_malformed(self) -> None: """Valid entries still emit when malformed ones are present in the list.""" regions = _build_regions(["016001", "", "049005", "abc"], []) assert "US-ID-FIPS16001" in regions assert "US-UT-FIPS49005" in regions assert len(regions) == 2 class TestStateFilter: """Tests for region-based filtering.""" @pytest.fixture def adapter(self, tmp_path: Path) -> NWSAdapter: """Create adapter with Pacific Northwest region (excludes CA).""" config = AdapterConfig( name="nws", enabled=True, cadence_s=60, settings={ "contact_email": "test@example.com", # Pacific NW region: WA/OR/ID - excludes CA (LA at 34N, region starts at 42N) "region": {"north": 49.0, "south": 42.0, "east": -104.0, "west": -125.0}, }, updated_at=datetime.now(timezone.utc), ) mock_config_store = MagicMock() return NWSAdapter(config, mock_config_store, 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 = AdapterConfig( name="nws", enabled=True, cadence_s=60, settings={ "contact_email": "test@example.com", # No region = accept all features }, updated_at=datetime.now(timezone.utc), ) mock_config_store = MagicMock() adapter = NWSAdapter(config, mock_config_store, 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 = AdapterConfig( name="nws", enabled=True, cadence_s=60, settings={ "contact_email": "test@example.com", # No region = accept all features }, updated_at=datetime.now(timezone.utc), ) mock_config_store = MagicMock() return NWSAdapter(config, mock_config_store, 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 = adapter.subject_for(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 = adapter.subject_for(event) assert "zone" in subject class TestRegionsSorted: """Tests for regions list sorting.""" @pytest.fixture def adapter(self, tmp_path: Path) -> NWSAdapter: config = AdapterConfig( name="nws", enabled=True, cadence_s=60, settings={ "contact_email": "test@example.com", # No region = accept all features }, updated_at=datetime.now(timezone.utc), ) mock_config_store = MagicMock() return NWSAdapter(config, mock_config_store, 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 = AdapterConfig( name="nws", enabled=True, cadence_s=60, settings={ "contact_email": "test@example.com", # No region = accept all features }, updated_at=datetime.now(timezone.utc), ) mock_config_store = MagicMock() return NWSAdapter(config, mock_config_store, 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 def test_sweep_only_deletes_own_adapter_rows( self, adapter: NWSAdapter, tmp_path: Path ) -> None: """Regression (v0.9.19.1): sweep_old_ids must be adapter-scoped. NWS previously ran an unscoped global DELETE that purged *every* adapter's published_ids older than 8 days; the inherited base method scopes the delete to ``adapter = ?``. """ adapter._db = sqlite3.connect(tmp_path / "dedup.db") adapter._db.execute( "CREATE TABLE published_ids (adapter TEXT, event_id TEXT, " "first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " "last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " "PRIMARY KEY (adapter, event_id))" ) for adp in ("nws", "eonet"): adapter._db.execute( "INSERT INTO published_ids (adapter, event_id, last_seen) " "VALUES (?, 'old', datetime('now', '-9 days'))", (adp,), ) adapter._db.commit() assert adapter.dedup_sweep_days == 8 assert adapter.sweep_old_ids() == 1 # only the nws row survivors = { r[0] for r in adapter._db.execute("SELECT adapter FROM published_ids") } assert survivors == {"eonet"} # foreign adapter's row survives 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