mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
feat(state_511_atis): Castle Rock 511 adapter — Idaho incidents/closures/road work (v0.9.2)
Second CENTRAL_TRAFFIC adapter. Production code; central-supervisor + central-gui
restart (new adapter class + ADAPTER_GROUPS). No new stream -> no archive restart;
migration 026 adds the adapter row only. Ships disabled.
Two-endpoint join per layer: GET /map/mapIcons/<Layer> (markers: itemId + coords)
joined on id with POST /List/GetData/<Layer> (DataTables detail: roadwayName,
description, county, severity). The marker feed has coords but no text; the List
feed has text but no coords.
Layers -> event_types (wzdx category/subject precedent): Incidents->incident,
Closures->closure, Construction (type "Roadwork")->work_zone. category is
"<event_type>.state_511_atis"; subject central.traffic.<event_type>.<state>.
Severity 3 if isFullClosure else 1. Cadence 300s. Dedup inherited from the
v0.9.1 SourceAdapter mixin. enrichment_locations canonical (latitude,longitude)
from the marker join; county/state come upstream.
Templatized per state via settings {"states":[{code,base_url}]} but ships
Idaho-only: cross-state spot-checks refuted the shared-URL hypothesis (Oregon
TripCheck is HTML, Wyoming wyoroad 404 -- neither is Castle Rock). Add states as
settings rows once each host is verified.
Also fixes a latent test bug: test_consumer_doc per-adapter heading regex was
[a-z_]+ (no digits); state_511_atis is the first adapter name with digits, so
widened to [a-z0-9_]+.
Full suite: 759 passed, 1 skipped (central and unprivileged zvx, 3x each).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
efb2a5799d
commit
30e25bf475
15 changed files with 493 additions and 2 deletions
1
tests/fixtures/state_511_atis_closures.json
vendored
Normal file
1
tests/fixtures/state_511_atis_closures.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"data":[{"DT_RowId":"17","tooltipUrl":"/tooltip/Closures/17?lang=%7Blang%7D&noCss=true","id":17,"type":"Closures","layerName":"Closures","roadwayName":"N McDermott Rd","description":" Long term road construction on N McDermott Rd Both Directions from Five Mile Creek to US-20. All lanes closed. 1/30/2023 2:24 PM Mon, Tue, Wed, Thu, Fri, Sat, Sun: Active all day<div class='cellSpacer'><i><b>Comments:</b></i> Open to local traffic only.</div>","sourceId":"469","source":"ERS","comment":"Open to local traffic only.","eventSubType":"longTermRoadConstruction","startDate":"1/30/23, 2:24 PM","endDate":null,"lastUpdated":"6/18/24, 6:48 PM","isFullClosure":true,"severity":"None","direction":"Both","locationDescription":"Five Mile Creek | US-20","detourDescription":null,"laneDescription":"All lanes closed","recurrenceDescription":"<b>Mon, Tue, Wed, Thu, Fri, Sat, Sun:</b><br/>Active all day<br/><br/>","widthRestriction":null,"heightRestriction":null,"heightUnderRestriction":null,"lengthRestriction":null,"weightRestriction":null,"majorEvent":null,"county":"Ada","region":"Idaho Falls/Pocatello","state":"Idaho","country":"United States","showOnMap":true,"restrictions":"Width Restriction: <br>Height Restriction: <br>Length Restriction: <br>Weight Restriction: <br>Speed Restriction: <br>"}]}
|
||||
1
tests/fixtures/state_511_atis_construction.json
vendored
Normal file
1
tests/fixtures/state_511_atis_construction.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"data":[{"DT_RowId":"23","tooltipUrl":"/tooltip/Construction/23?lang=%7Blang%7D&noCss=true","id":23,"type":"Roadwork","layerName":"Construction","roadwayName":"SH-81","description":" Work on the shoulder on SH-81 near Poverty Gulch. 7/8/2024 9:29 PM Mon: 12:00 PM - 5:00 PM, Tue, Wed, Thu, Fri, Sat, Sun: Active all day Activities: use caution, warning.","sourceId":"4277","source":"ERS","comment":null,"eventSubType":"workOnTheShoulder","startDate":"7/8/24, 9:29 PM","endDate":null,"lastUpdated":"6/11/25, 3:02 PM","isFullClosure":false,"severity":"None","direction":"Unknown","locationDescription":"Poverty Gulch","detourDescription":null,"laneDescription":"","recurrenceDescription":"<b>Mon:</b><br/>12:00 PM - 5:00 PM<br/><br/><b>Tue, Wed, Thu, Fri, Sat, Sun:</b><br/>Active all day<br/><br/>","widthRestriction":null,"heightRestriction":null,"heightUnderRestriction":null,"lengthRestriction":null,"weightRestriction":null,"majorEvent":null,"county":"Cassia","region":"Idaho Falls/Pocatello","state":"Idaho","country":"United States","showOnMap":true,"restrictions":"Width Restriction: <br>Height Restriction: <br>Length Restriction: <br>Weight Restriction: <br>Speed Restriction: <br>"}]}
|
||||
1
tests/fixtures/state_511_atis_incidents.json
vendored
Normal file
1
tests/fixtures/state_511_atis_incidents.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"data":[{"DT_RowId":"33579","tooltipUrl":"/tooltip/Incidents/33579?lang=%7Blang%7D&noCss=true","id":33579,"type":"Incidents","layerName":"Incidents","roadwayName":"US-95","description":" Roadway Blocked on US-95 Both Directions near MM (469). All lanes blocked. Activities: Expect Delays, Reduced to Single Lane, Alternating Direction of Travel, Use Caution.<div class='cellSpacer'><i><b>Comments:</b></i> Milepost 469, roadway blocked. Expect delays. Use caution.</div>","sourceId":"10991","source":"ERS","comment":"Milepost 469, roadway blocked. Expect delays. Use caution.","eventSubType":"roadwayBlocked","startDate":"5/25/26, 2:32 PM","endDate":null,"lastUpdated":"5/25/26, 3:40 PM","isFullClosure":false,"severity":"None","direction":"Both","locationDescription":"Ponderosa Mobile Home Park","detourDescription":null,"laneDescription":"All lanes blocked","recurrenceDescription":null,"widthRestriction":null,"heightRestriction":null,"heightUnderRestriction":null,"lengthRestriction":null,"weightRestriction":null,"majorEvent":null,"county":"Bonner","region":"Idaho Falls/Pocatello","state":"Idaho","country":"United States","showOnMap":true,"restrictions":"Width Restriction: <br>Height Restriction: <br>Length Restriction: <br>Weight Restriction: <br>Speed Restriction: <br>"}]}
|
||||
1
tests/fixtures/state_511_atis_markers_closures.json
vendored
Normal file
1
tests/fixtures/state_511_atis_markers_closures.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"item2":[{"polyline":{"path":"qbliGhv{eUsk@MeTEsBAsAGiAQw@Qi@MoAYmA]aA]}@a@[?[JSNKNENCTEzCGnAIX","color":"#CC0004"},"itemId":"17","location":[43.6485700000001,-116.47349],"icon":{"url":"/Generated/Content/Images/511/map_closure.svg"},"title":""}]}
|
||||
1
tests/fixtures/state_511_atis_markers_construction.json
vendored
Normal file
1
tests/fixtures/state_511_atis_markers_construction.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"item2":[{"itemId":"23","location":[42.5168038430856,-113.711287649613],"icon":{},"title":""}]}
|
||||
1
tests/fixtures/state_511_atis_markers_incidents.json
vendored
Normal file
1
tests/fixtures/state_511_atis_markers_incidents.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"item2":[{"itemId":"33579","location":[48.2055675659533,-116.563364000498],"icon":{},"title":""}]}
|
||||
|
|
@ -59,7 +59,7 @@ def _per_adapter_subsections(doc: str) -> list[str]:
|
|||
assert m, "doc missing '## 6. Per-adapter reference' section"
|
||||
section = m.group(1)
|
||||
|
||||
heading_re = re.compile(r"^### ([a-z_]+) — ", re.MULTILINE)
|
||||
heading_re = re.compile(r"^### ([a-z0-9_]+) — ", re.MULTILINE)
|
||||
return heading_re.findall(section)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1143,6 +1143,7 @@ _SAMPLE_INNER = {
|
|||
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
|
||||
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
|
||||
"wzdx": {"road_names": ["I-80"], "direction": "eastbound"},
|
||||
"state_511_atis": {"layer": "Incidents", "roadway_name": "US-95", "location_description": "Ponderosa Mobile Home Park"},
|
||||
}
|
||||
|
||||
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
||||
|
|
|
|||
133
tests/test_state_511_atis.py
Normal file
133
tests/test_state_511_atis.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Tests for the state_511_atis adapter (Castle Rock ATIS, Idaho).
|
||||
|
||||
Fixtures are real captures (one record + its matching marker per layer):
|
||||
state_511_atis_<layer>.json -- POST /List/GetData/<Layer> .data[0:1]
|
||||
state_511_atis_markers_<layer>.json -- GET /map/mapIcons/<Layer> matching item2
|
||||
|
||||
No tests/conftest isolation entry is added: dedup uses the supervisor-injected
|
||||
cursors.db (inherited mixin) and discovery is stateless -- no adapter-owned cache.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from central.adapter import SourceAdapter
|
||||
from central.adapters.state_511_atis import (
|
||||
LAYER_EVENT_TYPE,
|
||||
State511ATISAdapter,
|
||||
_parse_us_dt,
|
||||
)
|
||||
from central.config_models import AdapterConfig
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
DETAIL = {lyr: json.loads((FIX / f"state_511_atis_{lyr.lower()}.json").read_text())["data"][0]
|
||||
for lyr in ("Incidents", "Closures", "Construction")}
|
||||
MARK = {lyr: json.loads((FIX / f"state_511_atis_markers_{lyr.lower()}.json").read_text())["item2"][0]
|
||||
for lyr in ("Incidents", "Closures", "Construction")}
|
||||
|
||||
|
||||
def _coords(layer):
|
||||
loc = MARK[layer]["location"]
|
||||
return (loc[0], loc[1])
|
||||
|
||||
|
||||
def _cfg():
|
||||
return AdapterConfig(
|
||||
name="state_511_atis", enabled=True, cadence_s=300,
|
||||
settings={"states": [{"code": "ID", "base_url": "https://511.idaho.gov"}]},
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path):
|
||||
return State511ATISAdapter(_cfg(), MagicMock(), tmp_path / "cursors.db")
|
||||
|
||||
|
||||
def test_layer_event_type_map():
|
||||
assert LAYER_EVENT_TYPE == {"Incidents": "incident", "Closures": "closure", "Construction": "work_zone"}
|
||||
|
||||
|
||||
def test_parse_us_dt():
|
||||
assert _parse_us_dt("5/25/26, 2:32 PM") == datetime(2026, 5, 25, 14, 32, tzinfo=timezone.utc)
|
||||
assert _parse_us_dt("") is None
|
||||
assert _parse_us_dt("not a date") is None
|
||||
|
||||
|
||||
def test_dedup_key(adapter):
|
||||
e = adapter._build_event(DETAIL["Incidents"], _coords("Incidents"), "ID", "Incidents")
|
||||
assert e.id == "ID:Incidents:33579"
|
||||
|
||||
|
||||
def test_build_incident(adapter):
|
||||
e = adapter._build_event(DETAIL["Incidents"], _coords("Incidents"), "ID", "Incidents")
|
||||
assert e.category == "incident.state_511_atis"
|
||||
assert e.severity == 1
|
||||
assert e.data["roadway_name"] == "US-95"
|
||||
assert e.data["county"] == "Bonner"
|
||||
assert e.data["latitude"] is not None and e.data["longitude"] is not None
|
||||
|
||||
|
||||
def test_build_closure_full_closure_severity(adapter):
|
||||
e = adapter._build_event(DETAIL["Closures"], _coords("Closures"), "ID", "Closures")
|
||||
assert e.category == "closure.state_511_atis"
|
||||
assert e.data["is_full_closure"] is True
|
||||
assert e.severity == 3 # isFullClosure -> 3
|
||||
|
||||
|
||||
def test_build_construction_maps_to_work_zone(adapter):
|
||||
e = adapter._build_event(DETAIL["Construction"], _coords("Construction"), "ID", "Construction")
|
||||
assert e.category == "work_zone.state_511_atis" # layer Construction, type "Roadwork"
|
||||
assert e.severity == 1
|
||||
assert e.data["roadway_name"] == "SH-81"
|
||||
|
||||
|
||||
def test_join_missing_coords(adapter):
|
||||
e = adapter._build_event(DETAIL["Incidents"], None, "ID", "Incidents")
|
||||
assert e.data["latitude"] is None and e.data["longitude"] is None
|
||||
assert e.geo.centroid is None # still built, just no map point
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layer,et", [("Incidents", "incident"), ("Closures", "closure"), ("Construction", "work_zone")])
|
||||
def test_subject_for(adapter, layer, et):
|
||||
e = adapter._build_event(DETAIL[layer], _coords(layer), "ID", layer)
|
||||
assert adapter.subject_for(e) == f"central.traffic.{et}.id"
|
||||
|
||||
|
||||
def test_summary_partial_renders():
|
||||
from central.gui.routes import _derive_subject
|
||||
inner = {"layer": "Incidents", "roadway_name": "US-95", "location_description": "Ponderosa Mobile Home Park"}
|
||||
row = {"adapter": "state_511_atis", "data": {"data": {"data": inner}}}
|
||||
assert _derive_subject(row) == "Incident on US-95 — Ponderosa Mobile Home Park"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_joins_and_yields(adapter):
|
||||
await adapter.startup()
|
||||
|
||||
async def fake_markers(base_url, layer):
|
||||
m = MARK[layer]
|
||||
return {str(m["itemId"]): (m["location"][0], m["location"][1])}
|
||||
|
||||
async def fake_details(base_url, layer):
|
||||
return [DETAIL[layer]]
|
||||
|
||||
adapter._fetch_markers = fake_markers
|
||||
adapter._fetch_details = fake_details
|
||||
events = [e async for e in adapter.poll()]
|
||||
await adapter.shutdown()
|
||||
assert len(events) == 3 # one ID state x three layers
|
||||
assert {e.category for e in events} == {
|
||||
"incident.state_511_atis", "closure.state_511_atis", "work_zone.state_511_atis",
|
||||
}
|
||||
assert all(e.adapter == "state_511_atis" for e in events)
|
||||
|
||||
|
||||
def test_inherits_dedup_mixin():
|
||||
for m in ("is_published", "mark_published", "sweep_old_ids"):
|
||||
assert m not in State511ATISAdapter.__dict__, f"redefines {m}"
|
||||
assert getattr(State511ATISAdapter, m) is getattr(SourceAdapter, m)
|
||||
Loading…
Add table
Add a link
Reference in a new issue