meshai/work/tests/test_avalanche_refactor.py

606 lines
25 KiB
Python
Raw Normal View History

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
"""Phase-1 avalanche refactor tests — formatter+decider architecture.
chore: excise the dead Central NATS consumer path (-11,328 LOC) (#144) * chore: excise the dead Central NATS consumer path Central was retired and its database dropped 2026-07-15; its NATS broker no longer exists. Verified against the live CT108 deployment: all 12 adapters run feed_source=native, zero on central, and central.enabled is False (default, never overridden). The consumer and its handlers were unreachable. Removed: - central/consumer.py and 6 dead handlers (nws, quake, swpc, nwis, avy, incident) -- their handle_* entrypoints were reachable only from the consumer's dispatch - the Central wiring in main.py (init, guarded start, retry loop, stop path) - the dead config surface: CentralConsumerConfig, EnvironmentalConfig.central, adapter_config ("central","severity_thresholds") and its display block - the nats-py dependency (consumer.py was its only importer) - 19 test files that exercised only the dead path KEPT -- these live under central/ but are imported directly by native adapters, and deleting them would break production: - wfigs_handler.py: firms_handler._handle_pass_boundary() calls its _render() on the live FIRMS growth-fire path (env/firms.py -> ingest_hotspot_pixel) - firms_handler, satpass_handler, tle_handler: split files whose handle_* entrypoints are dead but whose engines are live. Left intact; splitting them is separate work. - pass_predictor, budget, idaho_gauge_sites: fully live. The usgs_quake keys global_mag_floor / regional_mag_floor / regional_centroid / regional_radius_mi / broadcast_pager_alerts are NOT removed despite comments labelling them "CENTRAL-PATH ONLY" -- notifications/gating/quake.py reads them unconditionally in the native path. Those comments are corrected separately. Test-count note: the suite drops ~425 tests. Most were migration PARITY tests whose sole purpose was proving the native rewrite byte-matched the Central handler (golden byte-parity, cross-source identity, gate-sequence replay). With the handler deleted there is nothing left to compare against, so they cannot exist. Native-only tests were kept and reworked where a test reached for a central symbol incidentally. This is a real coverage loss, accepted deliberately: the parity harness proved the refactor faithful, and git history preserves the originals. Suite: 1984 passed, 6 failed -- the same 6 pre-existing failures as main (stale SCHEMA_VERSION x3, expired TLE fixtures x2, one order-dependent), all being fixed on fix/green-test-suite. No new failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(nws): restore native-only golden coverage for the wire formatter Commit ca751fb5 deleted the Central nws_handler parity harness along with the handler itself, which took the ONLY tests that pinned formatters.nws .format()'s literal wire output. Gate-sequence and schema-conformance tests already survived natively; the formatter's actual rendered text did not have any native-only regression net. Add TestFormatterGolden to test_nws_refactor.py: 3 real-fixture cases plus 6 hand-built pathological cases mined from the deleted test_nws_handler.py (SVR path-sampling, the "no dangling separator" regression, TOR on-ground vs radar-indicated, FFW flood-cause detection). Every literal was verified by temporarily restoring the pre-excision central.nws_handler._render() from git history (ca751fb5^) in a throwaway, uncommitted script, confirming byte-identical output against the current native format() for all 37 real fixtures (nws/ + nws_last/) and all 9 pathological cases, then pinning the confirmed-matching string as the literal -- not a blind snapshot of current behavior. quake/swpc/avalanche/hydro/incident/fire were checked and already carry equivalent native-only golden coverage (added directly in ca751fb5), so no changes were needed there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:03:40 -06:00
The Central `avy_handler` module (and its centralseverityNAADS remap,
`handle_avy()`, `_render()`) has been deleted the native path is the only
production path now. Pure old-vs-new parity tests and tests of
Central-only logic (`_remap_centralseverity`, `handle_avy`) have been
removed; original diffs are preserved in git history. What remains
exercises native code directly (hand-written expected strings are kept as
regression pins on the current wire format).
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
chore: excise the dead Central NATS consumer path (-11,328 LOC) (#144) * chore: excise the dead Central NATS consumer path Central was retired and its database dropped 2026-07-15; its NATS broker no longer exists. Verified against the live CT108 deployment: all 12 adapters run feed_source=native, zero on central, and central.enabled is False (default, never overridden). The consumer and its handlers were unreachable. Removed: - central/consumer.py and 6 dead handlers (nws, quake, swpc, nwis, avy, incident) -- their handle_* entrypoints were reachable only from the consumer's dispatch - the Central wiring in main.py (init, guarded start, retry loop, stop path) - the dead config surface: CentralConsumerConfig, EnvironmentalConfig.central, adapter_config ("central","severity_thresholds") and its display block - the nats-py dependency (consumer.py was its only importer) - 19 test files that exercised only the dead path KEPT -- these live under central/ but are imported directly by native adapters, and deleting them would break production: - wfigs_handler.py: firms_handler._handle_pass_boundary() calls its _render() on the live FIRMS growth-fire path (env/firms.py -> ingest_hotspot_pixel) - firms_handler, satpass_handler, tle_handler: split files whose handle_* entrypoints are dead but whose engines are live. Left intact; splitting them is separate work. - pass_predictor, budget, idaho_gauge_sites: fully live. The usgs_quake keys global_mag_floor / regional_mag_floor / regional_centroid / regional_radius_mi / broadcast_pager_alerts are NOT removed despite comments labelling them "CENTRAL-PATH ONLY" -- notifications/gating/quake.py reads them unconditionally in the native path. Those comments are corrected separately. Test-count note: the suite drops ~425 tests. Most were migration PARITY tests whose sole purpose was proving the native rewrite byte-matched the Central handler (golden byte-parity, cross-source identity, gate-sequence replay). With the handler deleted there is nothing left to compare against, so they cannot exist. Native-only tests were kept and reworked where a test reached for a central symbol incidentally. This is a real coverage loss, accepted deliberately: the parity harness proved the refactor faithful, and git history preserves the originals. Suite: 1984 passed, 6 failed -- the same 6 pre-existing failures as main (stale SCHEMA_VERSION x3, expired TLE fixtures x2, one order-dependent), all being fixed on fix/green-test-suite. No new failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(nws): restore native-only golden coverage for the wire formatter Commit ca751fb5 deleted the Central nws_handler parity harness along with the handler itself, which took the ONLY tests that pinned formatters.nws .format()'s literal wire output. Gate-sequence and schema-conformance tests already survived natively; the formatter's actual rendered text did not have any native-only regression net. Add TestFormatterGolden to test_nws_refactor.py: 3 real-fixture cases plus 6 hand-built pathological cases mined from the deleted test_nws_handler.py (SVR path-sampling, the "no dangling separator" regression, TOR on-ground vs radar-indicated, FFW flood-cause detection). Every literal was verified by temporarily restoring the pre-excision central.nws_handler._render() from git history (ca751fb5^) in a throwaway, uncommitted script, confirming byte-identical output against the current native format() for all 37 real fixtures (nws/ + nws_last/) and all 9 pathological cases, then pinning the confirmed-matching string as the literal -- not a blind snapshot of current behavior. quake/swpc/avalanche/hydro/incident/fire were checked and already carry equivalent native-only golden coverage (added directly in ca751fb5), so no changes were needed there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:03:40 -06:00
Three test groups:
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
chore: excise the dead Central NATS consumer path (-11,328 LOC) (#144) * chore: excise the dead Central NATS consumer path Central was retired and its database dropped 2026-07-15; its NATS broker no longer exists. Verified against the live CT108 deployment: all 12 adapters run feed_source=native, zero on central, and central.enabled is False (default, never overridden). The consumer and its handlers were unreachable. Removed: - central/consumer.py and 6 dead handlers (nws, quake, swpc, nwis, avy, incident) -- their handle_* entrypoints were reachable only from the consumer's dispatch - the Central wiring in main.py (init, guarded start, retry loop, stop path) - the dead config surface: CentralConsumerConfig, EnvironmentalConfig.central, adapter_config ("central","severity_thresholds") and its display block - the nats-py dependency (consumer.py was its only importer) - 19 test files that exercised only the dead path KEPT -- these live under central/ but are imported directly by native adapters, and deleting them would break production: - wfigs_handler.py: firms_handler._handle_pass_boundary() calls its _render() on the live FIRMS growth-fire path (env/firms.py -> ingest_hotspot_pixel) - firms_handler, satpass_handler, tle_handler: split files whose handle_* entrypoints are dead but whose engines are live. Left intact; splitting them is separate work. - pass_predictor, budget, idaho_gauge_sites: fully live. The usgs_quake keys global_mag_floor / regional_mag_floor / regional_centroid / regional_radius_mi / broadcast_pager_alerts are NOT removed despite comments labelling them "CENTRAL-PATH ONLY" -- notifications/gating/quake.py reads them unconditionally in the native path. Those comments are corrected separately. Test-count note: the suite drops ~425 tests. Most were migration PARITY tests whose sole purpose was proving the native rewrite byte-matched the Central handler (golden byte-parity, cross-source identity, gate-sequence replay). With the handler deleted there is nothing left to compare against, so they cannot exist. Native-only tests were kept and reworked where a test reached for a central symbol incidentally. This is a real coverage loss, accepted deliberately: the parity harness proved the refactor faithful, and git history preserves the originals. Suite: 1984 passed, 6 failed -- the same 6 pre-existing failures as main (stale SCHEMA_VERSION x3, expired TLE fixtures x2, one order-dependent), all being fixed on fix/green-test-suite. No new failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(nws): restore native-only golden coverage for the wire formatter Commit ca751fb5 deleted the Central nws_handler parity harness along with the handler itself, which took the ONLY tests that pinned formatters.nws .format()'s literal wire output. Gate-sequence and schema-conformance tests already survived natively; the formatter's actual rendered text did not have any native-only regression net. Add TestFormatterGolden to test_nws_refactor.py: 3 real-fixture cases plus 6 hand-built pathological cases mined from the deleted test_nws_handler.py (SVR path-sampling, the "no dangling separator" regression, TOR on-ground vs radar-indicated, FFW flood-cause detection). Every literal was verified by temporarily restoring the pre-excision central.nws_handler._render() from git history (ca751fb5^) in a throwaway, uncommitted script, confirming byte-identical output against the current native format() for all 37 real fixtures (nws/ + nws_last/) and all 9 pathological cases, then pinning the confirmed-matching string as the literal -- not a blind snapshot of current behavior. quake/swpc/avalanche/hydro/incident/fire were checked and already carry equivalent native-only golden coverage (added directly in ca751fb5), so no changes were needed there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:03:40 -06:00
1. Parity (tier-b): formatter renders from canonical data. Expected
strings are hand-written (the current correct format).
2. Cross-source identity: native AvalancheAdapter.to_event() canonical data
renders correctly via the registered formatter.
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
3. Gate-sequence: replay canonical data through gating.avalanche.decide().
Verify danger-level gate (below / at / above threshold) and firstupdate
trend lifecycle labels.
4. Schema-conformance: env/avalanche.py to_event() emits all canonical keys;
_meshai_precomposed is NOT set.
"""
from __future__ import annotations
import pytest
from tests.harness.goldens import (
assert_byte_identical,
load_fixtures,
pinned_time,
)
# ── Shared clock epoch for deterministic renders ─────────────────────────────
_AT = 1_783_200_000.0 # 2026-07-03T00:00:00Z (pinned, same as quake tests)
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _make_fake_event(data: dict):
"""Minimal fake Event for calling the formatter without the full pipeline."""
class _FakeEvent:
pass
e = _FakeEvent()
e.data = data
return e
def _canonical_from_fixture(fixture: dict, *, is_update: bool = False) -> dict:
"""Extract canonical data dict from a Central avalanche envelope fixture."""
inner = fixture["envelope"]["data"]
d = inner.get("data") or {}
geo = inner.get("geo") or {}
centroid = geo.get("centroid") or []
lon, lat = (centroid[0], centroid[1]) if len(centroid) >= 2 else (None, None)
return {
"danger_level": d.get("danger_level"),
"danger_name": d.get("danger_name"),
"zone_name": d.get("zone_name"),
"center_id": d.get("center_id"),
"travel_advice": d.get("travel_advice"),
"lat": lat,
"lon": lon,
"is_update": is_update,
}
# ─────────────────────────────────────────────────────────────────────────────
# 1. Parity (tier-b) — formatter renders from canonical data
# ─────────────────────────────────────────────────────────────────────────────
class TestFormatterParity:
"""formatters/avalanche.format() renders correct output from canonical data."""
def test_fixture_0000_considerable_new_format(self):
"""Fixture 0000 (Considerable, NAADS 3) → formatter output matches expected.
centralseverity=2 maps to NAADS 3 (Considerable).
min_danger_level=3 broadcast.
is_update=False "Watch:" prefix.
OLD _render() output:
'⛷ AVY Watch: Sawtooth Mountains — Considerable (3)
Dangerous conditions on steep slopes.
SNFAC · valid today'
NEW formatter output (identical no tier-b diff for non-update case):
'⛷ AVY Watch: Sawtooth Mountains — Considerable (3)
Dangerous conditions on steep slopes.
SNFAC · valid today'
"""
from meshai.notifications.formatters.avalanche import format as avyfmt
fixtures = load_fixtures("avalanche")
fx = next(f for f in fixtures
if "considerable" in f["envelope"]["id"] and "update" not in f["envelope"]["id"])
canonical = _canonical_from_fixture(fx)
expected = (
"⛷ AVY Watch: Sawtooth Mountains — Considerable (3)"
"\nDangerous conditions on steep slopes."
"\nSNFAC · valid today"
)
with pinned_time(_AT):
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
assert_byte_identical(result, expected)
def test_fixture_0001_high_warning_prefix(self):
"""Fixture 0001 (High, NAADS 4) → formatter uses 'WARNING:' prefix.
OLD _render() output:
'⛷ AVY WARNING: Banner Summit — High (4)
Avoid all avalanche terrain today.
SNFAC · valid today'
NEW formatter output (identical WARNING prefix unchanged):
'⛷ AVY WARNING: Banner Summit — High (4)
Avoid all avalanche terrain today.
SNFAC · valid today'
"""
from meshai.notifications.formatters.avalanche import format as avyfmt
fixtures = load_fixtures("avalanche")
fx = next(f for f in fixtures if "banner" in f["envelope"]["id"])
canonical = _canonical_from_fixture(fx)
expected = (
"⛷ AVY WARNING: Banner Summit — High (4)"
"\nAvoid all avalanche terrain today."
"\nSNFAC · valid today"
)
with pinned_time(_AT):
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
assert_byte_identical(result, expected)
def test_fixture_0002_extreme_warning_prefix(self):
"""Fixture 0002 (Extreme, NAADS 5) → formatter uses 'WARNING:' prefix.
OLD _render() output:
'⛷ AVY WARNING: Soldier Mountains — Extreme (5)
Avoid all avalanche terrain!
SNFAC · valid today'
NEW formatter output (identical):
'⛷ AVY WARNING: Soldier Mountains — Extreme (5)
Avoid all avalanche terrain!
SNFAC · valid today'
"""
from meshai.notifications.formatters.avalanche import format as avyfmt
fixtures = load_fixtures("avalanche")
fx = next(f for f in fixtures if "soldier" in f["envelope"]["id"])
canonical = _canonical_from_fixture(fx)
expected = (
"⛷ AVY WARNING: Soldier Mountains — Extreme (5)"
"\nAvoid all avalanche terrain!"
"\nSNFAC · valid today"
)
with pinned_time(_AT):
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
assert_byte_identical(result, expected)
def test_tier_b_update_prefix_rendered(self):
chore: excise the dead Central NATS consumer path (-11,328 LOC) (#144) * chore: excise the dead Central NATS consumer path Central was retired and its database dropped 2026-07-15; its NATS broker no longer exists. Verified against the live CT108 deployment: all 12 adapters run feed_source=native, zero on central, and central.enabled is False (default, never overridden). The consumer and its handlers were unreachable. Removed: - central/consumer.py and 6 dead handlers (nws, quake, swpc, nwis, avy, incident) -- their handle_* entrypoints were reachable only from the consumer's dispatch - the Central wiring in main.py (init, guarded start, retry loop, stop path) - the dead config surface: CentralConsumerConfig, EnvironmentalConfig.central, adapter_config ("central","severity_thresholds") and its display block - the nats-py dependency (consumer.py was its only importer) - 19 test files that exercised only the dead path KEPT -- these live under central/ but are imported directly by native adapters, and deleting them would break production: - wfigs_handler.py: firms_handler._handle_pass_boundary() calls its _render() on the live FIRMS growth-fire path (env/firms.py -> ingest_hotspot_pixel) - firms_handler, satpass_handler, tle_handler: split files whose handle_* entrypoints are dead but whose engines are live. Left intact; splitting them is separate work. - pass_predictor, budget, idaho_gauge_sites: fully live. The usgs_quake keys global_mag_floor / regional_mag_floor / regional_centroid / regional_radius_mi / broadcast_pager_alerts are NOT removed despite comments labelling them "CENTRAL-PATH ONLY" -- notifications/gating/quake.py reads them unconditionally in the native path. Those comments are corrected separately. Test-count note: the suite drops ~425 tests. Most were migration PARITY tests whose sole purpose was proving the native rewrite byte-matched the Central handler (golden byte-parity, cross-source identity, gate-sequence replay). With the handler deleted there is nothing left to compare against, so they cannot exist. Native-only tests were kept and reworked where a test reached for a central symbol incidentally. This is a real coverage loss, accepted deliberately: the parity harness proved the refactor faithful, and git history preserves the originals. Suite: 1984 passed, 6 failed -- the same 6 pre-existing failures as main (stale SCHEMA_VERSION x3, expired TLE fixtures x2, one order-dependent), all being fixed on fix/green-test-suite. No new failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(nws): restore native-only golden coverage for the wire formatter Commit ca751fb5 deleted the Central nws_handler parity harness along with the handler itself, which took the ONLY tests that pinned formatters.nws .format()'s literal wire output. Gate-sequence and schema-conformance tests already survived natively; the formatter's actual rendered text did not have any native-only regression net. Add TestFormatterGolden to test_nws_refactor.py: 3 real-fixture cases plus 6 hand-built pathological cases mined from the deleted test_nws_handler.py (SVR path-sampling, the "no dangling separator" regression, TOR on-ground vs radar-indicated, FFW flood-cause detection). Every literal was verified by temporarily restoring the pre-excision central.nws_handler._render() from git history (ca751fb5^) in a throwaway, uncommitted script, confirming byte-identical output against the current native format() for all 37 real fixtures (nws/ + nws_last/) and all 9 pathological cases, then pinning the confirmed-matching string as the literal -- not a blind snapshot of current behavior. quake/swpc/avalanche/hydro/incident/fire were checked and already carry equivalent native-only golden coverage (added directly in ca751fb5), so no changes were needed there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:03:40 -06:00
"""Tier-b: is_update=True produces 'AVY Update:' prefix."""
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
from meshai.notifications.formatters.avalanche import format as avyfmt
fixtures = load_fixtures("avalanche")
fx = next(f for f in fixtures if "update" in f["envelope"]["id"])
# Extract with is_update=True
canonical = _canonical_from_fixture(fx, is_update=True)
expected_new = (
"⛷ AVY Update: Sawtooth Mountains — Considerable (3)"
"\nDangerous conditions on steep slopes."
"\nSNFAC · valid today"
)
with pinned_time(_AT):
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
assert_byte_identical(result, expected_new)
assert "Update:" in result
assert "Watch:" not in result
def test_formatter_budget_respected(self):
"""Formatter output fits within budget for worst-case zone/advice strings."""
from meshai.notifications.formatters.avalanche import format as avyfmt
canonical = {
"danger_level": 5,
"danger_name": "Extreme",
"zone_name": "A very long zone name that might cause budget overflow",
"center_id": "SNFAC",
"travel_advice": (
"Avoid all avalanche terrain! Very dangerous conditions exist "
"across all elevations and aspects. Natural avalanches certain."
),
"lat": 43.5,
"lon": -115.4,
"is_update": False,
}
with pinned_time(_AT):
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
assert len(result) <= 140, f"{len(result)} chars:\n{result!r}"
assert "AVY WARNING" in result
def test_no_travel_advice_still_renders(self):
"""Formatter renders cleanly when travel_advice is absent."""
from meshai.notifications.formatters.avalanche import format as avyfmt
canonical = {
"danger_level": 3,
"danger_name": "Considerable",
"zone_name": "Wood River Valley",
"center_id": "SNFAC",
"travel_advice": "",
"lat": 43.4,
"lon": -114.3,
"is_update": False,
}
with pinned_time(_AT):
result = avyfmt(_make_fake_event(canonical), now=_AT, budget=140)
assert "AVY Watch" in result
assert "Wood River Valley" in result
assert "SNFAC" in result
assert "\n\n" not in result # no blank lines
# ─────────────────────────────────────────────────────────────────────────────
# 2. Cross-source identity — native and Central produce identical renders
# ─────────────────────────────────────────────────────────────────────────────
class TestCrossSourceIdentity:
"""Native to_event() canonical data renders byte-identically to Central path."""
def test_native_central_render_identical_fixture_0000(self):
"""Canonical data from native and Central for fixture 0000 renders identically.
The Central path extracts from the envelope.
The native path produces the same canonical keys via to_event().
The formatter reads the same keys from both same wire.
"""
from meshai.notifications.formatters.avalanche import format as avyfmt
# Central-path canonical data (hand-extracted from fixture 0000)
central_canonical = {
"danger_level": 3,
"danger_name": "Considerable",
"zone_name": "Sawtooth Mountains",
"center_id": "SNFAC",
"travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.",
"lat": 43.8,
"lon": -114.9,
"is_update": False,
}
# Native-path canonical data (as to_event() would populate event.data)
native_canonical = {
"danger_level": 3,
"danger_name": "Considerable",
"zone_name": "Sawtooth Mountains",
"center_id": "SNFAC",
"travel_advice": "Dangerous conditions on steep slopes. Conservative decision-making is advised.",
"lat": 43.8,
"lon": -114.9,
"is_update": False,
"event_id": "avy_SNFAC_sawtooth_mountains",
}
with pinned_time(_AT):
central_wire = avyfmt(_make_fake_event(central_canonical),
now=_AT, budget=140)
native_wire = avyfmt(_make_fake_event(native_canonical),
now=_AT, budget=140)
assert_byte_identical(native_wire, central_wire), (
f"Native and Central renders must be byte-identical:\n"
f" Central: {central_wire!r}\n"
f" Native: {native_wire!r}"
)
def test_native_to_event_uses_canonical_keys(self):
"""to_event() event.data has the canonical keys the formatter reads."""
from unittest.mock import MagicMock
from meshai.env.avalanche import AvalancheAdapter
cfg = MagicMock()
cfg.center_ids = ["SNFAC"]
cfg.tick_seconds = 1800
cfg.season_months = [12, 1, 2, 3, 4]
adapter = AvalancheAdapter(cfg)
import time
now = time.time()
raw_evt = {
"source": "avalanche",
"event_id": "avy_SNFAC_sawtooth_mountains",
"event_type": "Avalanche Advisory",
"severity": "routine",
"headline": "Sawtooth Mountains: Considerable avalanche danger",
"zone_name": "Sawtooth Mountains",
"center": "Sawtooth Avalanche Center",
"center_id": "SNFAC",
"center_link": "https://www.sawtoothavalanche.com",
"forecast_link": "https://www.sawtoothavalanche.com/forecast",
"danger": "considerable",
"danger_level": 3,
"danger_name": "Considerable",
"travel_advice": "Dangerous conditions on steep slopes.",
"state": "ID",
"lat": 43.8,
"lon": -114.9,
"expires": now + 3600,
"fetched_at": now,
}
event = adapter.to_event(raw_evt)
assert event is not None, "to_event() must return an Event for valid input"
assert event.data is not None, "event.data must not be None"
canonical_keys = {
"danger_level", "danger_name", "zone_name", "center_id",
"travel_advice", "lat", "lon", "is_update", "event_id",
}
missing = canonical_keys - set(event.data.keys())
assert not missing, (
f"to_event() event.data missing canonical keys: {missing}\n"
f"Got keys: {sorted(event.data.keys())}"
)
# Spot-check values
assert event.data["danger_level"] == 3
assert event.data["danger_name"] == "Considerable"
assert event.data["zone_name"] == "Sawtooth Mountains"
assert event.data["center_id"] == "SNFAC"
assert event.data["is_update"] is False
assert event.data["event_id"] == "avy_SNFAC_sawtooth_mountains"
def test_native_to_event_no_precomposed(self):
"""to_event() must NOT set _meshai_precomposed in event.data (formatter governs)."""
from unittest.mock import MagicMock
from meshai.env.avalanche import AvalancheAdapter
cfg = MagicMock()
cfg.center_ids = ["SNFAC"]
cfg.tick_seconds = 1800
cfg.season_months = [12, 1, 2, 3, 4]
adapter = AvalancheAdapter(cfg)
import time
now = time.time()
raw_evt = {
"source": "avalanche",
"event_id": "avy_SNFAC_sawtooth_mountains",
"event_type": "Avalanche Advisory",
"severity": "routine",
"headline": "test",
"zone_name": "Sawtooth Mountains",
"center": "SNFAC",
"center_id": "SNFAC",
"center_link": "",
"forecast_link": "",
"danger": "considerable",
"danger_level": 3,
"danger_name": "Considerable",
"travel_advice": "Dangerous conditions.",
"state": "ID",
"lat": 43.8,
"lon": -114.9,
"expires": now + 3600,
"fetched_at": now,
}
event = adapter.to_event(raw_evt)
assert event is not None
assert not event.data.get("_meshai_precomposed"), (
"event.data must NOT have _meshai_precomposed=True — "
"the formatter registry governs rendering"
)
# ─────────────────────────────────────────────────────────────────────────────
# 3. Gate-sequence — danger-level gate + first→update trend
# ─────────────────────────────────────────────────────────────────────────────
class TestGateSequence:
"""gating.avalanche.decide() gate + lifecycle decisions."""
def _decide(self, danger_level: int, *, is_update: bool = False):
from meshai.notifications.gating.avalanche import decide
data = {
"danger_level": danger_level,
"danger_name": {1: "Low", 2: "Moderate", 3: "Considerable",
4: "High", 5: "Extreme"}.get(danger_level, "?"),
"zone_name": "Test Zone",
"center_id": "SNFAC",
"travel_advice": "Test travel advice.",
"lat": 43.8,
"lon": -114.9,
"is_update": is_update,
}
return decide(data, source="avalanche", now=_AT)
def test_level_below_threshold_suppressed(self):
"""danger_level=2 (Moderate) is below min_danger_level=3 → suppress."""
result = self._decide(2)
assert result.broadcast is False
assert result.lifecycle == "suppress"
def test_level_at_threshold_broadcast(self):
"""danger_level=3 (Considerable) equals min_danger_level=3 → broadcast."""
result = self._decide(3)
assert result.broadcast is True
assert result.lifecycle == "new"
def test_level_above_threshold_broadcast(self):
"""danger_level=4 (High) above min_danger_level=3 → broadcast + priority."""
result = self._decide(4)
assert result.broadcast is True
assert result.lifecycle == "new"
assert result.data_patch.get("_severity_override") == "priority"
def test_extreme_broadcast_priority(self):
"""danger_level=5 (Extreme) → broadcast + priority."""
result = self._decide(5)
assert result.broadcast is True
assert result.data_patch.get("_severity_override") == "priority"
def test_considerable_no_priority_override(self):
"""danger_level=3 (Considerable) → broadcast, _severity_override=None."""
result = self._decide(3)
assert result.broadcast is True
assert result.data_patch.get("_severity_override") is None
def test_first_sighting_lifecycle_new(self):
"""First event (is_update=False) → lifecycle='new'."""
result = self._decide(3, is_update=False)
assert result.broadcast is True
assert result.lifecycle == "new"
assert result.data_patch.get("is_update") is False
def test_update_trend_lifecycle_update(self):
"""Subsequent event (is_update=True) → lifecycle='update'."""
result = self._decide(3, is_update=True)
assert result.broadcast is True
assert result.lifecycle == "update"
assert result.data_patch.get("is_update") is True
def test_missing_danger_level_suppresses(self):
"""Missing danger_level in canonical data → suppress."""
from meshai.notifications.gating.avalanche import decide
result = decide({"zone_name": "test"}, source="avalanche", now=_AT)
assert result.broadcast is False
def test_is_update_propagated_into_data_patch(self):
"""decide() data_patch.is_update reflects the incoming is_update flag."""
from meshai.notifications.gating.avalanche import decide
result_new = decide(
{"danger_level": 3, "zone_name": "Z", "center_id": "C",
"travel_advice": "", "is_update": False},
source="avalanche", now=_AT,
)
assert result_new.data_patch["is_update"] is False
result_update = decide(
{"danger_level": 3, "zone_name": "Z", "center_id": "C",
"travel_advice": "", "is_update": True},
source="avalanche", now=_AT,
)
assert result_update.data_patch["is_update"] is True
# ─────────────────────────────────────────────────────────────────────────────
# 4. Schema conformance — to_event() canonical data completeness
# ─────────────────────────────────────────────────────────────────────────────
class TestSchemaConformance:
"""env/avalanche.py to_event() emits canonical keys without _meshai_precomposed."""
@pytest.fixture
def adapter(self):
from unittest.mock import MagicMock
from meshai.env.avalanche import AvalancheAdapter
cfg = MagicMock()
cfg.center_ids = ["SNFAC"]
cfg.tick_seconds = 1800
cfg.season_months = [12, 1, 2, 3, 4]
return AvalancheAdapter(cfg)
CANONICAL_KEYS = frozenset({
"danger_level", "danger_name", "zone_name", "center_id",
"travel_advice", "lat", "lon", "is_update", "event_id",
})
def _raw_evt(self, **overrides):
import time
base = {
"source": "avalanche",
"event_id": "avy_SNFAC_sawtooth_mountains",
"event_type": "Avalanche Advisory",
"severity": "routine",
"headline": "test",
"zone_name": "Sawtooth Mountains",
"center": "Sawtooth Avalanche Center",
"center_id": "SNFAC",
"center_link": "",
"forecast_link": "",
"danger": "considerable",
"danger_level": 3,
"danger_name": "Considerable",
"travel_advice": "Dangerous conditions on steep slopes.",
"state": "ID",
"lat": 43.8,
"lon": -114.9,
"expires": time.time() + 3600,
"fetched_at": time.time(),
}
base.update(overrides)
return base
def test_all_canonical_keys_present(self, adapter):
"""event.data contains all canonical schema keys."""
event = adapter.to_event(self._raw_evt())
assert event is not None
missing = self.CANONICAL_KEYS - set(event.data.keys())
assert not missing, f"Missing canonical keys: {missing}"
def test_no_precomposed_flag(self, adapter):
"""event.data must NOT have _meshai_precomposed — formatter governs."""
event = adapter.to_event(self._raw_evt())
assert event is not None
assert not event.data.get("_meshai_precomposed"), (
"event.data['_meshai_precomposed'] must be falsy/absent; "
"formatter registry governs dispatch rendering"
)
def test_danger_level_is_int(self, adapter):
"""event.data['danger_level'] is a Python int (not float or str)."""
event = adapter.to_event(self._raw_evt(danger_level=3))
assert event is not None
assert isinstance(event.data["danger_level"], int)
assert event.data["danger_level"] == 3
def test_is_update_defaults_false(self, adapter):
"""is_update=False when _is_update not set in the raw event dict."""
event = adapter.to_event(self._raw_evt())
assert event is not None
assert event.data["is_update"] is False
def test_is_update_propagated_from_store(self, adapter):
"""_is_update=True from EnvironmentalStore propagates to canonical is_update."""
raw = self._raw_evt()
raw["_is_update"] = True
event = adapter.to_event(raw)
assert event is not None
assert event.data["is_update"] is True
def test_event_id_in_canonical_data(self, adapter):
"""event.data['event_id'] matches the source event_id."""
event = adapter.to_event(self._raw_evt(event_id="avy_test_id"))
assert event is not None
assert event.data["event_id"] == "avy_test_id"
def test_formatter_renders_from_canonical_data(self, adapter):
"""Formatter reads canonical event.data and renders a valid wire string."""
from meshai.notifications.formatters.avalanche import format as avyfmt
event = adapter.to_event(self._raw_evt())
assert event is not None
with pinned_time(_AT):
result = avyfmt(event, now=_AT, budget=140)
assert result is not None
assert len(result) <= 140
assert "AVY Watch" in result
assert "Sawtooth Mountains" in result
assert "Considerable (3)" in result
def test_formatter_registered_for_avalanche_categories(self):
"""Both avalanche_warning and avalanche_watch have registered formatters."""
from meshai.notifications.formatters import get_formatter
fmt_warning = get_formatter("avalanche_warning")
assert fmt_warning is not None, "avalanche_warning must have a registered formatter"
fmt_watch = get_formatter("avalanche_watch")
assert fmt_watch is not None, "avalanche_watch must have a registered formatter"
def test_decider_registered_for_avalanche_categories(self):
"""Both avalanche_warning and avalanche_watch have registered deciders."""
from meshai.notifications.gating import get_decider
dec_warning = get_decider("avalanche_warning")
assert dec_warning is not None, "avalanche_warning must have a registered decider"
dec_watch = get_decider("avalanche_watch")
assert dec_watch is not None, "avalanche_watch must have a registered decider"