diff --git a/work/meshai/notifications/pipeline/dispatcher.py b/work/meshai/notifications/pipeline/dispatcher.py index ea82273..5c30e56 100644 --- a/work/meshai/notifications/pipeline/dispatcher.py +++ b/work/meshai/notifications/pipeline/dispatcher.py @@ -924,6 +924,203 @@ class Dispatcher: "scheduled-broadcast: audit row insert failed for %s", ch_type) return delivered_any + async def dispatch_scheduled_fire_broadcast( + self, text: str, *, + source_event_pk: str, + lat=None, lon=None, county=None, state=None, + ) -> bool: + """Region-aware scheduled broadcast for a FIRE reminder. + + Unlike dispatch_scheduled_broadcast (which hardcodes the + rf_propagation toggle -> band-conditions channels), a fire reminder + must land on the SAME channels a live New/Update fire alert for the + SAME fire would: routed through the `fire` toggle + region_routes + matrix, with the fire's region derived from its lat/lon exactly the + way the event path derives it (CoverageFilter.event_region_names over + the configured coverage areas). + + Resolution (mirrors _dispatch_toggles Section 1.5 semantics, minus the + event-driven dedup/cooldown/freshness gates that reminders intentionally + bypass — reminders are clock-driven, not event-driven): + + * derive regions from (lat, lon) via the coverage areas, + * for each ENABLED transport (mt/mc) whose region_routes cell matches + a derived region, route to that cell's channel (per-cell `enabled` + respected); a matched+enabled transport is matrix-OWNED even if its + column is null, so it does NOT fall back to the toggle default, + * any transport NOT owned by the matrix (matrix disabled for it, no + cell for any derived region, or no derived region at all) falls back + to the `fire` toggle's own default channel(s). + + This guarantees a SW-Idaho fire reminder goes to MT ch3 / MC + #sw-id-aida (etc.), and an unlocatable / unmatched fire falls back to + the fire toggle default — never to rf_propagation / band-conditions. + + Cold-start grace still applies (consistent with the other scheduled + broadcasts). Returns True on at least one successful mesh delivery. + """ + # Cold-start grace (mirrors dispatch_scheduled_broadcast). + grace_s = int(getattr(self._config.notifications, + "cold_start_grace_seconds", 60) or 0) + if grace_s > 0: + now_anchor = time.time() + if self._first_event_at is None: + self._first_event_at = now_anchor + self._persist_state() + if (now_anchor - self._first_event_at) < grace_s: + self._cold_start_dropped += 1 + self._persist_state() + self._logger.info( + "cold-start grace: dropping scheduled fire broadcast " + "(pk=%s)", source_event_pk) + return False + + toggles = getattr(self._config.notifications, "toggles", None) or {} + fire_tog = toggles.get("fire") if isinstance(toggles, dict) else None + if fire_tog is None: + self._logger.info( + "scheduled-fire-broadcast: fire toggle not found; dropping " + "(pk=%s)", source_event_pk) + return False + + # Build a synthetic fire Event purely to (a) run region derivation and + # (b) reuse make_payload_from_event. category wildfire_declared maps to + # the `fire` family; severity priority mirrors the live fire path. + from meshai.notifications.events import make_event, make_payload_from_event + ev = make_event( + source="wfigs", category="wildfire_declared", + severity="priority", title=text, + lat=(float(lat) if lat is not None else None), + lon=(float(lon) if lon is not None else None), + ) + ev.data["_meshai_precomposed"] = True + + # Derive regions from the fire's location the SAME way CoverageFilter + # does for a live event (named coverage areas -> region names). Never + # raises: an unlocatable fire simply yields no regions -> toggle default. + derived_regions: list = [] + try: + from meshai.coverage_area import ( + areas_from_config, event_region_names, + ) + _areas = areas_from_config(getattr(self._config, "coverage", None)) + if _areas and ev.lat is not None and ev.lon is not None: + derived_regions = event_region_names(ev, _areas) or [] + except Exception: + self._logger.exception( + "scheduled-fire-broadcast: region derivation failed; " + "falling back to toggle default (pk=%s)", source_event_pk) + if derived_regions: + ev.regions = list(derived_regions) + ev.region = derived_regions[0] + + # Resolve the channel plan. Each entry: (ch_type, chan_val). + # ch_type in {"mesh_broadcast", "meshcore_broadcast"} + # chan_val = Meshtastic channel index or MeshCore channel name. + # _mt_owned / _mc_owned track per-transport matrix authority so an + # owned transport does NOT also emit the toggle default. + plan: list = [] + _mt_owned = False + _mc_owned = False + rr = getattr(self._config.notifications, "region_routes", None) + _mt_on = bool(getattr(rr, "mt_enabled", False)) if rr is not None else False + _mc_on = bool(getattr(rr, "mc_enabled", False)) if rr is not None else False + if rr is not None and (_mt_on or _mc_on) and derived_regions: + fam_cells = (getattr(rr, "cells", None) or {}).get("fire") or {} + _seen: set = set() + for _rg in derived_regions: + if _rg in _seen or _rg not in fam_cells: + continue + _seen.add(_rg) + _cell = fam_cells[_rg] + # A matched region marks each ENABLED transport as matrix-owned + # (mirrors the event path's authoritative-suppress), so it does + # NOT fall back to the toggle default even if the column is null. + if _mt_on: + _mt_owned = True + if _mc_on: + _mc_owned = True + _cell_enabled = (_cell.get("enabled", True) if isinstance(_cell, dict) + else getattr(_cell, "enabled", True)) + if not _cell_enabled: + continue + _mt = (_cell.get("mt") if isinstance(_cell, dict) + else getattr(_cell, "mt", None)) + _mc = (_cell.get("mc") if isinstance(_cell, dict) + else getattr(_cell, "mc", None)) + if _mt_on and _mt is not None: + plan.append(("mesh_broadcast", _mt)) + if _mc_on and _mc: # truthy: non-empty string + plan.append(("meshcore_broadcast", _mc)) + + # Toggle-default fallback for any transport the matrix did NOT own. + # Uses the fire toggle's priority severity_channels (band-conditions is + # NOT consulted). A transport already owned by the matrix is skipped. + sev_channels = getattr(fire_tog, "severity_channels", {}) or {} + default_ch_types = [ + c for c in sev_channels.get("priority", ["mesh_broadcast"]) + if c in ("mesh_broadcast", "meshcore_broadcast") + ] + for ct in default_ch_types: + if ct == "mesh_broadcast" and not _mt_owned: + plan.append(("mesh_broadcast", + getattr(fire_tog, "broadcast_channel", None) or 0)) + elif ct == "meshcore_broadcast" and not _mc_owned: + _mcn = getattr(fire_tog, "meshcore_channel", None) + if _mcn: + plan.append(("meshcore_broadcast", _mcn)) + + if not plan: + self._logger.info( + "scheduled-fire-broadcast: no channels resolved for pk=%s " + "(regions=%s); dropping", source_event_pk, derived_regions) + return False + + delivered_any = False + for ch_type, chan_val in plan: + rule = self._toggle_to_rule( + fire_tog, ch_type, ev, + mt_override=(chan_val if ch_type == "mesh_broadcast" else None), + mc_override=(chan_val if ch_type == "meshcore_broadcast" else None), + ) + try: + channel = self._channel_factory(rule, self._connector) + payload = make_payload_from_event(ev, message=text) + success = await channel.deliver(payload, rule) + except Exception: + self._logger.exception( + "scheduled-fire-broadcast: delivery raised for %s", ch_type) + success = False + + if success: + delivered_any = True + self._logger.info( + "scheduled-fire-broadcast: dispatched pk=%s via %s ch=%s " + "regions=%s", source_event_pk, ch_type, chan_val, + derived_regions or "DEFAULT") + + # v20 per-mesh audit row (one per channel; mirrors + # dispatch_scheduled_broadcast). + try: + from meshai.persistence import get_db + conn = get_db() + bytes_sent = len(text.encode("utf-8")) if text else 0 + transport, channel_id, recipient = self._audit_route(rule, ch_type) + conn.execute( + "INSERT INTO mesh_broadcasts_out(sent_at, recipient, " + "channel, text, source_event_table, source_event_pk, " + "bytes_sent, ack_received, transport, success) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (int(time.time()), recipient, channel_id, text, + "fires", str(source_event_pk), bytes_sent, 0, + transport, 1 if success else 0), + ) + except Exception: + self._logger.exception( + "scheduled-fire-broadcast: audit row insert failed for %s", + ch_type) + return delivered_any + @staticmethod def _audit_route(rule, ch_type: str): """Resolve (transport, channel_id, recipient) for a mesh delivery. diff --git a/work/meshai/notifications/reminders/__init__.py b/work/meshai/notifications/reminders/__init__.py index c7b7b9c..4ce2840 100644 --- a/work/meshai/notifications/reminders/__init__.py +++ b/work/meshai/notifications/reminders/__init__.py @@ -308,10 +308,29 @@ class ReminderScheduler: return "" async def _dispatch(self, adapter: str, row, wire: str) -> bool: - """Send via dispatcher.dispatch_scheduled_broadcast (cold-start - grace honored, no toggle-path freshness gating).""" + """Send via the dispatcher (cold-start grace honored, no toggle-path + freshness gating). + + Fire (wfigs) reminders route through the REGION-AWARE fire path + (dispatch_scheduled_fire_broadcast) so an "Active:" reminder lands on + the SAME channels a live New/Update fire alert for that fire would — + the `fire` toggle + region_routes matrix, region derived from the + fire's lat/lon. Every OTHER adapter (rf_propagation band conditions, + 511 work zones) keeps the existing dispatch_scheduled_broadcast path + (rf_propagation -> band-conditions channels), unchanged.""" table, pk = self._row_pk(adapter, row) try: + if adapter == "wfigs": + return bool( + await self._dispatcher.dispatch_scheduled_fire_broadcast( + text=wire, + source_event_pk=pk, + lat=_safe_row(row, "lat"), + lon=_safe_row(row, "lon"), + county=_safe_row(row, "county"), + state=_safe_row(row, "state"), + ) + ) return bool(await self._dispatcher.dispatch_scheduled_broadcast( text=wire, source_event_table=table, source_event_pk=pk, )) @@ -393,3 +412,14 @@ def _safe_get(adapter_config, adapter: str, key: str) -> Any: return None except Exception: return None + + +def _safe_row(row, key: str) -> Any: + """Read a column from a sqlite3.Row without raising when it's absent. + + The wfigs reminder rows come from `SELECT * FROM fires`, so lat/lon/ + county/state are present; this stays defensive against schema drift.""" + try: + return row[key] + except (IndexError, KeyError, TypeError): + return None diff --git a/work/tests/test_fire_reminder_region_routing.py b/work/tests/test_fire_reminder_region_routing.py new file mode 100644 index 0000000..df91b14 --- /dev/null +++ b/work/tests/test_fire_reminder_region_routing.py @@ -0,0 +1,358 @@ +"""Fire-reminder region routing tests. + +A wfigs "Active:" reminder must land on the SAME channels a live New/Update +fire alert for that fire would — routed through the `fire` toggle + +region_routes matrix, with the fire's region derived from its lat/lon exactly +the way the event path derives it (coverage areas -> region names). Before this +change, fire reminders went through dispatch_scheduled_broadcast(), which +hardcodes the rf_propagation toggle -> Meshtastic ch4 / MeshCore #aida +(band-conditions), ignoring the fire's region entirely. + +These tests drive the FULL path: ReminderScheduler.tick_once() reads the seeded +`fires` row, the wfigs branch calls dispatch_scheduled_fire_broadcast(), which +derives the region from the fire's lat/lon over the configured coverage areas +and routes via the matrix. Delivery channels are asserted against a real +Dispatcher + a recording channel (no MagicMock) so the exact channel is checked. + +Region layout (coverage areas named to match the matrix cell keys): + SW Idaho ~ Boise (43.6, -116.2) -> MT ch3 / MC #sw-id-aida + SC Idaho ~ Twin Falls (42.5, -114.5) -> MT ch2 / MC #sc-id-aida + East Idaho ~ Idaho Falls (43.5, -112.0) -> MT ch5 / MC #e-id-aida + Toggle default (fire) ~ MT ch9 / MC #aida (never rf_propagation/ch4) +""" +from __future__ import annotations + +import asyncio +import time + +import pytest + +from meshai.config import Config, RegionRouteMatrix +from meshai.notifications.pipeline.dispatcher import Dispatcher +from meshai.notifications.reminders import ReminderScheduler +from meshai.persistence import get_db + + +# --------------------------------------------------------------------- recorder + + +class RecChannel: + """Records each delivery's transport + channel value + message.""" + + def __init__(self, rec: list, succeed: bool = True): + self.rec = rec + self.succeed = succeed + + async def deliver(self, payload, rule): + self.rec.append({ + "delivery_type": rule.delivery_type, + "broadcast_channel": getattr(rule, "broadcast_channel", None), + "meshcore_channel": getattr(rule, "meshcore_channel", None), + "message": payload.message if payload else None, + }) + return self.succeed + + +# --------------------------------------------------------------------- fixtures + + +# Coverage areas (name -> small bbox around the reference point). The name is +# what event_region_names() returns AND what the matrix cell is keyed on. +_COVERAGE_AREAS = [ + {"name": "SW", "west": -117.0, "south": 43.0, "east": -115.5, "north": 44.2}, + {"name": "SC", "west": -115.2, "south": 42.0, "east": -113.8, "north": 43.0}, + {"name": "East", "west": -112.8, "south": 43.0, "east": -111.2, "north": 44.2}, +] + +# Reference points that fall inside exactly one named area above. +_PT = { + "SW": (43.6, -116.2), + "SC": (42.5, -114.5), + "East": (43.5, -112.0), +} + + +def _fire_cfg(*, with_matrix=True, mt_enabled=True, mc_enabled=True, + cold_start_grace=0): + """Config: fire toggle enabled, coverage areas for region tagging, and the + per-region fire matrix (SW/SC/East -> MT/MC channels).""" + cfg = Config() + cfg.notifications.rules = [] + cfg.notifications.cold_start_grace_seconds = cold_start_grace + + # Fire toggle: default channels DISTINCT from every matrix cell + from + # rf_propagation (ch4) so a mis-route is unambiguous. MT default = ch9, + # MC default = #aida (the same MC default the legacy path used — proving + # the fallback is the FIRE toggle default, not rf_propagation's). + fire = cfg.notifications.toggles["fire"] + fire.enabled = True + fire.min_severity = "routine" + fire.regions = [] + fire.freshness_seconds = 0 + fire.cooldown_seconds = 0 + fire.broadcast_channel = 9 + fire.meshcore_channel = "#aida" + fire.severity_channels = { + "routine": ["mesh_broadcast", "meshcore_broadcast"], + "priority": ["mesh_broadcast", "meshcore_broadcast"], + "immediate": ["mesh_broadcast", "meshcore_broadcast"], + } + + # rf_propagation toggle configured too, so a mis-route to the OLD path + # would land on ch4/#aida-band and be caught by the assertions. + rf = cfg.notifications.toggles["rf_propagation"] + rf.enabled = True + rf.broadcast_channel = 4 + rf.meshcore_channel = "#aida-band" + rf.severity_channels = { + "priority": ["mesh_broadcast", "meshcore_broadcast"], + } + + cfg.coverage.enabled = True + cfg.coverage.areas = list(_COVERAGE_AREAS) + + if with_matrix: + cfg.notifications.region_routes = RegionRouteMatrix( + mt_enabled=mt_enabled, mc_enabled=mc_enabled, + cells={ + "fire": { + "SW": {"mt": 3, "mc": "#sw-id-aida", + "min_severity": "routine", "enabled": True}, + "SC": {"mt": 2, "mc": "#sc-id-aida", + "min_severity": "routine", "enabled": True}, + "East": {"mt": 5, "mc": "#e-id-aida", + "min_severity": "routine", "enabled": True}, + }, + }, + ) + return cfg + + +def _dispatcher(cfg, succeed=True): + rec: list = [] + d = Dispatcher(cfg, lambda rule, conn: RecChannel(rec, succeed), connector=None) + return d, rec + + +def _seed_fire(conn, *, irwin_id, lat, lon, last_broadcast_at, + current_contained_pct=10, name="Test Fire", + county="Ada", state="ID"): + now = int(time.time()) + conn.execute( + "INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, " + "current_acres, current_contained_pct, lat, lon, county, state, " + "declared_at, last_event_at, first_broadcast_at, last_broadcast_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (irwin_id, name, "WF", 500, current_contained_pct, lat, lon, + county, state, last_broadcast_at, now, + last_broadcast_at, last_broadcast_at), + ) + + +def _enable_wfigs_reminders(): + """Flip the reminders_wfigs.enabled kill switch on IN THE TEST DB ONLY. + + This mutates the per-test isolated sqlite adapter_config seed (conftest + points MESHAI_DB_PATH at a tmp file). It does NOT touch /data/config or + the container's adapter_config — the production kill switch stays False. + """ + conn = get_db() + conn.execute( + "UPDATE adapter_config SET default_json='true' " + "WHERE adapter='reminders_wfigs' AND key='enabled'" + ) + conn.execute( + "UPDATE adapter_config SET value_json='true' " + "WHERE adapter='reminders_wfigs' AND key='enabled'" + ) + from meshai.adapter_config import adapter_config as _ac + _ac.invalidate() + + +def _tick(cfg, *, irwin_id, region, succeed=True, **fire_kw): + """Seed one fire in `region`, run one reminder tick, return (fired, rec).""" + conn = get_db() + _enable_wfigs_reminders() + lat, lon = _PT[region] + _seed_fire(conn, irwin_id=irwin_id, lat=lat, lon=lon, + last_broadcast_at=int(time.time()) - 9 * 3600, **fire_kw) + d, rec = _dispatcher(cfg, succeed=succeed) + sch = ReminderScheduler(d, clock=time.time) + fired = asyncio.run(sch.tick_once()) + return fired, rec + + +# ============================================================ SW / SC / East + + +def test_sw_idaho_fire_reminder_routes_ch3_and_sw_mc(): + """SW Idaho fire reminder -> MT ch3 + MC #sw-id-aida (NOT ch4, NOT #aida).""" + cfg = _fire_cfg() + fired, rec = _tick(cfg, irwin_id="F-SW", region="SW") + assert fired == 1 + + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 3, \ + "SW fire reminder must go to MT ch3, got %r" % (mt,) + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#sw-id-aida", \ + "SW fire reminder must go to MC #sw-id-aida, got %r" % (mc,) + + # Explicitly rule out the OLD hardcoded rf_propagation route. + assert all(r["broadcast_channel"] != 4 for r in mt), "must NOT hit ch4" + assert all(r["meshcore_channel"] not in ("#aida", "#aida-band") for r in mc) + # And it must be an "Active:" reminder. + assert any("Active" in (r["message"] or "") for r in rec) + + +def test_sc_idaho_fire_reminder_routes_ch2_and_sc_mc(): + """SC Idaho fire reminder -> MT ch2 + MC #sc-id-aida.""" + cfg = _fire_cfg() + fired, rec = _tick(cfg, irwin_id="F-SC", region="SC") + assert fired == 1 + + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 2 + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#sc-id-aida" + + +def test_east_idaho_fire_reminder_routes_ch5_and_east_mc(): + """East Idaho fire reminder -> MT ch5 + MC #e-id-aida.""" + cfg = _fire_cfg() + fired, rec = _tick(cfg, irwin_id="F-E", region="East") + assert fired == 1 + + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 5 + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#e-id-aida" + + +# ============================================================ unresolved region + + +def test_unresolvable_region_falls_back_to_fire_toggle_default(): + """A fire whose lat/lon lands in NO named coverage area falls back to the + FIRE toggle default (MT ch9 / MC #aida) — never rf_propagation (ch4).""" + cfg = _fire_cfg() + conn = get_db() + _enable_wfigs_reminders() + # Point in the middle of the ocean — inside no named area. + _seed_fire(conn, irwin_id="F-NONE", lat=0.0, lon=0.0, + last_broadcast_at=int(time.time()) - 9 * 3600) + d, rec = _dispatcher(cfg) + fired = asyncio.run(ReminderScheduler(d, clock=time.time).tick_once()) + assert fired == 1 + + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 9, \ + "unresolved fire must fall back to fire toggle default MT ch9, got %r" % (mt,) + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida", \ + "unresolved fire must fall back to fire toggle default MC #aida, got %r" % (mc,) + # NOT the rf_propagation band-conditions channels. + assert all(r["broadcast_channel"] != 4 for r in mt) + assert all(r["meshcore_channel"] != "#aida-band" for r in mc) + + +def test_region_matched_but_not_in_matrix_falls_back_to_default(): + """A fire whose region IS derived (SW) but has NO cell in the matrix falls + back to the fire toggle default, still NOT rf_propagation.""" + cfg = _fire_cfg() + # Drop the SW cell so the derived 'SW' region has no matrix entry. + del cfg.notifications.region_routes.cells["fire"]["SW"] + fired, rec = _tick(cfg, irwin_id="F-SW2", region="SW") + assert fired == 1 + + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 9 + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida" + + +def test_matrix_disabled_falls_back_to_fire_toggle_default(): + """region_routes disabled for both transports -> fire toggle default + channels (ch9/#aida), never rf_propagation.""" + cfg = _fire_cfg(mt_enabled=False, mc_enabled=False) + fired, rec = _tick(cfg, irwin_id="F-SW3", region="SW") + assert fired == 1 + + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 9 + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida" + + +# ============================================================ regressions + + +def test_rf_propagation_reminder_still_routes_ch4_and_aida(): + """REGRESSION: a band-conditions (rf_propagation) reminder still uses the + UNCHANGED dispatch_scheduled_broadcast path -> MT ch4 / MC #aida. + + Driven via the same _dispatch entry the scheduler uses, with adapter=swpc + standing in for a scheduled non-fire broadcaster: it must NOT touch the new + fire path and must land on rf_propagation's configured channels.""" + cfg = _fire_cfg() + d, rec = _dispatcher(cfg) + sch = ReminderScheduler(d, clock=time.time) + + # A minimal fake row is fine — _dispatch(adapter="swpc", ...) only needs + # _row_pk to resolve, which reads row["event_id"]. + row = {"event_id": "S-RF"} + ok = asyncio.run(sch._dispatch("swpc", row, "🌌 Active: ongoing space weather")) + assert ok is True + + # rf_propagation is priority-class: severity_channels['priority'] = + # [mesh_broadcast, meshcore_broadcast] -> ch4 + #aida-band (its config). + mt = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mt) == 1 and mt[0]["broadcast_channel"] == 4, \ + "rf_propagation reminder must still use ch4, got %r" % (mt,) + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida-band" + # It must NOT have used any fire matrix / fire-default channel. + assert all(r["broadcast_channel"] not in (2, 3, 5, 9) for r in mt) + + +def test_fire_reminder_does_not_touch_rf_propagation_path(): + """The wfigs branch must call dispatch_scheduled_fire_broadcast and NOT + dispatch_scheduled_broadcast (the rf_propagation path). Verified by spying + on both dispatcher methods with a matched-region fire.""" + from unittest.mock import AsyncMock + + cfg = _fire_cfg() + conn = get_db() + _enable_wfigs_reminders() + lat, lon = _PT["SW"] + _seed_fire(conn, irwin_id="F-SPY", lat=lat, lon=lon, + last_broadcast_at=int(time.time()) - 9 * 3600) + + d, _rec = _dispatcher(cfg) + d.dispatch_scheduled_broadcast = AsyncMock(return_value=True) + fire_spy = AsyncMock(return_value=True) + d.dispatch_scheduled_fire_broadcast = fire_spy + + fired = asyncio.run(ReminderScheduler(d, clock=time.time).tick_once()) + assert fired == 1 + fire_spy.assert_called_once() + kwargs = fire_spy.call_args.kwargs + assert kwargs["source_event_pk"] == "F-SPY" + assert kwargs["lat"] == pytest.approx(lat) + assert kwargs["lon"] == pytest.approx(lon) + d.dispatch_scheduled_broadcast.assert_not_called() + + +def test_disabled_wfigs_reminders_send_nothing(): + """Sanity: with the kill switch OFF (default), no fire reminder fires even + when a stale fire exists.""" + cfg = _fire_cfg() + conn = get_db() + # NOTE: intentionally do NOT call _enable_wfigs_reminders(). + lat, lon = _PT["SW"] + _seed_fire(conn, irwin_id="F-OFF", lat=lat, lon=lon, + last_broadcast_at=int(time.time()) - 9 * 3600) + d, rec = _dispatcher(cfg) + fired = asyncio.run(ReminderScheduler(d, clock=time.time).tick_once()) + assert fired == 0 + assert rec == [] diff --git a/work/tests/test_reminders.py b/work/tests/test_reminders.py index e2849c1..aaa2122 100644 --- a/work/tests/test_reminders.py +++ b/work/tests/test_reminders.py @@ -71,6 +71,11 @@ def _enable_wfigs_reminders(): def mock_dispatcher(): d = MagicMock() d.dispatch_scheduled_broadcast = AsyncMock(return_value=True) + # wfigs reminders route through the region-aware fire path (see + # dispatch_scheduled_fire_broadcast); make it awaitable too so the + # non-fire path tests (rf/511 via dispatch_scheduled_broadcast) and the + # fire path tests share one fixture. + d.dispatch_scheduled_fire_broadcast = AsyncMock(return_value=True) return d @@ -89,10 +94,12 @@ def test_wfigs_reminder_fires_past_cadence(mock_dispatcher): sch = ReminderScheduler(mock_dispatcher, clock=lambda: now) fired = asyncio.run(sch.tick_once()) assert fired == 1 - mock_dispatcher.dispatch_scheduled_broadcast.assert_called_once() - args = mock_dispatcher.dispatch_scheduled_broadcast.call_args.kwargs + # wfigs now routes through the region-aware fire path, NOT the hardcoded + # rf_propagation dispatch_scheduled_broadcast. + mock_dispatcher.dispatch_scheduled_broadcast.assert_not_called() + mock_dispatcher.dispatch_scheduled_fire_broadcast.assert_called_once() + args = mock_dispatcher.dispatch_scheduled_fire_broadcast.call_args.kwargs assert "Active" in args["text"] - assert args["source_event_table"] == "fires" assert args["source_event_pk"] == "F1"