mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(notifications): Phase 2.10 avalanche adapter pipeline integration
Adds AvalancheAdapter.to_event(), wiring the avalanche.org map-layer
adapter into the notification EventBus, following the Phase 2.7 traffic /
2.9 USGS pattern.
to_event() design (emit only elevated danger):
- Category from danger_level: High/Extreme (4-5) -> avalanche_warning;
Considerable (3) -> avalanche_watch.
- Low/Moderate (1-2) and No-Rating (-1/0) have no distinct trend trigger
in this adapter and are intentionally NOT emitted (return None) -- the
two categories are warning/watch only, matching the spec.
- Severity: passed through unchanged from the adapter's danger mapping
(danger >= 4 -> priority, else routine; the adapter never emits
"immediate"). Severity tiering is delegated to the pipeline Inhibitor.
- Summary: headline + danger name + travel advice.
- group_key/inhibit_keys: the adapter's stable "avy_{center}_{zone}"
event_id as both. Re-polls of the same zone coalesce; single inhibit
key lets the Inhibitor suppress lower-severity re-emissions.
- Defensive: missing centroid (lat/lon), missing event_id, or missing
danger_level returns None; try/except-guarded.
No store.py change: the Phase 2.9 _emit_event None-guard already handles
to_event() returning None, and store gates emission on
hasattr(adapter, "to_event").
Rule 17: no new tunable. avalanche enabled / center_ids / season_months
already exist in env_feeds.yaml (GUI-editable). Rule 18 N/A -- the
avalanche.org v2 public map-layer API is keyless (no .env entry; the
.ref credentials store has no avalanche provider key, confirming none is
needed). Rule 16: standalone fetch path validated in-container below.
Config note: avalanche was already enabled (center_ids: [SNFAC], the
Sawtooth Avalanche Center -- the correct South Central Idaho / Magic
Valley center). It was already one of the 7 live adapters, so this phase
keeps the count at 7 (no 7->8 change) and required no env_feeds.yaml
edit. There is no per-zone config knob; the adapter fetches all zones for
the configured center.
Tests: tests/test_adapter_avalanche.py (14 tests) mirrors
test_adapter_usgs -- category split (warning vs watch), severity
pass-through, group_key/inhibit_keys, distinct-zone keys, field
population, and the non-emit/defensive cases (low/moderate -> None,
no-rating -> None, missing danger_level/centroid/event_id -> None,
corrupted -> None). Full suite: 188 passed.
Live smoke test (prod container, Phase 2.10 code rebuilt in): clean
startup, 7 env adapters loaded, no traceback. Late May is off-season
(season_months [12,1,2,3,4]) so tick() short-circuits in normal
operation. To exercise the open-API path, a one-shot standalone fetch was
run in-container with an all-months config against center SNFAC: health
is_loaded=true, last_error=null, consecutive_errors=0, last_fetch set,
off_season=false -- the fetch reached api.avalanche.org with no DNS/auth
errors (Phase 2.6.6 DNS fix). event_count=0 because all SNFAC zones are
server-side off_season in late May, so no Event is emitted -- acceptable
per the seasonal caveat. The temporary season_months edit was reverted
and the container restarted on the real config (7 adapters, healthy). The
emission path (elevated -> avalanche_warning / avalanche_watch) is
unit-validated and is the same store->bus path emitting live for NWS and
traffic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4feb6a1895
commit
1d35188b98
2 changed files with 272 additions and 1 deletions
196
tests/test_adapter_avalanche.py
Normal file
196
tests/test_adapter_avalanche.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Tests for avalanche adapter Phase 2.10 — to_event() method."""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.env.avalanche import AvalancheAdapter
|
||||
from meshai.notifications.events import Event
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock AvalancheConfig with real scalar fields."""
|
||||
config = MagicMock()
|
||||
config.center_ids = ["SNFAC"]
|
||||
config.tick_seconds = 1800
|
||||
config.season_months = [12, 1, 2, 3, 4]
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(mock_config):
|
||||
"""Create an AvalancheAdapter with mocked config."""
|
||||
return AvalancheAdapter(mock_config)
|
||||
|
||||
|
||||
def make_avy_event(
|
||||
center_id="SNFAC",
|
||||
zone_name="Banner Summit",
|
||||
danger_level=4,
|
||||
danger_name="High",
|
||||
severity="priority",
|
||||
travel_advice="Dangerous avalanche conditions.",
|
||||
lat=44.3,
|
||||
lon=-115.2,
|
||||
headline=None,
|
||||
):
|
||||
"""Helper to create a stored avalanche event dict (mirrors _fetch)."""
|
||||
now = time.time()
|
||||
if headline is None:
|
||||
headline = f"{zone_name}: {danger_name} avalanche danger"
|
||||
if travel_advice:
|
||||
headline += f" -- {travel_advice[:100]}"
|
||||
return {
|
||||
"source": "avalanche",
|
||||
"event_id": f"avy_{center_id}_{zone_name.replace(' ', '_').lower()}",
|
||||
"event_type": "Avalanche Advisory",
|
||||
"severity": severity,
|
||||
"headline": headline,
|
||||
"zone_name": zone_name,
|
||||
"center": "Sawtooth Avalanche Center",
|
||||
"center_id": center_id,
|
||||
"center_link": "https://www.sawtoothavalanche.com",
|
||||
"forecast_link": "https://www.sawtoothavalanche.com/forecast",
|
||||
"danger": danger_name.lower(),
|
||||
"danger_level": danger_level,
|
||||
"danger_name": danger_name,
|
||||
"travel_advice": travel_advice,
|
||||
"state": "ID",
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"expires": now + 3600,
|
||||
"fetched_at": now,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CATEGORY TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_high_and_extreme_are_warning(adapter):
|
||||
"""Danger level 4 (High) and 5 (Extreme) map to avalanche_warning."""
|
||||
for level, name in [(4, "High"), (5, "Extreme")]:
|
||||
event = adapter.to_event(make_avy_event(danger_level=level, danger_name=name))
|
||||
assert event is not None
|
||||
assert event.category == "avalanche_warning"
|
||||
|
||||
|
||||
def test_considerable_is_watch(adapter):
|
||||
"""Danger level 3 (Considerable) maps to avalanche_watch."""
|
||||
event = adapter.to_event(
|
||||
make_avy_event(danger_level=3, danger_name="Considerable", severity="routine")
|
||||
)
|
||||
assert event is not None
|
||||
assert event.category == "avalanche_watch"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SEVERITY PASS-THROUGH TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_severity_passes_through(adapter):
|
||||
"""Severity from the stored event passes through unchanged."""
|
||||
for sev in ["routine", "priority", "immediate"]:
|
||||
event = adapter.to_event(make_avy_event(severity=sev, danger_level=4))
|
||||
assert event is not None
|
||||
assert event.severity == sev
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GROUP KEY / INHIBIT KEY TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_group_key_is_event_id(adapter):
|
||||
"""Group key is the stable avy_{center}_{zone} key."""
|
||||
event = adapter.to_event(make_avy_event(center_id="SNFAC", zone_name="Banner Summit"))
|
||||
assert event is not None
|
||||
assert event.group_key == "avy_SNFAC_banner_summit"
|
||||
|
||||
|
||||
def test_inhibit_keys_match_group_key(adapter):
|
||||
"""The sole inhibit key equals the group key (Inhibitor does severity tiering)."""
|
||||
event = adapter.to_event(make_avy_event())
|
||||
assert event is not None
|
||||
assert event.inhibit_keys == [event.group_key]
|
||||
|
||||
|
||||
def test_distinct_zones_get_distinct_keys(adapter):
|
||||
"""Two zones in the same center get distinct group keys."""
|
||||
e1 = adapter.to_event(make_avy_event(zone_name="Banner Summit"))
|
||||
e2 = adapter.to_event(make_avy_event(zone_name="Galena Summit"))
|
||||
assert e1.group_key != e2.group_key
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CONTENT / FIELD POPULATION TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_populates_core_fields(adapter):
|
||||
"""Core Event fields are populated from the stored dict."""
|
||||
evt = make_avy_event(lat=44.31, lon=-115.22)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.source == "avalanche"
|
||||
assert event.lat == 44.31
|
||||
assert event.lon == -115.22
|
||||
assert event.expires == evt["expires"]
|
||||
assert event.timestamp == evt["fetched_at"]
|
||||
assert event.id # auto-computed
|
||||
|
||||
|
||||
def test_summary_includes_danger_and_advice(adapter):
|
||||
"""Summary includes the danger name and travel advice."""
|
||||
event = adapter.to_event(
|
||||
make_avy_event(danger_name="Extreme", danger_level=5, travel_advice="Avoid all terrain.")
|
||||
)
|
||||
assert event is not None
|
||||
assert "Extreme" in event.summary
|
||||
assert "Avoid all terrain." in event.summary
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DEFENSIVE / NON-EMIT TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_low_danger_returns_none(adapter):
|
||||
"""Low/Moderate (1-2) danger is not actionable, returns None."""
|
||||
for level, name in [(1, "Low"), (2, "Moderate")]:
|
||||
assert adapter.to_event(make_avy_event(danger_level=level, danger_name=name)) is None
|
||||
|
||||
|
||||
def test_no_rating_returns_none(adapter):
|
||||
"""A no-rating (-1/0) advisory returns None."""
|
||||
for level in [-1, 0]:
|
||||
assert adapter.to_event(make_avy_event(danger_level=level, danger_name="No Rating")) is None
|
||||
|
||||
|
||||
def test_missing_danger_level_returns_none(adapter):
|
||||
"""Missing danger_level returns None."""
|
||||
evt = make_avy_event()
|
||||
evt["danger_level"] = None
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
def test_missing_centroid_returns_none(adapter):
|
||||
"""Missing centroid (lat/lon) returns None."""
|
||||
evt = make_avy_event()
|
||||
evt["lat"] = None
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
def test_missing_event_id_returns_none(adapter):
|
||||
"""Missing event_id returns None (no stable group key)."""
|
||||
evt = make_avy_event()
|
||||
evt["event_id"] = None
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
def test_does_not_raise_on_corrupted_dict(adapter):
|
||||
"""Corrupted dict returns None without raising."""
|
||||
assert adapter.to_event({"garbage": True}) is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue