diff --git a/meshai/central/consumer.py b/meshai/central/consumer.py index 155efcf..7a3d990 100644 --- a/meshai/central/consumer.py +++ b/meshai/central/consumer.py @@ -18,6 +18,7 @@ from datetime import datetime from typing import Optional from meshai.notifications.events import Event, make_event +from meshai.notifications.categories import get_category logger = logging.getLogger("meshai.central.consumer") @@ -394,8 +395,25 @@ class CentralConsumer: # lifecycle distinctness for accounting. data["_central_tombstone_id"] = str(env_id) + # v0.5.7-regression: upstream Central payloads for most adapters + # (firms, nwis, swpc_*, wfigs_*, tomtom_incidents, ...) carry per- + # adapter fields but NOT a top-level `title` or `headline`. Falling + # back to `cat_raw` produced category-as-title broadcasts that + # leaked the raw Central hierarchical category onto the mesh + # (e.g. "incident.tomtom_incidents" instead of "Road Incident"). + # Prefer the meshai-friendly registry name from get_category() over + # the raw category. cat_raw stays as the last-resort tail so + # genuinely-unknown categories still produce *something* readable. + friendly_name = None + try: + ci = get_category(category) + if ci and ci.get("name"): + friendly_name = str(ci["name"]) + except Exception: + pass title = (data.get("title") or data.get("headline") - or cat_raw or f"{inner.get('adapter', 'central')} event") + or friendly_name or cat_raw + or f"{inner.get('adapter', 'central')} event") kwargs = dict( title=str(title)[:200], diff --git a/meshai/notifications/renderers/mesh.py b/meshai/notifications/renderers/mesh.py index 935a92b..d7ac10b 100644 --- a/meshai/notifications/renderers/mesh.py +++ b/meshai/notifications/renderers/mesh.py @@ -44,21 +44,25 @@ class MeshRenderer(Renderer): return self._chunk_long_line(line) def _format_one_line(self, p: NotificationPayload) -> str: - """Build the headline for a payload. + """Return the payload message verbatim for mesh delivery. - Default format: - "[] " - where EventTypeTitle is a short label derived from - p.event_type (e.g. "weather_warning" → "Weather"). If - p.event_type is None, omit the prefix. + v0.5.7-regression: previously prepended "[] " (the legacy + v0.5.0 debug format) to every payload. Since v0.5.2 the dispatcher + hands `payload.message` from compose_mesh_message(), whose output + ALREADY starts with the category emoji + family label (e.g. + "🚨 ROADS: ...", "🔥 FIRE: ..."). The renderer's wrap produced a + visually-broken duplicate "[Roads] 🚨 ROADS: ..." that hit the live + mesh during the v0.5.7 staged flip. The bug had been dormant since + v0.5.2 because no live broadcasts had ever fired in production + (safe-mode held the whole time). - Truncates the message at the limit only if the prefix - is short enough; otherwise lets the chunker handle it. + Now: pass `payload.message` through unchanged. The composer is the + single source of truth for mesh formatting. `_toggle_label` and the + `TOGGLE_LABELS` table below are kept because the digest renderer + still uses them (see meshai/notifications/pipeline/digest.py) for + the multi-line summary format -- do not remove them here. """ - prefix = self._toggle_label(p.event_type) - if prefix: - return f"[{prefix}] {p.message}" - return p.message + return p.message or "" def _toggle_label(self, event_type: Optional[str]) -> Optional[str]: """Map an event category to a short toggle label. diff --git a/tests/test_central_envelope_to_wire_v057.py b/tests/test_central_envelope_to_wire_v057.py new file mode 100644 index 0000000..c0d3641 --- /dev/null +++ b/tests/test_central_envelope_to_wire_v057.py @@ -0,0 +1,285 @@ +"""v0.5.7-regression: end-to-end Central envelope -> mesh wire string. + +Closes the seam between consumer/composer/renderer that the v0.5.7 staged +flip exposed. Pre-v0.5.7-regression two pre-existing bugs were dormant: + + 1. consumer._normalize() fell back to `cat_raw` (the raw Central + hierarchical category like "incident.tomtom_incidents") when the + upstream payload lacked `title`/`headline`. That string ended up as + event.title and the composer's primary identifier. + 2. MeshRenderer._format_one_line() prepended "[] " to every + payload.message -- including composer output that already starts + with the family label (e.g. "🚨 ROADS:"). Produced the visually- + broken duplicate "[Roads] 🚨 ROADS: ..." that Matt observed. + +Both bugs predate the v0.5.7 campaign but only manifested when v0.5.7 +was the first to flip Central live with master ON. Both were unit-tested +in isolation (composer with clean titles, renderer with legacy messages) +but no integration test exercised the full envelope -> wire path with a +realistic Central payload. This file fills that gap. + +For five representative Central adapter envelopes (one per stream family +that produces user-facing broadcasts), assert the rendered wire string: + + - Does NOT start with "[" (no [Family] legacy prefix). + - Does NOT contain raw Central category tokens like ".tomtom_incidents", + ".firms", ".kindex", ".proton_flux" -- those would indicate the + category-as-title fallback fired. + - DOES start with the composer's emoji + family label (e.g. "🚨 ", + "🔥 ", "⚠ ", "🌐 "). + - Contains the meshai-friendly registry name from ALERT_CATEGORIES + when the upstream payload lacks a useful title/headline. +""" + +import json + +import pytest + +from meshai.central.consumer import CentralConsumer +from meshai.config import EnvironmentalConfig +from meshai.notifications.events import make_payload_from_event +from meshai.notifications.pipeline.bus import EventBus +from meshai.notifications.renderers.composer import compose_mesh_message +from meshai.notifications.renderers.mesh import MeshRenderer +from meshai.notifications.categories import ALERT_CATEGORIES + + +# ---------- Envelope -> Event helper --------------------------------------- + + +def _envelope_to_event(subject: str, envelope: dict): + """Run a CloudEvents envelope through CentralConsumer._normalize/_handle + the way it would in production, returning the emitted Event.""" + rec = [] + bus = EventBus(); bus.subscribe(rec.append) + c = CentralConsumer(EnvironmentalConfig(), bus) + ev = c._handle(subject, json.dumps(envelope).encode()) + assert ev is not None, f"_handle returned None for subject {subject!r}" + return ev + + +def _render_to_wire(event) -> str: + """Run an Event through the dispatcher's composer + renderer path the way + _dispatch_toggles does for mesh_broadcast / mesh_dm, returning the final + wire-format string the renderer would hand to the connector.""" + friendly = compose_mesh_message(event) + assert friendly, "composer returned empty" + payload = make_payload_from_event(event, message=friendly) + chunks = MeshRenderer().render(payload) + assert chunks, "renderer returned no chunks" + return chunks[0] + + +# ---------- Five-adapter representative envelopes ------------------------- + + +# 1. tomtom_incidents -- the exact failure mode Matt observed live +TOMTOM_ENV = { + "id": "tt-12345", + "data": { + "id": "tt-12345", + "adapter": "tomtom_incidents", + "category": "incident.tomtom_incidents", + "time": "2026-06-04T15:40:00+00:00", + "severity": 3, # immediate per map_severity (>=3) + "geo": {"centroid": [-114.0, 42.5], "primary_region": "US-ID", + "regions": ["US-ID"]}, + # NOTE: tomtom_incidents upstream payload carries per-incident fields + # like roadway / event_type but NO top-level title or headline. That's + # the trigger for the v0.5.7-regression cat_raw fallback bug. + "data": {"roadway": "I-84 EB", "event_type": "crash", + "delay_seconds": 1800}, + }, +} + +# 2. FIRMS hotspot -- VIIRS NOAA-20, high confidence +FIRMS_ENV = { + "id": "viirs_noaa20:2026-06-04:0530:43.123:-115.456", + "data": { + "id": "viirs_noaa20:2026-06-04:0530:43.123:-115.456", + "adapter": "firms", + "category": "fire.hotspot.viirs_noaa20.high", + "time": "2026-06-04T05:30:00+00:00", + "severity": 2, + "geo": {"centroid": [-115.456, 43.123], "primary_region": "US-ID", + "regions": ["US-ID"]}, + "data": {"latitude": 43.123, "longitude": -115.456, + "confidence": "high", "frp": 22.5, "satellite": "N20"}, + }, +} + +# 3. NWS alert -- explicitly carries headline (positive control) +NWS_ENV = { + "id": "urn:oid:2.49.0.1.840.0.abc", + "data": { + "id": "urn:oid:2.49.0.1.840.0.abc", + "adapter": "nws", + "category": "wx.alert.us.id.severe_thunderstorm_warning", + "time": "2026-06-04T15:40:00+00:00", + "severity": 3, + "geo": {"centroid": [-116.2, 43.6], "primary_region": "US-ID", + "regions": ["US-ID"]}, + "data": { + "headline": "Severe Thunderstorm Warning issued June 4 by NWS Boise", + "description": "

The NWS in Boise has issued a Severe Thunderstorm Warning...

", + "areaDesc": "Ada, ID", + }, + }, +} + +# 4. USGS quake -- carries title (positive control) +QUAKE_ENV = { + "id": "us8000mc12", + "data": { + "id": "us8000mc12", + "adapter": "usgs_quake", + "category": "quake.event.moderate", + "time": "2026-06-04T12:00:00+00:00", + "severity": 2, + "geo": {"centroid": [-114.5, 44.2], "primary_region": "US-ID", + "regions": ["US-ID"]}, + "data": {"title": "M 4.2 - 23 km ESE of Stanley, ID", + "magnitude": 4.2, "place": "23 km ESE of Stanley, ID", + "depth": 8.0, "magType": "ml"}, + }, +} + +# 5. SWPC alert -- no title/headline, just message body +SWPC_ENV = { + "id": "A20F|2026-04-24 23:50:43.280", + "data": { + "id": "A20F|2026-04-24 23:50:43.280", + "adapter": "swpc_alerts", + "category": "space.alert", + "time": "2026-04-24T23:50:43.280Z", + "severity": 0, + "geo": {"centroid": None, "primary_region": None, "regions": []}, + "data": {"product_id": "A20F", + "issue_datetime": "2026-04-24 23:50:43.280", + "message": "WATCH: Geomagnetic Storm Category G1 Predicted ..."}, + }, +} + +CASES = [ + pytest.param( + "central.traffic.incident.id", TOMTOM_ENV, + "road_incident", "Road Incident", + id="tomtom_incidents-no-title-cat-fallback", + ), + pytest.param( + "central.fire.hotspot.viirs_noaa20.high", FIRMS_ENV, + "wildfire_hotspot", "Wildfire Hotspot", + id="firms-hotspot-no-title-cat-fallback", + ), + pytest.param( + "central.wx.alert.us.id.severe_thunderstorm_warning", NWS_ENV, + "weather_warning", None, # NWS supplies headline; friendly name not used + id="nws-with-headline", + ), + pytest.param( + "central.quake.event.moderate", QUAKE_ENV, + "earthquake_event", None, # USGS supplies title + id="quake-with-title", + ), + pytest.param( + "central.space.alert.a20f", SWPC_ENV, + "rf_propagation_alert", "Space Weather Alert", + id="swpc-alert-no-title-cat-fallback", + ), +] + + +@pytest.mark.parametrize("subject,envelope,expected_cat,expected_friendly_name", CASES) +def test_wire_string_no_legacy_family_prefix(subject, envelope, expected_cat, expected_friendly_name): + """No payload should produce a wire string starting with '[' -- the v0.5.0 + debug-format prefix the MeshRenderer used to add and now no longer does.""" + ev = _envelope_to_event(subject, envelope) + wire = _render_to_wire(ev) + assert not wire.startswith("["), ( + f"wire string still starts with legacy [Family] prefix: {wire!r}" + ) + + +@pytest.mark.parametrize("subject,envelope,expected_cat,expected_friendly_name", CASES) +def test_wire_string_no_raw_central_category_leaks(subject, envelope, expected_cat, expected_friendly_name): + """No wire string should contain a raw Central hierarchical category token + like '.tomtom_incidents', '.firms', '.kindex', '.proton_flux'. Those would + indicate the cat_raw fallback fired and the title-fallback fix didn't take.""" + ev = _envelope_to_event(subject, envelope) + wire = _render_to_wire(ev) + for leak in ( + ".tomtom_incidents", ".firms", + ".kindex", ".proton_flux", + "fire.hotspot.viirs", "incident.tomtom", + ): + assert leak not in wire, ( + f"raw Central category token {leak!r} leaked to wire: {wire!r}" + ) + + +@pytest.mark.parametrize("subject,envelope,expected_cat,expected_friendly_name", CASES) +def test_event_category_is_meshai_flat(subject, envelope, expected_cat, expected_friendly_name): + """The consumer must produce a meshai-flat category (not the raw Central + hierarchical string) so downstream filtering + UI selectability work.""" + ev = _envelope_to_event(subject, envelope) + assert ev.category == expected_cat, ( + f"expected event.category={expected_cat!r} got {ev.category!r}" + ) + assert ev.category in ALERT_CATEGORIES, ( + f"event.category {ev.category!r} not in ALERT_CATEGORIES -- audit gap" + ) + + +@pytest.mark.parametrize("subject,envelope,expected_cat,expected_friendly_name", CASES) +def test_friendly_name_used_when_upstream_has_no_title(subject, envelope, expected_cat, expected_friendly_name): + """For Central adapters whose upstream payload lacks `title`/`headline`, + the consumer's title fallback must use the meshai-friendly registry name + (`ALERT_CATEGORIES[category]['name']`) instead of `cat_raw`. NWS / USGS + quake carry their own title; this assertion skips those (expected_friendly_name=None).""" + if expected_friendly_name is None: + pytest.skip("adapter supplies its own title -- registry fallback not exercised") + ev = _envelope_to_event(subject, envelope) + assert ev.title == expected_friendly_name, ( + f"expected title={expected_friendly_name!r} got {ev.title!r}" + ) + + +@pytest.mark.parametrize("subject,envelope,expected_cat,expected_friendly_name", CASES) +def test_wire_string_starts_with_composer_label(subject, envelope, expected_cat, expected_friendly_name): + """The wire string should start with an emoji + family label like + '🚨 ROADS:', '🔥 FIRE:', '⚠ WX:', '🌐 RF:', '⛷ AVY:'. Confirms the + composer is what produces the formatting (not the renderer).""" + ev = _envelope_to_event(subject, envelope) + wire = _render_to_wire(ev) + # Find ":" within the first ~20 chars: that's the label terminator. + head = wire[:30] + assert ":" in head, ( + f"wire string head {head!r} has no composer label terminator ':'" + ) + + +# ---------- Specific Matt-saw regression ---------------------------------- + + +def test_matt_smoking_gun_no_longer_reproduces(): + """The exact regression Matt saw at 15:40:30 on 2026-06-04: + [Roads] 🚨 ROADS: incident.tomtom_incidents, US-ID. immediate + must NEVER reproduce. Strong-form assertion combining all three failure + modes: no '[Roads]' prefix, no raw category leak, no missing friendly name.""" + ev = _envelope_to_event("central.traffic.incident.id", TOMTOM_ENV) + wire = _render_to_wire(ev) + + assert not wire.startswith("[Roads]"), ( + f"the exact regression reproduced: {wire!r}" + ) + assert "incident.tomtom_incidents" not in wire, ( + f"raw central category still leaks to wire: {wire!r}" + ) + # Friendly name in primary slot + assert "Road Incident" in wire, ( + f"friendly registry name not in wire: {wire!r}" + ) + # Severity tail present + assert "immediate" in wire, ( + f"severity tail missing: {wire!r}" + ) diff --git a/tests/test_renderers.py b/tests/test_renderers.py index b898dc0..cdd5d0d 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -28,8 +28,14 @@ def test_mesh_render_short_message_single_chunk(): assert "Test alert" in chunks[0] -def test_mesh_render_event_type_prefix(): - """Known event type adds toggle label prefix.""" +def test_mesh_render_passes_message_verbatim(): + """v0.5.7-regression: MeshRenderer no longer prepends '[] '. + The composer (compose_mesh_message) is the single source of truth for + mesh formatting since v0.5.2 -- its output already starts with the + family emoji + label (e.g. '🚨 ROADS:'). The renderer used to wrap + that with '[Roads] ' producing the visually-broken duplicate + '[Roads] 🚨 ROADS: ...' that hit the live mesh during the v0.5.7 + staged flip. Now the renderer is a verbatim pass-through.""" payload = NotificationPayload( message="Severe storm", category="weather_warning", @@ -40,11 +46,13 @@ def test_mesh_render_event_type_prefix(): renderer = MeshRenderer() chunks = renderer.render(payload) assert len(chunks) == 1 - assert chunks[0].startswith("[Weather]") + assert chunks[0] == "Severe storm", chunks[0] + assert not chunks[0].startswith("["), chunks[0] def test_mesh_render_unknown_event_type_no_prefix(): - """Unknown event type does not add a prefix.""" + """v0.5.7-regression: same verbatim pass-through behavior regardless of + whether the event_type is in the registry.""" payload = NotificationPayload( message="Hello", category="made_up_thing", @@ -55,6 +63,7 @@ def test_mesh_render_unknown_event_type_no_prefix(): renderer = MeshRenderer() chunks = renderer.render(payload) assert len(chunks) == 1 + assert chunks[0] == "Hello" assert not chunks[0].startswith("[")