2026-07-04 12:55:48 -06:00
|
|
|
"""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:
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
"""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__"
|
2026-07-04 12:55:48 -06:00
|
|
|
|
|
|
|
|
def setup_method(self):
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
os.environ["MESHAI_SHADOW_CATEGORIES"] = self._CATEGORY
|
2026-07-04 12:55:48 -06:00
|
|
|
_reset_shadow_cache()
|
|
|
|
|
|
|
|
|
|
def teardown_method(self):
|
|
|
|
|
os.environ.pop("MESHAI_SHADOW_CATEGORIES", None)
|
|
|
|
|
_reset_shadow_cache()
|
|
|
|
|
|
|
|
|
|
def test_enabled_for_returns_true(self):
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
assert shadow_mod.enabled_for(self._CATEGORY) is True
|
2026-07-04 12:55:48 -06:00
|
|
|
|
|
|
|
|
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"))
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
# No decider is registered for this category, so get_decider(...) returns
|
|
|
|
|
# None and shadow_gate returns early without writing anything.
|
2026-07-04 12:55:48 -06:00
|
|
|
shadow_mod.shadow_gate(
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
self._CATEGORY,
|
2026-07-04 12:55:48 -06:00
|
|
|
{"_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(
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
self._CATEGORY,
|
2026-07-04 12:55:48 -06:00
|
|
|
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:
|
refactor(phase1): quake + swpc(Kp+flare) + avalanche, behind staged-cutover gate (#29)
* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe
Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.). It
also called pull_subscribe_bind() without await, making the fetch a no-op.
Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds. Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.
Adds live captured fixtures:
- tests/fixtures/quake/ — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/ — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate
First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.
- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
(geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
_storm stays legacy; geomag 600s window re-homed off the module-global into
gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
(avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
emit canonical Event.data (avalanche stops precomposing). env/store.py generic
native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
bind); real quake/swpc fixtures captured; avalanche synthetic.
Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:58:46 -06:00
|
|
|
"""MESHAI_SHADOW_CATEGORIES set but no formatter registered → still no-op.
|
|
|
|
|
|
|
|
|
|
Note: earthquake_event has a formatter in Phase 1+; this class uses
|
|
|
|
|
wildfire_incident which remains un-migrated and has no formatter entry.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_CATEGORY = "wildfire_incident"
|
2026-07-04 12:55:48 -06:00
|
|
|
|
|
|
|
|
def setup_method(self):
|
refactor(phase1): quake + swpc(Kp+flare) + avalanche, behind staged-cutover gate (#29)
* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe
Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.). It
also called pull_subscribe_bind() without await, making the fetch a no-op.
Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds. Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.
Adds live captured fixtures:
- tests/fixtures/quake/ — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/ — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate
First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.
- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
(geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
_storm stays legacy; geomag 600s window re-homed off the module-global into
gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
(avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
emit canonical Event.data (avalanche stops precomposing). env/store.py generic
native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
bind); real quake/swpc fixtures captured; avalanche synthetic.
Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:58:46 -06:00
|
|
|
os.environ["MESHAI_SHADOW_CATEGORIES"] = self._CATEGORY
|
2026-07-04 12:55:48 -06:00
|
|
|
_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(
|
refactor(phase1): quake + swpc(Kp+flare) + avalanche, behind staged-cutover gate (#29)
* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe
Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.). It
also called pull_subscribe_bind() without await, making the fetch a no-op.
Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds. Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.
Adds live captured fixtures:
- tests/fixtures/quake/ — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/ — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate
First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.
- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
(geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
_storm stays legacy; geomag 600s window re-homed off the module-global into
gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
(avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
emit canonical Event.data (avalanche stops precomposing). env/store.py generic
native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
bind); real quake/swpc fixtures captured; avalanche synthetic.
Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:58:46 -06:00
|
|
|
self._CATEGORY,
|
2026-07-04 12:55:48 -06:00
|
|
|
_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(
|
refactor(phase1): quake + swpc(Kp+flare) + avalanche, behind staged-cutover gate (#29)
* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe
Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.). It
also called pull_subscribe_bind() without await, making the fetch a no-op.
Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds. Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.
Adds live captured fixtures:
- tests/fixtures/quake/ — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/ — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate
First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.
- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
(geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
_storm stays legacy; geomag 600s window re-homed off the module-global into
gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
(avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
emit canonical Event.data (avalanche stops precomposing). env/store.py generic
native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
bind); real quake/swpc fixtures captured; avalanche synthetic.
Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:58:46 -06:00
|
|
|
self._CATEGORY,
|
2026-07-04 12:55:48 -06:00
|
|
|
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()
|
fix(shadow): forward `source` kwarg to gating decider in shadow_gate (#31)
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:37:12 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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"
|