mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
v0.10.0: ITD 511 official API adapter (events + advisories + cameras) (#85)
First official-state-DOT-API pattern landing. Two adapters in one PR: - itd_511 (event-class): polls Events (60s) + Advisories (300s) from https://511.idaho.gov/api/v2/get/{event,alerts}. Decodes EncodedPolyline to LineString via the polyline lib (bookend LineString or Point fallback); ITD Severity string mapped None->1 / Minor->2 / Major->3 with IsFullClosure=true forcing 3 regardless; RecurrenceSchedules / Restrictions / DetourPolyline pass through unmodified. Advisories ship as structural pass-through under data.advisory since the upstream /alerts endpoint currently returns []; per-record try/except keeps a surprise shape from sinking the cycle when ITD posts its first one. - itd_511_cameras (telemetry-class): polls Cameras (600s). One event per camera per UTC day; image URL passes straight through to <img src>. Region uniform US-ID with data.source_jurisdiction preserving the raw upstream Source field for the ~1.2% cross-DOT border-region mirrors (UDOT / ODOT / WYDOT / WSDOT / NDot / MTD / DriveBC / Lemhi County). Subject convention (v0.9.20 forward): central.traffic.<event_type>.us.id and central.traffic_cameras.us.id.<camera_id>. Castle Rock state_511_atis keeps its bare-state subject; consumers stay on central.traffic.> wildcards during the A/B comparison window. Retry predicate tightened from the Castle Rock / TomTom precedent: 5xx + connection / timeout retry; 4xx other than 429 skip-with-warn (don't burn quota on permanent errors); 429 honors Retry-After once then retries. API key (alias 'idaho_511') travels in the ?key= query string, so every error log path runs through self._redact() to scrub the URL. Both adapters ship disabled; operator enables via GUI after registering the API key with 'python -m set_api_key idaho_511'. Reuses existing CENTRAL_TRAFFIC and CENTRAL_TRAFFIC_CAMERAS streams -- no archive restart needed. Scope-cap exception: this PR is ~1.5k lines vs. the standard 500-line cap, authorized as a one-time exception for the first official-state-DOT-API pattern landing. Two adapters + their tests + real-API fixtures naturally exceed the v0.9.x adapter-cap budget. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1f7bccaac6
commit
1d5548c24c
18 changed files with 1783 additions and 2 deletions
206
tests/test_itd_511_cameras.py
Normal file
206
tests/test_itd_511_cameras.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Tests for the itd_511_cameras adapter (v0.10.0).
|
||||
|
||||
Fixture covers 4 cameras: ITDNET, ACHD, UDOT (cross-border per v0.10.0
|
||||
finding 4), and an RWIS multi-view (to exercise the additional_views capture):
|
||||
tests/fixtures/itd_511_cameras_sample.json
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from central.adapter import SourceAdapter
|
||||
from central.adapters.itd_511 import _Transient
|
||||
from central.adapters.itd_511_cameras import NATIVE_SOURCES, Itd511CamerasAdapter
|
||||
from central.config_models import AdapterConfig
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
CAMS = json.loads((FIX / "itd_511_cameras_sample.json").read_text())
|
||||
|
||||
|
||||
def _cfg():
|
||||
return AdapterConfig(
|
||||
name="itd_511_cameras", enabled=True, cadence_s=600,
|
||||
settings={"api_key_alias": "idaho_511"},
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path):
|
||||
cs = MagicMock()
|
||||
cs.get_api_key = AsyncMock(return_value="testkey-32chars-deadbeefdeadbeef")
|
||||
return Itd511CamerasAdapter(_cfg(), cs, tmp_path / "cursors.db")
|
||||
|
||||
|
||||
def test_class_attributes_match_spec():
|
||||
assert Itd511CamerasAdapter.name == "itd_511_cameras"
|
||||
assert Itd511CamerasAdapter.data_class == "telemetry"
|
||||
assert Itd511CamerasAdapter.requires_api_key == "idaho_511"
|
||||
assert Itd511CamerasAdapter.default_cadence_s == 600
|
||||
|
||||
|
||||
def test_build_event_category_and_subject(adapter):
|
||||
e = adapter._build_event(CAMS[0])
|
||||
assert e.category == "camera.itd_511_cameras"
|
||||
assert e.adapter == "itd_511_cameras"
|
||||
assert adapter.subject_for(e) == f"central.traffic_cameras.us.id.{CAMS[0]['Id']}"
|
||||
|
||||
|
||||
def test_dedup_id_per_utc_day(adapter):
|
||||
e = adapter._build_event(CAMS[0])
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
assert e.id == f"idaho_511:cam:{CAMS[0]['Id']}:{today}"
|
||||
|
||||
|
||||
def test_image_url_passthrough(adapter):
|
||||
e = adapter._build_event(CAMS[0])
|
||||
assert e.data["image_url"] == CAMS[0]["Views"][0]["Url"]
|
||||
assert e.data["image_url"].startswith("https://511.idaho.gov/map/Cctv/")
|
||||
|
||||
|
||||
def test_source_jurisdiction_preserves_border_cameras(adapter):
|
||||
"""Per v0.10.0 finding 4: ITD aggregates ~1.2% cross-DOT mirrors (UDOT,
|
||||
ODOT, WYDOT, ...). Region stays US-ID; source_jurisdiction preserves the
|
||||
raw upstream Source value for downstream re-bucketing."""
|
||||
udot = next((c for c in CAMS if c["Source"] == "UDOT"), None)
|
||||
assert udot is not None, "fixture must include a UDOT cross-border camera"
|
||||
e = adapter._build_event(udot)
|
||||
assert e.data["source_jurisdiction"] == "UDOT"
|
||||
assert e.data["source"] == "UDOT"
|
||||
assert e.geo.primary_region == "US-ID" # uniform Idaho tagging per locked decision
|
||||
assert e.geo.regions == ["US-ID"]
|
||||
|
||||
|
||||
def test_multiple_views_captured(adapter):
|
||||
multi = next((c for c in CAMS if len(c.get("Views") or []) > 1), None)
|
||||
assert multi is not None, "fixture must include a multi-view camera"
|
||||
e = adapter._build_event(multi)
|
||||
assert e.data["view_count"] == len(multi["Views"])
|
||||
assert e.data["additional_views"] == [v["Url"] for v in multi["Views"][1:]]
|
||||
|
||||
|
||||
def test_single_view_has_empty_additional_views(adapter):
|
||||
single = next((c for c in CAMS if len(c.get("Views") or []) == 1), None)
|
||||
assert single is not None, "fixture must include a single-view camera"
|
||||
e = adapter._build_event(single)
|
||||
assert e.data["additional_views"] == []
|
||||
assert e.data["view_count"] == 1
|
||||
|
||||
|
||||
def test_build_event_returns_none_without_id(adapter):
|
||||
assert adapter._build_event({"Source": "ITDNET"}) is None
|
||||
|
||||
|
||||
def test_severity_is_1_for_telemetry(adapter):
|
||||
e = adapter._build_event(CAMS[0])
|
||||
assert e.severity == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_yields_one_event_per_camera(adapter):
|
||||
await adapter.startup()
|
||||
adapter._fetch_cameras = AsyncMock(return_value=CAMS)
|
||||
events = [e async for e in adapter.poll()]
|
||||
await adapter.shutdown()
|
||||
assert len(events) == len(CAMS)
|
||||
assert all(e.adapter == "itd_511_cameras" for e in events)
|
||||
assert all(e.category == "camera.itd_511_cameras" for e in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_skips_cleanly_without_api_key(tmp_path):
|
||||
cs = MagicMock()
|
||||
cs.get_api_key = AsyncMock(return_value=None)
|
||||
adapter = Itd511CamerasAdapter(_cfg(), cs, tmp_path / "cursors.db")
|
||||
await adapter.startup()
|
||||
events = [e async for e in adapter.poll()]
|
||||
await adapter.shutdown()
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_summary_partial_renders():
|
||||
from central.gui.routes import _derive_subject
|
||||
inner = {"camera_id": 1, "location": "I-15 UT/ID State Line UT", "roadway": "I-15"}
|
||||
row = {"adapter": "itd_511_cameras", "data": {"data": {"data": inner}}}
|
||||
assert _derive_subject(row) == "Camera: I-15 UT/ID State Line UT"
|
||||
|
||||
|
||||
def test_summary_partial_falls_back_to_id_when_location_missing():
|
||||
from central.gui.routes import _derive_subject
|
||||
inner = {"camera_id": 42}
|
||||
row = {"adapter": "itd_511_cameras", "data": {"data": {"data": inner}}}
|
||||
assert _derive_subject(row) == "Camera: #42"
|
||||
|
||||
|
||||
def test_inherits_dedup_mixin_from_source_adapter():
|
||||
for m in ("is_published", "mark_published", "sweep_old_ids"):
|
||||
assert m not in Itd511CamerasAdapter.__dict__, f"redefines {m}"
|
||||
assert getattr(Itd511CamerasAdapter, m) is getattr(SourceAdapter, m)
|
||||
|
||||
|
||||
# --- BUG E: poll() must catch _Transient (tenacity reraise after retries) ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_catches_transient_after_exhausted_retries(adapter):
|
||||
"""BUG E regression: cameras.poll() except tuple must include _Transient
|
||||
so tenacity's reraise of a persistent 5xx or 429 (after exhausted
|
||||
retries) does NOT crash the whole poll cycle."""
|
||||
await adapter.startup()
|
||||
|
||||
async def boom_transient():
|
||||
raise _Transient("503 persistent")
|
||||
|
||||
adapter._fetch_cameras = boom_transient
|
||||
events = [e async for e in adapter.poll()]
|
||||
await adapter.shutdown()
|
||||
assert events == [] # poll exited cleanly, didn't raise
|
||||
|
||||
|
||||
# --- BUG D2: NATIVE_SOURCES allow-list lives at the adapter, not the partial -
|
||||
|
||||
def test_native_sources_module_constant():
|
||||
assert NATIVE_SOURCES == frozenset({"ITDNET", "Idaho511", "ACHD", "RWIS"})
|
||||
|
||||
|
||||
def test_is_native_source_flag_set_per_camera(adapter):
|
||||
"""Border-region UDOT camera is non-native; ITDNET is native."""
|
||||
udot = next(c for c in CAMS if c["Source"] == "UDOT")
|
||||
itdnet = next(c for c in CAMS if c["Source"] == "ITDNET")
|
||||
assert adapter._build_event(udot).data["is_native_source"] is False
|
||||
assert adapter._build_event(itdnet).data["is_native_source"] is True
|
||||
|
||||
|
||||
def test_row_partial_does_not_hardcode_source_list(adapter):
|
||||
"""D2 regression: the cross-DOT-mirror annotation is driven by
|
||||
data.is_native_source — the partial must NOT carry the source allow-list
|
||||
itself ([[feedback_no_hardcoding]])."""
|
||||
from central.gui.routes import _get_templates
|
||||
tmpl = _get_templates().env.get_template("_event_rows/itd_511_cameras.html")
|
||||
udot_evt = adapter._build_event(next(c for c in CAMS if c["Source"] == "UDOT"))
|
||||
itdnet_evt = adapter._build_event(next(c for c in CAMS if c["Source"] == "ITDNET"))
|
||||
udot_html = tmpl.render(event={"data": {"data": {"data": udot_evt.data}}})
|
||||
itdnet_html = tmpl.render(event={"data": {"data": {"data": itdnet_evt.data}}})
|
||||
assert "(cross-DOT mirror)" in udot_html
|
||||
assert "(cross-DOT mirror)" not in itdnet_html
|
||||
# Audit: the partial source on disk must not contain the allow-list.
|
||||
partial = Path(__file__).resolve().parents[1] / (
|
||||
"src/central/gui/templates/_event_rows/itd_511_cameras.html"
|
||||
)
|
||||
text = partial.read_text()
|
||||
for name in NATIVE_SOURCES:
|
||||
assert name not in text, f"partial hardcodes {name}"
|
||||
|
||||
|
||||
# --- BUG D3: assert→if-raise on the cameras sibling --------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_session_unset_raises_runtime(adapter):
|
||||
"""D3 regression: asserts strip under python -O; the session-not-started
|
||||
precondition must hold even with optimizations."""
|
||||
assert adapter._session is None
|
||||
with pytest.raises(RuntimeError, match="session not started"):
|
||||
await adapter._fetch_cameras()
|
||||
Loading…
Add table
Add a link
Reference in a new issue