mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
* feat(region-routing): P1 tagging + region_routes primitive + read/write API + preview launcher - config.py: add Coverage.region_tagging (bool=False); add RegionRouteMatrix dataclass (enabled, cells) above NotificationsConfig; add region_routes field to NotificationsConfig; add explicit hydration branch for region_routes in _dict_to_dataclass mirroring destinations pattern. - coverage_area.py: add MonitoringArea.name (str|None=None, frozen); update areas_from_config to preserve name; refactor inline geom extraction from classify_event_areas into shared _event_geom_json helper; add matching_area_names(geom_json, areas)->list[str] (additive, all named matches, config-order, deduped; gate unchanged); add event_region_names convenience wrapper. - coverage_filter.py: add region_tagging ctor kwarg; stamp event.region/ regions before the gate when region_tagging=True and areas non-empty and not event.regions (never clobbers satpass preset). - pipeline/__init__.py: wire region_tagging into CoverageFilter construction. - notification_routes.py: add GET /notifications/regions (named coverage area names, config-order, deduped); GET /notifications/region-routing (matrix as JSON); POST /notifications/region-routing (explicit RMW — only region_routes changes, toggles/rules/destinations survive). - scripts/preview_dashboard.py: mesh-free launcher — dashboard API only, no mesh connector, no broadcast loop; vite runs separately. All 87 coverage tests pass; 300 total pass; 6 pre-existing failures unchanged (adapter config count mismatch + MeshCore EventType.NEW_CONTACT). * feat(region-routing): manual region x family matrix editor page Adds RegionRoutingMatrix.tsx — a plain editor over the region_routes config primitive. Rows = families (via useFamilies()), cols = regions (from GET /api/notifications/regions). Each cell exposes MT channel (ChannelPicker single + includeDisabled), MC channel name (text input), min_severity select (routine/priority/critical/immediate), and an enabled checkbox. Only cells where MT or MC is set are included in the sparse POST payload. Master enable toggle maps to top-level enabled. MT budget guard warns when more than 7 distinct MT indices are in use. Sticky family column; horizontal scroll for wide region sets. Registers route /region-routing in App.tsx and adds "Region Routing" nav entry (Map icon) under the Meshtastic section in Layout.tsx, immediately after Routing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(region-routing): regions endpoint reads saved (disk) coverage so routing columns are dynamic without a bot restart; preview reloads config after writes * feat(routing): unify MT/MC routing into per-family cards; region routing as an in-card expand; remove rules/destinations UI + standalone page * refactor(routing): move Meshtastic Routing from /notifications to /meshtastic/routing (mirror /meshcore/routing); redirect legacy path * feat(region-routing): dispatcher honors region_routes matrix (authoritative-on-match, per-region cooldown, per-channel dedup); non-matrix path unchanged Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(region-routing): matrix dedup key must match boot-restore 2-tuple form (prevents restart re-broadcast flood); regression test --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
440 lines
17 KiB
Python
440 lines
17 KiB
Python
"""Region-routes matrix dispatcher tests (P3 — authoritative-on-match).
|
||
|
||
Covers the matrix branch inserted between Section 1 (staleness) and Section 2
|
||
(region-scope/toggle-min_severity) in _dispatch_toggles.
|
||
|
||
NOTE: the test harness does NOT build a CoverageFilter — event.region /
|
||
event.regions are set manually on each event object. That is intentional: the
|
||
matrix branch resolves against event.region/regions independently of how those
|
||
fields were populated.
|
||
|
||
Test inventory
|
||
--------------
|
||
1. cell_match_routes_mt_and_mc — a cell with both mt+mc sends two deliveries
|
||
2. routine_event_bypasses_toggle_floor — matrix cell floor=routine; toggle.min_severity=priority
|
||
3. no_cell_for_region_falls_through — event region not in cells → normal toggle path
|
||
4. matrix_disabled_falls_through — enabled=False → byte-identical toggle path
|
||
5. per_region_cooldown_independence — cooldown on region A does not block region B
|
||
6. per_channel_dedup_independence — failed channel leaves no dedup; retries on next call
|
||
7. authoritative_no_double_send — matrix match does NOT also fire toggle default
|
||
8. empty_chans_authoritative_no_send — every cell below floor → return, no toggle fallback
|
||
9. dedup_suppresses_repeat_same_channel — same event id + same channel → dedup on second call
|
||
10. cell_disabled_flag_skipped — cell with enabled=False is ignored
|
||
"""
|
||
|
||
import asyncio
|
||
import time
|
||
|
||
import pytest
|
||
|
||
from meshai.config import Config, RegionRouteMatrix
|
||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||
from meshai.notifications.events import make_event
|
||
|
||
|
||
# ------------------------------------------------------------------ helpers
|
||
|
||
|
||
class RecChannel:
|
||
"""Channel recorder capturing delivery type, channel values, and success."""
|
||
|
||
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),
|
||
"name": rule.name,
|
||
"message": payload.message if payload else None,
|
||
})
|
||
return self.succeed
|
||
|
||
|
||
def _make_dispatcher(cfg, succeed=True):
|
||
rec: list = []
|
||
d = Dispatcher(cfg, lambda rule, conn: RecChannel(rec, succeed), connector=None)
|
||
return d, rec
|
||
|
||
|
||
def _dispatch(cfg, event, succeed=True):
|
||
d, rec = _make_dispatcher(cfg, succeed=succeed)
|
||
asyncio.run(d.dispatch(event))
|
||
return d, rec
|
||
|
||
|
||
def _base_cfg(fam="fire", cooldown_s=0, min_severity="priority"):
|
||
"""Minimal config: one toggle enabled, no cold-start grace."""
|
||
cfg = Config()
|
||
cfg.notifications.rules = []
|
||
cfg.notifications.cold_start_grace_seconds = 0
|
||
t = cfg.notifications.toggles[fam]
|
||
t.enabled = True
|
||
t.min_severity = min_severity
|
||
t.regions = [] # no toggle-level region filter
|
||
t.severity_channels = {
|
||
"routine": ["mesh_broadcast"],
|
||
"priority": ["mesh_broadcast"],
|
||
"immediate": ["mesh_broadcast"],
|
||
}
|
||
t.freshness_seconds = 0 # never stale in tests
|
||
t.cooldown_seconds = cooldown_s
|
||
t.broadcast_channel = 0 # toggle default channel (different from matrix cells)
|
||
return cfg
|
||
|
||
|
||
def _rr(cells: dict, enabled: bool = True) -> RegionRouteMatrix:
|
||
return RegionRouteMatrix(enabled=enabled, cells=cells)
|
||
|
||
|
||
def _ev(fam="fire", region=None, regions=None, severity="immediate",
|
||
eid=None, suffix=None):
|
||
ev = make_event(
|
||
source="test", category="wildfire_incident" if fam == "fire" else "weather_warning",
|
||
severity=severity, title="Test event",
|
||
)
|
||
if eid:
|
||
ev.id = eid
|
||
if region:
|
||
ev.region = region
|
||
if regions:
|
||
ev.regions = regions
|
||
if suffix:
|
||
ev.data["_dedup_suffix"] = suffix
|
||
return ev
|
||
|
||
|
||
# ============================================================ Test 1
|
||
# Cell match → both mt and mc channels deliver
|
||
|
||
def test_cell_match_routes_mt_and_mc():
|
||
"""A cell with mt=3 and mc='sci-fires' must produce two deliveries —
|
||
mesh_broadcast on channel 3 and meshcore_broadcast on channel 'sci-fires'."""
|
||
cfg = _base_cfg(fam="fire")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 3, "mc": "sci-fires", "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
ev = _ev(fam="fire", region="SCI")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
types = {r["delivery_type"] for r in rec}
|
||
assert "mesh_broadcast" in types, "must deliver via mesh_broadcast"
|
||
assert "meshcore_broadcast" in types, "must deliver via meshcore_broadcast"
|
||
assert len(rec) == 2, "exactly two deliveries (mt + mc)"
|
||
|
||
mb = next(r for r in rec if r["delivery_type"] == "mesh_broadcast")
|
||
mcb = next(r for r in rec if r["delivery_type"] == "meshcore_broadcast")
|
||
assert mb["broadcast_channel"] == 3
|
||
assert mcb["meshcore_channel"] == "sci-fires"
|
||
|
||
|
||
# ============================================================ Test 2
|
||
# Routine severity event routes via matrix even when toggle.min_severity=priority
|
||
|
||
def test_routine_event_bypasses_toggle_floor():
|
||
"""Matrix cell min_severity=routine must route a routine-severity event even
|
||
when the toggle's own min_severity=priority (Section 2 bypass)."""
|
||
cfg = _base_cfg(fam="fire", min_severity="priority")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SWI": {"mt": 1, "mc": None, "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
ev = _ev(fam="fire", region="SWI", severity="routine")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
# Matrix delivered at mt=1
|
||
assert len(rec) == 1
|
||
assert rec[0]["delivery_type"] == "mesh_broadcast"
|
||
assert rec[0]["broadcast_channel"] == 1
|
||
|
||
|
||
# ============================================================ Test 3
|
||
# Event region not in cells → fall through to normal toggle path
|
||
|
||
def test_no_cell_for_region_falls_through():
|
||
"""When the event's region is not present in fam_cells, _matrix_matched is
|
||
None and the dispatcher falls through to the toggle default path."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 3, "mc": None, "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
# Region 'SWI' has no cell
|
||
ev = _ev(fam="fire", region="SWI")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
# Toggle default path delivers via broadcast_channel=0 (toggle's default)
|
||
assert len(rec) == 1
|
||
assert rec[0]["delivery_type"] == "mesh_broadcast"
|
||
assert rec[0]["broadcast_channel"] == 0 # toggle default, not matrix cell
|
||
|
||
|
||
# ============================================================ Test 4
|
||
# matrix disabled → byte-identical to no-matrix behaviour
|
||
|
||
def test_matrix_disabled_falls_through():
|
||
"""region_routes.enabled=False must leave the toggle path completely unchanged."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(
|
||
cells={"fire": {"SCI": {"mt": 7, "mc": "x", "min_severity": "routine", "enabled": True}}},
|
||
enabled=False,
|
||
)
|
||
ev = _ev(fam="fire", region="SCI")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
# Toggle default still fires with broadcast_channel=0
|
||
assert len(rec) == 1
|
||
assert rec[0]["broadcast_channel"] == 0
|
||
|
||
|
||
# ============================================================ Test 5
|
||
# Per-region cooldown independence
|
||
|
||
def test_per_region_cooldown_independence():
|
||
"""Cooldown armed for region A must not block region B."""
|
||
cfg = _base_cfg(fam="fire", cooldown_s=300, min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 3, "mc": None, "min_severity": "routine", "enabled": True},
|
||
"SWI": {"mt": 1, "mc": None, "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
d, rec = _make_dispatcher(cfg)
|
||
|
||
ev_sci = _ev(fam="fire", region="SCI", eid="ev-sci")
|
||
asyncio.run(d.dispatch(ev_sci))
|
||
# SCI ch=3 now has cooldown armed
|
||
assert len(rec) == 1
|
||
assert rec[0]["broadcast_channel"] == 3
|
||
|
||
ev_swi = _ev(fam="fire", region="SWI", eid="ev-swi")
|
||
asyncio.run(d.dispatch(ev_swi))
|
||
# SWI ch=1 must still deliver despite SCI cooldown
|
||
assert len(rec) == 2
|
||
assert rec[1]["broadcast_channel"] == 1
|
||
assert d.dispatch_stats()["cooldown_dropped"] == 0
|
||
|
||
|
||
# ============================================================ Test 6
|
||
# Per-channel dedup independence: failed channel leaves no dedup trace
|
||
|
||
def test_per_channel_dedup_failed_channel_retries():
|
||
"""A channel that fails delivery must leave no dedup entry, so it retries
|
||
next sweep. A distinct (different ch_type/chan) channel that succeeded is
|
||
still deduped on the second dispatch."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {
|
||
"mt": 3, "mc": "sci-fires",
|
||
"min_severity": "routine", "enabled": True,
|
||
},
|
||
}
|
||
})
|
||
d = Dispatcher.__new__(Dispatcher)
|
||
|
||
# Track which channels have succeeded to control per-channel return value.
|
||
_succeeded: set = set()
|
||
_rec: list = []
|
||
|
||
def _factory(rule, conn):
|
||
ch = rule.delivery_type
|
||
chan = rule.broadcast_channel if ch == "mesh_broadcast" else rule.meshcore_channel
|
||
|
||
class _Ch:
|
||
async def deliver(self_, payload, rule_):
|
||
_rec.append({
|
||
"delivery_type": ch,
|
||
"chan": chan,
|
||
})
|
||
# mesh_broadcast ch=3 fails first time only
|
||
if ch == "mesh_broadcast" and ("mb", chan) not in _succeeded:
|
||
return False
|
||
_succeeded.add((ch, chan))
|
||
return True
|
||
|
||
return _Ch()
|
||
|
||
from meshai.persistence import init_db
|
||
import os, tempfile
|
||
# Use a fresh DB (conftest already set MESHAI_DB_PATH via monkeypatch)
|
||
Dispatcher.__init__(d, cfg, _factory, connector=None)
|
||
|
||
ev = _ev(fam="fire", region="SCI", eid="dedup-ch-test")
|
||
|
||
# First dispatch: mesh_broadcast fails, meshcore_broadcast succeeds.
|
||
asyncio.run(d.dispatch(ev))
|
||
assert len(_rec) == 2 # both attempted
|
||
|
||
# Dedup keys are 2-tuples (source, "id#suffix|ch_type|chan") — the channel
|
||
# is baked into the second element (this shape survives the boot restore).
|
||
# meshcore_broadcast succeeded → recorded; mesh_broadcast failed → NOT.
|
||
assert any("meshcore_broadcast" in k[1] for k in d._dedup_lru), \
|
||
"meshcore_broadcast (success) must be in dedup_lru"
|
||
assert not any("|mesh_broadcast|" in k[1] for k in d._dedup_lru), \
|
||
"mesh_broadcast (failed) must NOT be in dedup_lru"
|
||
|
||
# Second dispatch same event: meshcore_broadcast deduped; mesh_broadcast retries.
|
||
before = len(_rec)
|
||
asyncio.run(d.dispatch(ev))
|
||
new_deliveries = _rec[before:]
|
||
# Only mesh_broadcast attempted again (meshcore was deduped)
|
||
assert any(r["delivery_type"] == "mesh_broadcast" for r in new_deliveries), \
|
||
"mesh_broadcast must retry on second dispatch"
|
||
assert not any(r["delivery_type"] == "meshcore_broadcast" for r in new_deliveries), \
|
||
"meshcore_broadcast must be deduped on second dispatch"
|
||
assert d.dispatch_stats()["dedup_dropped"] == 1
|
||
|
||
|
||
# ============================================================ Test 7
|
||
# Authoritative-on-match: matrix match must NOT also fire toggle default
|
||
|
||
def test_authoritative_no_double_send():
|
||
"""When the matrix matches, the toggle default (Sections 2–6) must NOT run.
|
||
We verify by checking the rule name: matrix branch uses toggle:<fam> but
|
||
the channel is the matrix cell's channel, not the toggle's broadcast_channel."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.toggles["fire"].broadcast_channel = 0 # toggle default
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 5, "mc": None, "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
ev = _ev(fam="fire", region="SCI")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
# Exactly one delivery, via matrix cell channel 5 (not toggle default 0)
|
||
assert len(rec) == 1
|
||
assert rec[0]["broadcast_channel"] == 5, \
|
||
"matrix channel (5) must be used, not toggle default (0)"
|
||
|
||
|
||
# ============================================================ Test 8
|
||
# Empty chans (every cell below floor) → authoritative no-send, not toggle fallback
|
||
|
||
def test_empty_chans_authoritative_no_send():
|
||
"""When every matched cell is below its min_severity floor the matrix
|
||
still consumes the event (authoritative) and returns without sending.
|
||
The toggle default must NOT run as a fallback."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {
|
||
"mt": 3, "mc": None,
|
||
"min_severity": "immediate", # floor = immediate
|
||
"enabled": True,
|
||
},
|
||
}
|
||
})
|
||
# routine event — below the cell floor
|
||
ev = _ev(fam="fire", region="SCI", severity="routine")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
assert rec == [], "below floor → authoritative no-send, toggle default does NOT fire"
|
||
|
||
|
||
# ============================================================ Test 9
|
||
# Dedup suppresses repeat on same channel
|
||
|
||
def test_dedup_suppresses_repeat_same_channel():
|
||
"""Same event id + same channel → first send succeeds, second is deduped."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 2, "mc": None, "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
d, rec = _make_dispatcher(cfg)
|
||
|
||
ev = _ev(fam="fire", region="SCI", eid="dedup-repeat")
|
||
asyncio.run(d.dispatch(ev))
|
||
assert len(rec) == 1
|
||
|
||
asyncio.run(d.dispatch(ev))
|
||
assert len(rec) == 1 # second suppressed
|
||
assert d.dispatch_stats()["dedup_dropped"] == 1
|
||
|
||
|
||
# ============================================================ Test 10
|
||
# cell enabled=False is skipped
|
||
|
||
def test_cell_disabled_flag_skipped():
|
||
"""A cell with enabled=False must be ignored even when its region matches."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 3, "mc": None, "min_severity": "routine", "enabled": False},
|
||
}
|
||
})
|
||
ev = _ev(fam="fire", region="SCI")
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
# Cell disabled → chans empty → authoritative no-send
|
||
assert rec == []
|
||
|
||
|
||
# ============================================================ Bonus: existing tests
|
||
# Smoke-check that non-matrix toggle path is unaffected when region_routes.enabled=False
|
||
|
||
def test_existing_toggle_path_unaffected_by_inert_matrix():
|
||
"""Sanity: a toggle with no region_routes config behaves exactly as before
|
||
(sends via the toggle's default broadcast_channel=1)."""
|
||
cfg = _base_cfg(fam="weather", min_severity="routine")
|
||
cfg.notifications.toggles["weather"].broadcast_channel = 1
|
||
# No region_routes set → default RegionRouteMatrix(enabled=False)
|
||
ev = make_event(
|
||
source="nws", category="weather_warning", severity="priority",
|
||
title="Red Flag",
|
||
)
|
||
_, rec = _dispatch(cfg, ev)
|
||
assert len(rec) == 1
|
||
assert rec[0]["broadcast_channel"] == 1
|
||
|
||
|
||
# ============================================================ Bonus: regions list
|
||
|
||
def test_matrix_resolves_from_event_regions_list():
|
||
"""event.regions=[...] (multi-region) must also resolve cells, not just
|
||
event.region. Both matching regions send to their respective channels."""
|
||
cfg = _base_cfg(fam="fire", min_severity="routine")
|
||
cfg.notifications.region_routes = _rr(cells={
|
||
"fire": {
|
||
"SCI": {"mt": 3, "mc": None, "min_severity": "routine", "enabled": True},
|
||
"SWI": {"mt": 1, "mc": None, "min_severity": "routine", "enabled": True},
|
||
}
|
||
})
|
||
# event.region=None, event.regions=["SCI","SWI"]
|
||
ev = _ev(fam="fire", regions=["SCI", "SWI"])
|
||
_, rec = _dispatch(cfg, ev)
|
||
|
||
# Each region maps to a distinct mt index → two sends
|
||
channels = {r["broadcast_channel"] for r in rec}
|
||
assert channels == {1, 3}
|
||
|
||
|
||
def test_restart_dedup_key_matches_boot_restore_form():
|
||
"""Regression (restart-flood): the matrix dedup key MUST equal the boot-
|
||
restore shape — a 2-tuple (source, "id|ch_type|chan"). Dispatcher
|
||
construction rehydrates _dedup_lru as 2-tuples; a 4-tuple check key would
|
||
miss after every restart and re-broadcast the whole matrix backlog."""
|
||
cfg = _base_cfg(fam="fire", cooldown_s=0)
|
||
cfg.notifications.region_routes = _rr(
|
||
{"fire": {"SCI": {"mt": 3, "mc": None,
|
||
"min_severity": "routine", "enabled": True}}}
|
||
)
|
||
d, rec = _make_dispatcher(cfg)
|
||
ev = _ev(fam="fire", region="SCI", eid="restart-test")
|
||
# Exactly what dispatcher.__init__ restores from dispatcher_dedup:
|
||
d._dedup_lru[("test", "restart-test|mesh_broadcast|3")] = True
|
||
asyncio.run(d.dispatch(ev))
|
||
mesh_sends = [r for r in rec if r["delivery_type"] == "mesh_broadcast"]
|
||
assert mesh_sends == [], (
|
||
"matrix must dedup against the restored 2-tuple key; a re-broadcast "
|
||
"here is the restart-flood bug"
|
||
)
|