meshai/work/tests/test_shadow_inert.py
malice 8bc9b14dba
refactor(phase3b): migrate WFIGS fire to formatter+decider (tier-a) (#33)
Move wfigs wire rendering into notifications/formatters/fire.py and the
full fire state machine into notifications/gating/fire.py, behind the
registry, NO cutover. handle_wfigs builds a canonical dict, calls
decide(), keeps the inline fires INSERT/UPDATE of current_* and the
tombstoned_at stamp unconditional, then branches on is_cutover(...) —
legacy _attach_commit_handles/all-clear path stays byte-identical while
the new path bakes in shadow.

Reproduces every legacy stamp through GateResult.data_patch:
- forward-only acres/containment growth + 8h cooldown gating
- tombstone wildfire_closed all-clear (row exists AND last_broadcast_at
  IS NOT NULL) with _severity_override="priority", _dedup_suffix="closed"
- growth _dedup_suffix=f"{acres}|{contained_pct}", _cooldown_suffix=irwin_id
- idempotent commit UPSERT of fires(last_broadcast_*) + event_log flip
- full _location_anchor fallback chain (geocoder_city -> resolve_anchor
  -> landclass -> county -> state) preserved in the formatter

Registered under the three explicit categories (wildfire_declared,
wildfire_incident, wildfire_closed) rather than the `fire` toggle, so the
family-fallback does NOT capture the still-deferred FIRMS categories
(wildfire_hotspot/new_ignition/wildfire_growth); a registration test
asserts those resolve elsewhere.

Native env/fires.py deferred (missing IRWIN/cause/landclass, no tombstone
concept); non-cutover so store._emit_event won't run it.

tier-a: 19 new golden+gate-sequence tests; wfigs handler 23/23 preserved;
suite at 34-failure baseline (1571 passed).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:51:26 -06:00

280 lines
11 KiB
Python

"""Phase-0b: verify shadow hooks are inert by default and never mutate state.
Test cases:
1. MESHAI_SHADOW_CATEGORIES unset → shadow_gate / shadow_render are pure no-ops
(no filesystem, no DB, no exception).
2. MESHAI_SHADOW_CATEGORIES set to a category with no decider registered →
still no-op (get_decider returns None, early return).
3. MESHAI_SHADOW_CATEGORIES set to a category with no formatter registered →
still no-op (get_formatter returns None, early return).
4. shadow_gate / shadow_render never raise regardless of input.
"""
from __future__ import annotations
import os
import pytest
import meshai.notifications.shadow as shadow_mod
def _reset_shadow_cache():
"""Clear lru_cache so env-var changes take effect."""
shadow_mod._clear_enabled_cache()
# ---------------------------------------------------------------------------
# Helper: a minimal fake Event-like object for shadow_render
# ---------------------------------------------------------------------------
class _FakeEvent:
id = "test-event-001"
category = "earthquake_event"
source = "usgs_quake"
data: dict = {}
# ---------------------------------------------------------------------------
# Case 1: env var unset — must be completely off
# ---------------------------------------------------------------------------
class TestShadowInertWhenEnvUnset:
"""With MESHAI_SHADOW_CATEGORIES unset, all shadow functions are no-ops."""
def setup_method(self):
os.environ.pop("MESHAI_SHADOW_CATEGORIES", None)
_reset_shadow_cache()
def test_enabled_for_returns_false(self):
assert shadow_mod.enabled_for("earthquake_event") is False
assert shadow_mod.enabled_for("nws") is False
assert shadow_mod.enabled_for("") is False
def test_shadow_gate_no_filesystem(self, tmp_path, monkeypatch):
"""shadow_gate must not touch the filesystem when the env var is unset."""
monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow"))
shadow_mod.shadow_gate(
"earthquake_event",
{"_severity_override": "immediate"},
source="usgs_quake",
now=1_700_000_000.0,
old_broadcast=True,
)
# No shadow dir should have been created.
assert not (tmp_path / "shadow").exists()
def test_shadow_render_no_filesystem(self, tmp_path, monkeypatch):
"""shadow_render must not touch the filesystem when the env var is unset."""
monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow"))
shadow_mod.shadow_render(
"earthquake_event",
_FakeEvent(),
old_wire="🌍 EQ M4.2: Test, ID 30mi NE routine",
)
assert not (tmp_path / "shadow").exists()
def test_shadow_gate_returns_none(self):
result = shadow_mod.shadow_gate(
"earthquake_event", {}, source="usgs", now=0.0, old_broadcast=False
)
assert result is None
def test_shadow_render_returns_none(self):
result = shadow_mod.shadow_render(
"earthquake_event", _FakeEvent(), old_wire="something"
)
assert result is None
# ---------------------------------------------------------------------------
# Case 2: env var set but no decider registered for that category
# ---------------------------------------------------------------------------
class TestShadowInertWhenNoDecider:
"""MESHAI_SHADOW_CATEGORIES set but no decider registered → still no-op."""
# A category name with NO decider and NO formatter registered, so the
# "no decider" premise is genuinely true regardless of migration phase.
_CATEGORY = "__no_such_category__"
def setup_method(self):
os.environ["MESHAI_SHADOW_CATEGORIES"] = self._CATEGORY
_reset_shadow_cache()
def teardown_method(self):
os.environ.pop("MESHAI_SHADOW_CATEGORIES", None)
_reset_shadow_cache()
def test_enabled_for_returns_true(self):
assert shadow_mod.enabled_for(self._CATEGORY) is True
def test_shadow_gate_no_filesystem_when_no_decider(self, tmp_path, monkeypatch):
"""get_decider returns None → shadow_gate exits before any file write."""
monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow"))
# No decider is registered for this category, so get_decider(...) returns
# None and shadow_gate returns early without writing anything.
shadow_mod.shadow_gate(
self._CATEGORY,
{"_dedup_suffix": "M4.2"},
source="usgs_quake",
now=1_700_000_000.0,
old_broadcast=True,
)
assert not (tmp_path / "shadow").exists()
def test_shadow_gate_does_not_raise(self):
"""shadow_gate must not propagate any exception."""
try:
shadow_mod.shadow_gate(
self._CATEGORY,
None, # intentionally bad input — must not raise
source="usgs_quake",
now=1_700_000_000.0,
old_broadcast=False,
)
except Exception as exc:
pytest.fail(f"shadow_gate raised unexpectedly: {exc!r}")
# ---------------------------------------------------------------------------
# Case 3: env var set but no formatter registered for that category
# ---------------------------------------------------------------------------
class TestShadowRenderInertWhenNoFormatter:
"""MESHAI_SHADOW_CATEGORIES set but no formatter registered → still no-op.
Note: earthquake_event has a formatter in Phase 1+; Phase-3b migrated the
WFIGS categories (wildfire_declared / wildfire_incident / wildfire_closed),
so this class uses wildfire_hotspot — a FIRMS category that remains
un-migrated (deferred) and has no formatter entry.
"""
_CATEGORY = "wildfire_hotspot"
def setup_method(self):
os.environ["MESHAI_SHADOW_CATEGORIES"] = self._CATEGORY
_reset_shadow_cache()
def teardown_method(self):
os.environ.pop("MESHAI_SHADOW_CATEGORIES", None)
_reset_shadow_cache()
def test_shadow_render_no_filesystem_when_no_formatter(self, tmp_path, monkeypatch):
"""get_formatter returns None → shadow_render exits before any file write."""
monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow"))
shadow_mod.shadow_render(
self._CATEGORY,
_FakeEvent(),
old_wire="old wire string",
)
assert not (tmp_path / "shadow").exists()
def test_shadow_render_does_not_raise(self):
"""shadow_render must not propagate any exception."""
try:
shadow_mod.shadow_render(
self._CATEGORY,
None, # intentionally bad input — must not raise
old_wire="anything",
)
except Exception as exc:
pytest.fail(f"shadow_render raised unexpectedly: {exc!r}")
# ---------------------------------------------------------------------------
# Case 4: multiple categories, partial enable
# ---------------------------------------------------------------------------
class TestShadowPartialEnable:
"""Only listed categories are enabled; others stay off."""
def setup_method(self):
os.environ["MESHAI_SHADOW_CATEGORIES"] = "nws,earthquake_event"
_reset_shadow_cache()
def teardown_method(self):
os.environ.pop("MESHAI_SHADOW_CATEGORIES", None)
_reset_shadow_cache()
def test_listed_category_is_enabled(self):
assert shadow_mod.enabled_for("nws") is True
assert shadow_mod.enabled_for("earthquake_event") is True
def test_unlisted_category_is_disabled(self):
assert shadow_mod.enabled_for("fire") is False
assert shadow_mod.enabled_for("geomagnetic_storm") is False
def test_shadow_gate_off_for_unlisted(self, tmp_path, monkeypatch):
monkeypatch.setattr(shadow_mod, "_SHADOW_DIR", str(tmp_path / "shadow"))
shadow_mod.shadow_gate(
"fire", {}, source="wfigs", now=0.0, old_broadcast=True
)
assert not (tmp_path / "shadow").exists()
# ---------------------------------------------------------------------------
# Case 5: shadow_gate forwards `source` to the decider (regression guard)
# ---------------------------------------------------------------------------
class TestShadowGateForwardsSource:
"""shadow_gate must call the decider with the keyword-only `source` arg.
The decider contract is `decide(data, *, source, now) -> GateResult`.
A prior bug called `decider(data, now=now)`, omitting `source`, so every
decider raised TypeError (swallowed by shadow_gate) and zero mismatch
records were ever written. This test registers a fake decider that
asserts `source` was forwarded and returns broadcast=True; with
old_broadcast=False that is a mismatch, so exactly one record must be
captured.
"""
_CATEGORY = "__shadow_test_cat__"
def setup_method(self):
# Enable shadow for the test-only category; ensure it is NOT cut over
# (so is_cutover is False and shadow_gate does not early-return).
os.environ["MESHAI_SHADOW_CATEGORIES"] = self._CATEGORY
os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None)
_reset_shadow_cache()
from meshai.notifications.cutover import _clear_cache as _clear_cutover_cache
_clear_cutover_cache()
# Register a fake decider that asserts the forwarded source.
from meshai.notifications.gating import DECIDERS, register
from meshai.notifications.gating.base import GateResult
def _fake_decider(data, *, source, now):
assert source == "test-src", f"source not forwarded: {source!r}"
return GateResult(broadcast=True)
self._DECIDERS = DECIDERS
register(self._CATEGORY, _fake_decider)
def teardown_method(self):
# Pop the fake decider so it never leaks into other tests.
self._DECIDERS.pop(self._CATEGORY, None)
os.environ.pop("MESHAI_SHADOW_CATEGORIES", None)
os.environ.pop("MESHAI_CUTOVER_CATEGORIES", None)
_reset_shadow_cache()
from meshai.notifications.cutover import _clear_cache as _clear_cutover_cache
_clear_cutover_cache()
def test_source_forwarded_and_mismatch_recorded(self, monkeypatch):
# Capture records locally instead of touching the real shadow dir.
captured = []
monkeypatch.setattr(
shadow_mod, "_append_jsonl",
lambda category, record: captured.append(record),
)
result = shadow_mod.shadow_gate(
self._CATEGORY, {}, source="test-src", now=0.0, old_broadcast=False
)
# Contract: returns None, does not raise.
assert result is None
# Exactly one mismatch record captured (new=True vs old=False).
assert len(captured) == 1
record = captured[0]
assert record["new_broadcast"] is True
assert record["old_broadcast"] is False
assert record["source"] == "test-src"