"""v0.6-4 — B13 guard-commit ordering + dedup suffix. Regression tests for two silent-suppression defects: 1. B13 ordering: cooldown arming and dedup recording used to happen BEFORE the region filter / severity floor / matrix resolution and BEFORE delivery. A non-deliverable or failed event therefore burned guard state and suppressed later deliverable events for up to the dedup retention window. Now both commit only after >=1 successful delivery. 2. Dedup suffix: feeds like WFIGS publish the SAME upstream id (IrwinID) on every sweep, so the bare (source, id) dedup key permanently swallowed every post-"New" lifecycle broadcast. The handler now stamps the broadcast-justifying state into event.data["_dedup_suffix"]; unchanged repeats dedup, updates pass. """ import asyncio import pytest from meshai.config import Config from meshai.notifications.pipeline.dispatcher import Dispatcher from meshai.notifications.events import make_event class RecChannel: def __init__(self, rec, succeed=True): self.rec = rec self.succeed = succeed async def deliver(self, payload, rule): self.rec.append({ "delivery_type": rule.delivery_type, "message": payload.message, }) return self.succeed def _cfg(**kw): cfg = Config() cfg.notifications.rules = [] cfg.notifications.cold_start_grace_seconds = 0 t = cfg.notifications.toggles["weather"] t.enabled = True t.min_severity = kw.get("min_severity", "routine") t.regions = kw.get("regions", []) t.severity_channels = kw.get("severity_channels", { "routine": ["mesh_broadcast"], "priority": ["mesh_broadcast"], "immediate": ["mesh_broadcast"], }) t.freshness_seconds = 600 t.cooldown_seconds = kw.get("cooldown_seconds", 300) t.broadcast_channel = 1 return cfg def _ev(eid="ev-1", severity="priority", **data): ev = make_event(source="nws", category="weather.alert.severe", severity=severity, title="t") ev.id = eid if data: ev.data.update(data) return ev def _disp(cfg, succeed=True): rec: list = [] d = Dispatcher(cfg, lambda rule, conn: RecChannel(rec, succeed), connector=None) return d, rec # ---------------------------------------------------------------- B13 def test_empty_matrix_row_burns_no_guard_state(): """Event whose severity row is empty must leave no cooldown/dedup.""" cfg = _cfg(severity_channels={"routine": [], "priority": [], "immediate": ["mesh_broadcast"]}) d, rec = _disp(cfg) asyncio.run(d.dispatch(_ev(eid="fire-x", severity="priority"))) assert rec == [] assert len(d._toggle_cooldown) == 0 assert len(d._dedup_lru) == 0 # Operator fixes the matrix -> the SAME event id must now deliver. cfg.notifications.toggles["weather"].severity_channels["priority"] = \ ["mesh_broadcast"] asyncio.run(d.dispatch(_ev(eid="fire-x", severity="priority"))) assert len(rec) == 1 def test_below_severity_floor_burns_no_guard_state(): cfg = _cfg(min_severity="immediate") d, rec = _disp(cfg) asyncio.run(d.dispatch(_ev(eid="ev-floor", severity="priority"))) assert rec == [] and not d._toggle_cooldown and not d._dedup_lru def test_wrong_region_burns_no_guard_state(): cfg = _cfg(regions=["US-ID"]) ev = _ev(eid="ev-region", severity="priority") ev.region = "US-MT" d, rec = _disp(cfg) asyncio.run(d.dispatch(ev)) assert rec == [] and not d._toggle_cooldown and not d._dedup_lru def test_failed_delivery_leaves_no_dedup_and_retries(): """All-channels-failed delivery must not dedup-record: the next sweep's redelivery of the same id must reach the channel again.""" cfg = _cfg() d, rec = _disp(cfg, succeed=False) asyncio.run(d.dispatch(_ev(eid="retry-me", severity="priority"))) assert len(rec) == 1 # attempted assert not d._dedup_lru # but not recorded assert not d._toggle_cooldown # and no cooldown armed # Channel recovers -> same event id delivers. d._channel_factory = lambda rule, conn: RecChannel(rec, succeed=True) asyncio.run(d.dispatch(_ev(eid="retry-me", severity="priority"))) assert len(rec) == 2 assert d._dedup_lru # recorded only on success def test_successful_delivery_commits_cooldown_and_dedup(): cfg = _cfg() d, rec = _disp(cfg) asyncio.run(d.dispatch(_ev(eid="ok-1", severity="priority"))) assert len(rec) == 1 assert len(d._toggle_cooldown) == 1 assert ("nws", "ok-1") in d._dedup_lru # Exact repeat is throttled (cooldown fires first at this severity). asyncio.run(d.dispatch(_ev(eid="ok-1", severity="priority"))) assert len(rec) == 1 assert d._cooldown_dropped == 1 # With cooldown out of the way, the repeat is dedup-dropped. cfg.notifications.toggles["weather"].cooldown_seconds = 0 asyncio.run(d.dispatch(_ev(eid="ok-1", severity="priority"))) assert len(rec) == 1 assert d._dedup_dropped == 1 # ---------------------------------------------------------------- suffix def test_dedup_suffix_lets_updates_pass_and_repeats_dedup(): """Same upstream id: identical suffix dedups, changed suffix passes — the WFIGS lifecycle-update fix.""" cfg = _cfg(cooldown_seconds=0) d, rec = _disp(cfg) # "New" broadcast: 0.1 acres. asyncio.run(d.dispatch( _ev(eid="{IRWIN-1}", severity="immediate", _dedup_suffix="0.1|0"))) assert len(rec) == 1 # Feed re-sweeps, nothing changed: same id + same suffix -> dedup. asyncio.run(d.dispatch( _ev(eid="{IRWIN-1}", severity="immediate", _dedup_suffix="0.1|0"))) assert len(rec) == 1 assert d._dedup_dropped == 1 # The fire blows up: same id, NEW suffix -> must broadcast. asyncio.run(d.dispatch( _ev(eid="{IRWIN-1}", severity="immediate", _dedup_suffix="9400.0|10"))) assert len(rec) == 2 # And that update's repeat dedups too. asyncio.run(d.dispatch( _ev(eid="{IRWIN-1}", severity="immediate", _dedup_suffix="9400.0|10"))) assert len(rec) == 2 def test_wfigs_handler_stamps_dedup_suffix(): from meshai.central.wfigs_handler import _attach_commit_handles data = {} _attach_commit_handles(data, irwin_id="{X}", acres=42.0, contained_pct=15, event_log_row_id=None) assert data["_dedup_suffix"] == "42.0|15" assert data["_cooldown_suffix"] == "{X}"