"""Phase-3b WFIGS wildfire refactor tests. Verifies the source-agnostic formatter+decider migration for the wildfire hazard, mirroring test_hydro_refactor.py / test_quake_refactor.py: 1. Golden byte-identical: formatters.fire.format() reproduces the legacy wfigs_handler._render() wire exactly for a New incident, an Update-with- growth ((+delta) size line), a movement-line case, an anchor-line case, and the wildfire_closed all-clear. 2. Gate-sequence parity: an explicit `now`-timeline of WFIGS events driven through the NEW gating.fire.decide() matches the OLD handle_wfigs broadcast/suppress behavior AND the data-dict stamps (category / _severity_override / _dedup_suffix / _cooldown_suffix). 3. Registration: the three explicit categories the wfigs_handler emits (wildfire_declared / wildfire_incident / wildfire_closed) resolve to the fire formatter + decider, and FIRMS categories do NOT. """ from __future__ import annotations import pytest from meshai.notifications.formatters._budget import budget_for from meshai.env.fire_render import ( _build_canonical, _render as _wfigs_render, handle_wfigs, ) from meshai.notifications.formatters.fire import format as fire_format from meshai.notifications.gating.fire import decide as fire_decide from meshai.persistence import close_thread_connection, init_db from meshai.persistence import db as persistence_db from tests.harness.goldens import assert_byte_identical from tests.test_wfigs_handler import ( _IRWIN_A, _make_active_envelope, _make_tombstone, _normalize_wfigs, ) _AT = 1_800_000_000.0 # pinned epoch (unused by fire render; kept for parity) @pytest.fixture def mem_db(monkeypatch, tmp_path): db_path = str(tmp_path / "fire-refactor-test.sqlite") monkeypatch.setenv("MESHAI_DB_PATH", db_path) persistence_db._initialised.clear() close_thread_connection() conn = init_db() try: from meshai.adapter_config import adapter_config as _ac _ac.invalidate() except Exception: pass try: from meshai.env import fire_render as _wh _wh._last_cleanup = 0 except Exception: pass yield conn close_thread_connection() persistence_db._initialised.discard(db_path) class _FakeEvent: def __init__(self, data, category=None): self.data = data self.category = category # ───────────────────────────────────────────────────────────────────────────── # 1. Golden byte-identical — formatter reproduces _render()/all-clear exactly # ───────────────────────────────────────────────────────────────────────────── class TestFormatterGolden: """formatters.fire.format() == wfigs_handler._render() for the same inputs.""" def _budget(self) -> int: return budget_for("wfigs") def _incident_data(self, **over) -> dict: d = { "incident_name": "Cache Peak Fire", "acres": 1847.0, "contained_pct": 23, "fire_cause": "Lightning", "declared_at_epoch": 1_781_204_400, "unique_fire_id": "2026-IDSCF-000987", "geocoder_city": "Burley", # short-circuits anchor (no DB/Photon) "lat": 42.197, "lon": -113.710, "county": "Cassia", "state": "ID", "landclass": None, } d.update(over) return d def test_new_incident(self, mem_db): n = self._incident_data() old = _wfigs_render(n, prefix="New") new = fire_format(_FakeEvent({**n, "is_update": False}), now=_AT, budget=self._budget()) assert_byte_identical(new, old) assert new.startswith("🔥 Cache Peak Fire — New") assert "1,847 ac" in new assert "containment 23%" in new def test_update_with_growth_delta(self, mem_db): n = self._incident_data(acres=3000.0, contained_pct=35) old = _wfigs_render(n, prefix="Update", last_bcast_acres=1847.0, last_bcast_contained=23) new = fire_format( _FakeEvent({**n, "is_update": True, "last_bcast_acres": 1847.0, "last_bcast_contained": 23}), now=_AT, budget=self._budget()) assert_byte_identical(new, old) assert new.startswith("🔥 Cache Peak Fire — Update") assert "3,000 ac (+1,153)" in new # delta line assert "containment 35%" in new def test_movement_line(self, mem_db): # movement is FIRMS-injected; the formatter must read it from event.data # exactly as _render(movement=...) does today. mv = {"direction": "NE", "speed_mph": 1.2} n = self._incident_data() old = _wfigs_render(n, prefix="New", movement=mv) new = fire_format(_FakeEvent({**n, "is_update": False, "movement": mv}), now=_AT, budget=self._budget()) assert_byte_identical(new, old) assert "Moving NE 1.2 mi/h" in new def test_anchor_line(self, mem_db): # No geocoder_city → line 3 is resolved via the shared resolve_anchor + # the legacy fallback tiers. Both paths hit the same seeded town_anchors # table, so the wire must stay byte-identical. n = self._incident_data(geocoder_city=None) old = _wfigs_render(n, prefix="New") new = fire_format(_FakeEvent({**n, "is_update": False}), now=_AT, budget=self._budget()) assert_byte_identical(new, old) # line 3 is NOT a movement line and NOT a raw city name assert "Moving" not in new def test_wildfire_closed_all_clear(self, mem_db): # Drive the real handler to produce the legacy all-clear wire, then # assert the formatter reproduces it byte-for-byte from event.data. env = _make_active_envelope(geocoder_city="Burley") n0 = _normalize_wfigs(env) data0 = {} handle_wfigs(n0, env, env["subject"], data=data0, now=1_000_000) data0["_on_broadcast_committed"](float(1_000_000)) # arm last_broadcast_* tomb = _make_tombstone() data_t = {} old_wire = handle_wfigs(_normalize_wfigs(tomb), tomb, tomb["subject"], data=data_t, now=2_000_000) assert old_wire is not None assert old_wire.startswith("✅ Cache Peak Fire — contained & closed") assert data_t["category"] == "wildfire_closed" # Reconstruct the canonical fields the decider's data_patch supplies to # the formatter for the closed wire, and render. closed_data = { "category": "wildfire_closed", "incident_name": "Cache Peak Fire", "acres": 1847.0, "contained_pct": 23, "lat": env["data"]["data"]["latitude"], "lon": env["data"]["data"]["longitude"], "county": "Cassia", "state": "ID", } new_wire = fire_format(_FakeEvent(closed_data, category="wildfire_closed"), now=_AT, budget=budget_for("wfigs")) assert_byte_identical(new_wire, old_wire) # ───────────────────────────────────────────────────────────────────────────── # 2. Gate-sequence parity — new decide() vs old handle_wfigs across a lifecycle # ───────────────────────────────────────────────────────────────────────────── class TestGateSequenceParity: """A full fire lifecycle agrees between decide() and handle_wfigs().""" def _decide(self, env, now): n = _normalize_wfigs(env) canonical = _build_canonical(n, n["_kind"]) return fire_decide(canonical, source="wfigs", now=float(now)) def _step(self, env, now, *, expect_broadcast, expect_lifecycle): """Assert decide() and handle_wfigs() agree at one timeline step. decide() (called first) only READS state, so it sees the same pre-write row the handler's internal decide() sees. On broadcast the handler's legacy (not-cutover) stamps must equal decide()'s data_patch, and we arm last_broadcast_* via the commit callback (simulating the dispatcher). """ n = _normalize_wfigs(env) gate = self._decide(env, now) assert gate.broadcast is expect_broadcast, ( f"decide broadcast {gate.broadcast} != {expect_broadcast} " f"@ {now} ({expect_lifecycle})") assert gate.lifecycle == expect_lifecycle, ( f"decide lifecycle {gate.lifecycle} != {expect_lifecycle} @ {now}") data = {} wire = handle_wfigs(n, env, env["subject"], data=data, now=now) assert (wire is not None) is expect_broadcast if expect_broadcast: # Handler's stamped keys (legacy path) match decide()'s data_patch. for k in ("_severity_override", "_dedup_suffix", "_cooldown_suffix"): assert data.get(k) == gate.data_patch.get(k), ( f"stamp {k}: handler={data.get(k)!r} " f"decide={gate.data_patch.get(k)!r}") if expect_lifecycle == "new": assert data.get("category") == "wildfire_declared" assert gate.data_patch.get("category") == "wildfire_declared" elif expect_lifecycle == "update": # Update keeps the envelope-derived wildfire_incident: no override. assert "category" not in data assert "category" not in gate.data_patch elif expect_lifecycle == "closed": assert data.get("category") == "wildfire_closed" assert gate.data_patch.get("category") == "wildfire_closed" assert data.get("_severity_override") == "priority" # Arm last_broadcast_* for the next cooldown check. data["_on_broadcast_committed"](float(now)) return wire def test_full_lifecycle(self, mem_db): irwin = _IRWIN_A base = 1_800_000_000 disc_ms = (base - 3600) * 1000 # discovered 1h before first sight (fresh) def _active(acres, pct, subject_n="a"): return _make_active_envelope( irwin_id=irwin, geocoder_city="Burley", daily_acres=acres, pct_contained=pct, fire_discovery_dt_ms=disc_ms) # [0] first sight → New broadcast self._step(_active(250.0, 0), base, expect_broadcast=True, expect_lifecycle="new") # [1] small growth 1h later (inside 8h cooldown) → suppress self._step(_active(300.0, 0), base + 3600, expect_broadcast=False, expect_lifecycle="cooldown") # [2] growth after cooldown (8h) → Update self._step(_active(500.0, 0), base + 28800, expect_broadcast=True, expect_lifecycle="update") # [3] containment change after another cooldown → Update self._step(_active(500.0, 40), base + 28800 * 2, expect_broadcast=True, expect_lifecycle="update") # [4] tombstone → all-clear (fire was broadcast earlier) tomb = _make_tombstone(irwin_id=irwin) self._step(tomb, base + 100000, expect_broadcast=True, expect_lifecycle="closed") def test_dedup_suffix_tracks_state(self, mem_db): """_dedup_suffix carries the acres|contained that justified the broadcast (so unchanged re-deliveries dedup but genuine updates pass).""" base = 1_800_000_000 disc_ms = (base - 3600) * 1000 env_new = _make_active_envelope( irwin_id=_IRWIN_A, geocoder_city="Burley", daily_acres=250.0, pct_contained=0, fire_discovery_dt_ms=disc_ms) gate = self._decide(env_new, base) n = _normalize_wfigs(env_new) assert gate.data_patch["_dedup_suffix"] == f"{n['acres']}|{n['contained_pct']}" assert gate.data_patch["_cooldown_suffix"] == _IRWIN_A def test_never_broadcast_tombstone_suppressed(self, mem_db): """A tombstone for a fire that never reached the mesh is silent.""" tomb = _make_tombstone(irwin_id=_IRWIN_A) gate = self._decide(tomb, 1_000_000) assert gate.broadcast is False assert gate.lifecycle == "suppress" # Handler agrees: returns None. out = handle_wfigs(_normalize_wfigs(tomb), tomb, tomb["subject"], data={}, now=1_000_000) assert out is None def test_perimeter_never_broadcasts(self, mem_db): from tests.test_wfigs_handler import _make_perimeter per = _make_perimeter(irwin_id=_IRWIN_A) gate = self._decide(per, 1_000_000) assert gate.broadcast is False assert gate.lifecycle == "suppress" # ───────────────────────────────────────────────────────────────────────────── # 3. Registration — the three explicit categories resolve; FIRMS does not # ───────────────────────────────────────────────────────────────────────────── class TestRegistration: @pytest.mark.parametrize( "cat", ["wildfire_declared", "wildfire_incident", "wildfire_closed"]) def test_formatter_registered(self, cat): from meshai.notifications.formatters import get_formatter assert get_formatter(cat) is fire_format @pytest.mark.parametrize( "cat", ["wildfire_declared", "wildfire_incident", "wildfire_closed"]) def test_decider_registered(self, cat): from meshai.notifications.gating import get_decider assert get_decider(cat) is fire_decide @pytest.mark.parametrize( "cat", ["wildfire_hotspot", "new_ignition", "unattributed_hotspot_cluster"]) def test_firms_categories_not_captured(self, cat): # These native FIRMS categories remain deferred; they must NOT resolve # to the fire formatter/decider via the "fire" toggle family fallback. # (Phase-3c migrated the FIRMS FUSION categories wildfire_growth / # wildfire_spotting / wildfire_halted — covered in test_firms_refactor.py # — but growth reuses the fire FORMATTER while keeping its OWN firms # DECIDER, so it is intentionally not asserted here.) from meshai.notifications.formatters import get_formatter from meshai.notifications.gating import get_decider assert get_formatter(cat) is not fire_format assert get_decider(cat) is not fire_decide