mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix: WZDx registry nested-url crash + composer "None" leak (dry-run findings) (#42)
Two bugs found running native adapters against real upstreams:
- env/wzdx.py: _select_feeds() called .strip() on the registry url field,
but Socrata "URL"-column values arrive as {"url": "..."} — crashed ALL
native WZDx discovery with AttributeError. Added _unwrap_url() (dict/str/
None-robust), applied to url/apiurl/feed_url.
- composer._context_segment: appended optional fields by key presence, so
cause: None (set by native road adapters) leaked literal "None" onto the
wire. Guard on value (cause/expires_at truthiness; containment_pct is-not-
None so 0% still renders). Legacy Mode-B path — golden tests unchanged.
+3 regression tests; golden/composer suites pass; full suite 10-failure
baseline (1685 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6595b10bdd
commit
63245b8fba
4 changed files with 71 additions and 4 deletions
21
work/meshai/env/wzdx.py
vendored
21
work/meshai/env/wzdx.py
vendored
|
|
@ -75,6 +75,23 @@ _US_STATES = {
|
|||
}
|
||||
|
||||
|
||||
def _unwrap_url(v) -> str:
|
||||
"""Coerce a registry URL field to a clean string.
|
||||
|
||||
Socrata's "URL" column type serializes as a nested object
|
||||
(``{"url": "https://…"}``) — e.g. Idaho's row in the live FHWA registry —
|
||||
while plain-text columns come back as a bare string. Unwrap the nested
|
||||
form, pass strings through, and treat anything else (None, numbers) as
|
||||
empty so callers never trip over ``.strip()``.
|
||||
"""
|
||||
if isinstance(v, dict):
|
||||
inner = v.get("url")
|
||||
return inner.strip() if isinstance(inner, str) else ""
|
||||
if isinstance(v, str):
|
||||
return v.strip()
|
||||
return ""
|
||||
|
||||
|
||||
class WZDxAdapter:
|
||||
"""FHWA WZDx work-zone polling adapter (native ``work_zone`` source)."""
|
||||
|
||||
|
|
@ -199,7 +216,9 @@ class WZDxAdapter:
|
|||
fmt = str(row.get("format") or "").strip().lower()
|
||||
if fmt and "geojson" not in fmt:
|
||||
continue # only GeoJSON WZDx feeds; skip xml/other
|
||||
url = (row.get("url") or row.get("apiurl") or row.get("feed_url") or "").strip()
|
||||
url = (_unwrap_url(row.get("url"))
|
||||
or _unwrap_url(row.get("apiurl"))
|
||||
or _unwrap_url(row.get("feed_url")))
|
||||
if url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
|
|
|||
|
|
@ -285,14 +285,14 @@ def _context_segment(event: Event) -> Optional[str]:
|
|||
"""Optional context (dropped FIRST when over budget)."""
|
||||
data = event.data or {}
|
||||
bits: list[str] = []
|
||||
if "containment_pct" in data:
|
||||
if data.get("containment_pct") is not None:
|
||||
try:
|
||||
bits.append(f"{int(float(data['containment_pct']))}% contained")
|
||||
except Exception:
|
||||
pass
|
||||
if "cause" in data:
|
||||
if data.get("cause"):
|
||||
bits.append(str(data["cause"]))
|
||||
if "expires_at" in data:
|
||||
if data.get("expires_at"):
|
||||
bits.append(f"exp {data['expires_at']}")
|
||||
return ", ".join(bits) if bits else None
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,33 @@ def test_select_feeds_handles_garbage(adapter):
|
|||
assert adapter._select_feeds([None, 3, {"state": "ID"}]) == [] # no url
|
||||
|
||||
|
||||
def test_select_feeds_unwraps_socrata_url_object(adapter):
|
||||
"""Live FHWA registry serializes Idaho's ``url`` as a Socrata "URL" column
|
||||
object (``{"url": "…"}``), not a bare string. Selection must unwrap it and
|
||||
never raise ``AttributeError: 'dict' object has no attribute 'strip'``."""
|
||||
rows = [
|
||||
{"state": "Idaho", "format": "geojson",
|
||||
"url": {"url": "https://511.idaho.gov/api/wzdx"}},
|
||||
]
|
||||
feeds = adapter._select_feeds(rows)
|
||||
assert feeds == ["https://511.idaho.gov/api/wzdx"]
|
||||
|
||||
|
||||
def test_tick_survives_socrata_url_object(adapter, monkeypatch):
|
||||
"""End-to-end discovery path tolerates the nested-object ``url`` shape."""
|
||||
registry = [{"state": "Idaho", "format": "geojson",
|
||||
"url": {"url": "https://511.idaho.gov/api/wzdx"}}]
|
||||
fc = make_feature_collection(make_wzdx_feature(feat_id="A"))
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
return registry if "datahub" in url else fc
|
||||
|
||||
monkeypatch.setattr(adapter, "_http_get_json", fake_get)
|
||||
# Must not raise; the unwrapped feed URL is discovered and fetched.
|
||||
assert adapter.tick() is True
|
||||
assert {e["external_id"] for e in adapter.get_events()} == {"idot-1:A"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CATEGORY + CANONICAL DATA
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -232,6 +232,27 @@ def test_renderer_byte_budget_drops_optional_segments():
|
|||
assert "lightning" not in s
|
||||
|
||||
|
||||
def test_renderer_omits_none_context_fields():
|
||||
"""Regression — native road adapters set optional context keys present but
|
||||
None (``cause``/``expires_at``/``containment_pct``). The composer must
|
||||
guard on value, not key presence, so the literal string 'None' never leaks
|
||||
onto the wire."""
|
||||
e = make_event(
|
||||
source="wzdx", category="wildfire_incident", severity="immediate",
|
||||
title="Test Fire", region="Boise", timestamp=time.time(),
|
||||
data={"cause": None, "expires_at": None, "containment_pct": None},
|
||||
)
|
||||
s = compose_mesh_message(e)
|
||||
assert "None" not in s
|
||||
# A real value on the same field still renders (guard doesn't over-suppress).
|
||||
e2 = make_event(
|
||||
source="wzdx", category="wildfire_incident", severity="immediate",
|
||||
title="Test Fire", region="Boise", timestamp=time.time(),
|
||||
data={"cause": "lightning", "expires_at": None, "containment_pct": None},
|
||||
)
|
||||
assert "lightning" in compose_mesh_message(e2)
|
||||
|
||||
|
||||
def test_renderer_never_mid_character_truncation():
|
||||
"""The composer must never emit a UTF-8 byte sequence that splits a
|
||||
codepoint. Even with required-only over budget, we drop wholesale or
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue