mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
742 lines
31 KiB
Python
742 lines
31 KiB
Python
|
|
"""Phase-1 quake refactor tests — reference implementation verification.
|
|||
|
|
|
|||
|
|
Four test groups:
|
|||
|
|
|
|||
|
|
1. Parity (tier-b): fixture 0002 → canonical data → formatter.
|
|||
|
|
Expected string is hand-written (the new correct format).
|
|||
|
|
The OLD _render() output for the same fixture is captured in a comment so
|
|||
|
|
the intended tier-b diff is explicit and reviewable.
|
|||
|
|
Two synthetic cases show the PAGER + update-prefix diffs explicitly.
|
|||
|
|
|
|||
|
|
2. Cross-source identity: native adapter builds the same canonical data as
|
|||
|
|
the Central path for fixture 0002. Both render byte-identically.
|
|||
|
|
|
|||
|
|
3. Gate-sequence: replay four synthetic events through the OLD handle_quake
|
|||
|
|
gating and the NEW gating.quake.decide(); assert broadcast/suppress match.
|
|||
|
|
|
|||
|
|
4. Schema-conformance: env/usgs_quake.py to_event() emits all canonical keys.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from meshai.persistence import close_thread_connection, init_db
|
|||
|
|
from meshai.persistence import db as persistence_db
|
|||
|
|
from tests.harness.goldens import (
|
|||
|
|
assert_byte_identical,
|
|||
|
|
load_fixtures,
|
|||
|
|
pinned_time,
|
|||
|
|
run_gate_sequence,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ── Shared clock epoch for deterministic renders ─────────────────────────────
|
|||
|
|
_AT = 1_783_200_000.0 # 2026-07-03T00:00:00Z (pinned)
|
|||
|
|
|
|||
|
|
# ── DB fixture shared by gate-sequence tests ─────────────────────────────────
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def mem_db(monkeypatch, tmp_path):
|
|||
|
|
db_path = str(tmp_path / "quake-refactor-test.sqlite")
|
|||
|
|
monkeypatch.setenv("MESHAI_DB_PATH", db_path)
|
|||
|
|
persistence_db._initialised.clear()
|
|||
|
|
close_thread_connection()
|
|||
|
|
conn = init_db()
|
|||
|
|
yield conn
|
|||
|
|
close_thread_connection()
|
|||
|
|
persistence_db._initialised.discard(db_path)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
# 1. Parity (tier-b) — formatter renders from canonical data
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestFormatterParity:
|
|||
|
|
"""formatter/quake.format() renders correct output from canonical data."""
|
|||
|
|
|
|||
|
|
def _render_old(self, *, mag, place, depth_km, lat, lon, tsunami, is_update=False):
|
|||
|
|
"""Capture OLD _render() output for diff comments."""
|
|||
|
|
from meshai.central.quake_handler import _render
|
|||
|
|
from meshai.central.budget import budget_for
|
|||
|
|
return _render(mag=mag, place=place, depth_km=depth_km, lat=lat,
|
|||
|
|
lon=lon, tsunami=tsunami, is_update=is_update)
|
|||
|
|
|
|||
|
|
def test_fixture_0002_new_format(self):
|
|||
|
|
"""Fixture 0002 (M3.3 Lima Montana) → NEW formatter output matches hand-written expected.
|
|||
|
|
|
|||
|
|
Fixture 0002 has alert=null and no tsunami so the tier-b additions
|
|||
|
|
(PAGER line, update-prefix) are not visible. The old and new outputs
|
|||
|
|
are IDENTICAL for this fixture — which is correct. The hand-written
|
|||
|
|
expected below documents the canonical format; synthetic tests below
|
|||
|
|
show the tier-b additions.
|
|||
|
|
|
|||
|
|
OLD _render() output (captured for diff transparency):
|
|||
|
|
"🌐 New: M3.3 — 19 km S of Lima, Montana\\nDepth: 11 km · @ 44.460, -112.611"
|
|||
|
|
NEW formatter output (same — no tier-b changes triggered):
|
|||
|
|
"🌐 New: M3.3 — 19 km S of Lima, Montana\\nDepth: 11 km · @ 44.460, -112.611"
|
|||
|
|
"""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
fixtures = load_fixtures("quake")
|
|||
|
|
fx = next(f for f in fixtures if f["envelope"]["id"] == "us6000t9bn")
|
|||
|
|
inner = fx["envelope"]["data"]
|
|||
|
|
d = inner["data"]
|
|||
|
|
geo = inner["geo"]
|
|||
|
|
cent = geo["centroid"] # [lon, lat]
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": d["magnitude"], # 3.3
|
|||
|
|
"depth_km": d["depth"], # 11.169 (raw USGS key)
|
|||
|
|
"lat": cent[1], # 44.46
|
|||
|
|
"lon": cent[0], # -112.6108
|
|||
|
|
"place": d["place"], # "19 km S of Lima, Montana"
|
|||
|
|
"tsunami": bool(d["tsunami"]), # False
|
|||
|
|
"pager": d.get("alert"), # None
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": fx["envelope"]["id"],
|
|||
|
|
"is_update": False,
|
|||
|
|
"_severity_override": None,
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 160.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Hand-written new correct format (tier-b changes are invisible here)
|
|||
|
|
expected = (
|
|||
|
|
"\U0001f310 New: M3.3 — 19 km S of Lima, Montana"
|
|||
|
|
"\nDepth: 11 km · @ 44.460, -112.611"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
|||
|
|
|
|||
|
|
assert_byte_identical(result, expected)
|
|||
|
|
|
|||
|
|
# Verify old _render matches new for this fixture (no tier-b diff)
|
|||
|
|
old_wire = self._render_old(
|
|||
|
|
mag=canonical["magnitude"], place=canonical["place"],
|
|||
|
|
depth_km=canonical["depth_km"], lat=canonical["lat"],
|
|||
|
|
lon=canonical["lon"], tsunami=canonical["tsunami"],
|
|||
|
|
is_update=False,
|
|||
|
|
)
|
|||
|
|
assert_byte_identical(result, old_wire), (
|
|||
|
|
"For fixture 0002 (null PAGER, is_update=False) old and new "
|
|||
|
|
"outputs must be identical — the tier-b diff only appears when "
|
|||
|
|
"PAGER or is_update are set."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_tier_b_pager_orange_rendered(self):
|
|||
|
|
"""Tier-b ①: PAGER=orange is NOW rendered on a 4th line.
|
|||
|
|
|
|||
|
|
OLD _render() output (captured):
|
|||
|
|
"🌐 New: M2.0 — Off the coast of Oregon\\nDepth: 10 km · @ 44.000, -125.000"
|
|||
|
|
NEW formatter output (tier-b change — PAGER line added):
|
|||
|
|
"🌐 New: M2.0 — Off the coast of Oregon\\nDepth: 10 km · @ 44.000, -125.000\\n⚠️ PAGER: orange"
|
|||
|
|
"""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": 2.0,
|
|||
|
|
"depth_km": 10.0,
|
|||
|
|
"lat": 44.0,
|
|||
|
|
"lon": -125.0,
|
|||
|
|
"place": "Off the coast of Oregon",
|
|||
|
|
"tsunami": False,
|
|||
|
|
"pager": "orange", # PAGER set — triggers tier-b line
|
|||
|
|
"is_update": False,
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": "test_pager_orange",
|
|||
|
|
"_severity_override": "immediate",
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 500.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# OLD _render() output (PAGER not rendered)
|
|||
|
|
old_wire = self._render_old(
|
|||
|
|
mag=2.0, place="Off the coast of Oregon", depth_km=10.0,
|
|||
|
|
lat=44.0, lon=-125.0, tsunami=False, is_update=False,
|
|||
|
|
)
|
|||
|
|
# NEW formatter output (PAGER rendered as 4th line)
|
|||
|
|
expected_new = (
|
|||
|
|
"\U0001f310 New: M2.0 — Off the coast of Oregon"
|
|||
|
|
"\nDepth: 10 km · @ 44.000, -125.000"
|
|||
|
|
"\n⚠️ PAGER: orange"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
|||
|
|
|
|||
|
|
assert_byte_identical(result, expected_new)
|
|||
|
|
# Confirm the old wire does NOT have the PAGER line
|
|||
|
|
assert "PAGER" not in old_wire, (
|
|||
|
|
f"OLD _render() must not contain PAGER line; got: {old_wire!r}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_tier_b_update_prefix_rendered(self):
|
|||
|
|
"""Tier-b ②: is_update=True produces 'Update:' prefix (was hard-coded 'New:').
|
|||
|
|
|
|||
|
|
OLD _render() output (is_update always False):
|
|||
|
|
"🌐 New: M3.0 — 5 km NE of Stanley, Idaho\\nDepth: 8 km · @ 44.200, -114.900"
|
|||
|
|
NEW formatter output (is_update=True):
|
|||
|
|
"🌐 Update: M3.0 — 5 km NE of Stanley, Idaho\\nDepth: 8 km · @ 44.200, -114.900"
|
|||
|
|
"""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": 3.0,
|
|||
|
|
"depth_km": 8.0,
|
|||
|
|
"lat": 44.2,
|
|||
|
|
"lon": -114.9,
|
|||
|
|
"place": "5 km NE of Stanley, Idaho",
|
|||
|
|
"tsunami": False,
|
|||
|
|
"pager": None,
|
|||
|
|
"is_update": True, # tier-b: update-prefix now live
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": "test_update_prefix",
|
|||
|
|
"_severity_override": None,
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 10.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# OLD _render() always uses is_update=False
|
|||
|
|
old_wire = self._render_old(
|
|||
|
|
mag=3.0, place="5 km NE of Stanley, Idaho", depth_km=8.0,
|
|||
|
|
lat=44.2, lon=-114.9, tsunami=False, is_update=False,
|
|||
|
|
)
|
|||
|
|
expected_new = (
|
|||
|
|
"\U0001f310 Update: M3.0 — 5 km NE of Stanley, Idaho"
|
|||
|
|
"\nDepth: 8 km · @ 44.200, -114.900"
|
|||
|
|
)
|
|||
|
|
expected_old = (
|
|||
|
|
"\U0001f310 New: M3.0 — 5 km NE of Stanley, Idaho"
|
|||
|
|
"\nDepth: 8 km · @ 44.200, -114.900"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
|||
|
|
|
|||
|
|
assert_byte_identical(result, expected_new)
|
|||
|
|
assert_byte_identical(old_wire, expected_old)
|
|||
|
|
assert "Update:" in result
|
|||
|
|
assert "New:" not in result
|
|||
|
|
|
|||
|
|
def test_tsunami_escalation_preserved(self):
|
|||
|
|
"""Tsunami escalation (🚨 emoji + TSUNAMI WARNING line) unchanged from _render."""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": 4.5,
|
|||
|
|
"depth_km": 5.0,
|
|||
|
|
"lat": 35.0,
|
|||
|
|
"lon": 141.0,
|
|||
|
|
"place": "off the coast of Japan",
|
|||
|
|
"tsunami": True,
|
|||
|
|
"pager": None,
|
|||
|
|
"is_update": False,
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": "test_tsunami",
|
|||
|
|
"_severity_override": "immediate",
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 8000.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
|||
|
|
|
|||
|
|
old_wire = self._render_old(
|
|||
|
|
mag=4.5, place="off the coast of Japan", depth_km=5.0,
|
|||
|
|
lat=35.0, lon=141.0, tsunami=True,
|
|||
|
|
)
|
|||
|
|
assert result.startswith("\U0001f6a8"), "Tsunami emoji must be 🚨"
|
|||
|
|
assert "\U0001f6a8 TSUNAMI WARNING" in result
|
|||
|
|
assert_byte_identical(result, old_wire)
|
|||
|
|
|
|||
|
|
def test_m5_escalation_emoji_preserved(self):
|
|||
|
|
"""M5+ uses ⚠️ emoji — unchanged from _render."""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": 5.2,
|
|||
|
|
"depth_km": 12.0,
|
|||
|
|
"lat": 44.0,
|
|||
|
|
"lon": -114.0,
|
|||
|
|
"place": "15 km NW of Mackay, Idaho",
|
|||
|
|
"tsunami": False,
|
|||
|
|
"pager": None,
|
|||
|
|
"is_update": False,
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": "test_m5",
|
|||
|
|
"_severity_override": None,
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 50.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(_make_fake_event(canonical), now=_AT, budget=140)
|
|||
|
|
|
|||
|
|
assert result.startswith("⚠️"), f"M5.2 must use ⚠️ emoji; got: {result!r}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
# 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_0002(self):
|
|||
|
|
"""Build native canonical data for fixture 0002 event, render → byte-identical.
|
|||
|
|
|
|||
|
|
The Central path produces canonical data by extracting from the
|
|||
|
|
envelope. The native path produces canonical data in to_event().
|
|||
|
|
The formatter reads the same keys from both → same wire.
|
|||
|
|
"""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
# Central-path canonical data (hand-extracted from fixture 0002)
|
|||
|
|
central_canonical = {
|
|||
|
|
"magnitude": 3.3,
|
|||
|
|
"depth_km": 11.169, # normalized from raw "depth" field
|
|||
|
|
"lat": 44.46,
|
|||
|
|
"lon": -112.6108,
|
|||
|
|
"place": "19 km S of Lima, Montana",
|
|||
|
|
"tsunami": False,
|
|||
|
|
"pager": None,
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": "us6000t9bn",
|
|||
|
|
"is_update": False,
|
|||
|
|
"_severity_override": None,
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 160.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Native-path canonical data (as to_event() would set it)
|
|||
|
|
native_canonical = {
|
|||
|
|
"magnitude": 3.3,
|
|||
|
|
"depth_km": 11.169,
|
|||
|
|
"lat": 44.46,
|
|||
|
|
"lon": -112.6108,
|
|||
|
|
"place": "19 km S of Lima, Montana",
|
|||
|
|
"tsunami": False, # native has no tsunami flag
|
|||
|
|
"pager": None, # native has no PAGER
|
|||
|
|
"occurred_at": None,
|
|||
|
|
"event_id": "us6000t9bn",
|
|||
|
|
"is_update": False,
|
|||
|
|
"_severity_override": None,
|
|||
|
|
"_dedup_suffix": "",
|
|||
|
|
"distance_km": 160.0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
central_wire = qfmt(_make_fake_event(central_canonical),
|
|||
|
|
now=_AT, budget=140)
|
|||
|
|
native_wire = qfmt(_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() data dict has the canonical keys the formatter reads."""
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
from meshai.env.usgs_quake import USGSQuakeAdapter
|
|||
|
|
|
|||
|
|
cfg = MagicMock()
|
|||
|
|
cfg.feed_url = "https://example.com/feed"
|
|||
|
|
cfg.min_magnitude = 1.0
|
|||
|
|
cfg.bbox = []
|
|||
|
|
cfg.region = "magic_valley"
|
|||
|
|
cfg.tick_seconds = 300
|
|||
|
|
|
|||
|
|
adapter = USGSQuakeAdapter(cfg)
|
|||
|
|
raw_evt = {
|
|||
|
|
"event_id": "us_test_identity",
|
|||
|
|
"magnitude": 3.5,
|
|||
|
|
"place": "5 km SW of Twin Falls, Idaho",
|
|||
|
|
"depth_km": 7.5,
|
|||
|
|
"lat": 42.5,
|
|||
|
|
"lon": -114.5,
|
|||
|
|
"quake_time": 1_783_000_000.0,
|
|||
|
|
"fetched_at": 1_783_000_010.0,
|
|||
|
|
"expires": 1_783_086_400.0,
|
|||
|
|
"severity": "priority",
|
|||
|
|
}
|
|||
|
|
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 = {
|
|||
|
|
"magnitude", "depth_km", "lat", "lon", "place",
|
|||
|
|
"tsunami", "pager", "occurred_at", "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["magnitude"] == 3.5
|
|||
|
|
assert event.data["lat"] == 42.5
|
|||
|
|
assert event.data["lon"] == -114.5
|
|||
|
|
assert event.data["depth_km"] == 7.5
|
|||
|
|
assert event.data["tsunami"] is False
|
|||
|
|
assert event.data["pager"] is None
|
|||
|
|
assert event.data["event_id"] == "us_test_identity"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
# 3. Gate-sequence — old gating vs new decide() — identical decisions
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _make_envelope(*, event_id, mag, lat, lon, depth_km=10.0, place=None,
|
|||
|
|
tsunami=0, alert=None, time_ms=1_780_000_000_000):
|
|||
|
|
"""Build a minimal Central-style quake envelope for gate-sequence testing."""
|
|||
|
|
place = place or f"near test location ({lat:.1f},{lon:.1f})"
|
|||
|
|
return {
|
|||
|
|
"envelope": {
|
|||
|
|
"id": event_id,
|
|||
|
|
"data": {
|
|||
|
|
"id": event_id,
|
|||
|
|
"adapter": "usgs_quake",
|
|||
|
|
"category": "quake.event.test",
|
|||
|
|
"severity": 0,
|
|||
|
|
"geo": {"centroid": [lon, lat]},
|
|||
|
|
"data": {
|
|||
|
|
"id": event_id,
|
|||
|
|
"magnitude": mag,
|
|||
|
|
"place": place,
|
|||
|
|
"depth_km": depth_km,
|
|||
|
|
"time_ms": time_ms,
|
|||
|
|
"tsunami": tsunami,
|
|||
|
|
"alert": alert,
|
|||
|
|
"latitude": lat,
|
|||
|
|
"longitude": lon,
|
|||
|
|
"depth": depth_km,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
"subject": "central.quake.event.test.unknown",
|
|||
|
|
"captured_epoch": int(time_ms / 1000),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestGateSequence:
|
|||
|
|
"""Gate parity: old handle_quake decisions match new gating.quake.decide()."""
|
|||
|
|
|
|||
|
|
@pytest.fixture(autouse=True)
|
|||
|
|
def _db(self, mem_db):
|
|||
|
|
"""All tests in this class share the same mem_db."""
|
|||
|
|
self.db = mem_db
|
|||
|
|
|
|||
|
|
def _old_gate(self, fixture, *, now):
|
|||
|
|
"""Old path: handle_quake returning non-None = broadcast."""
|
|||
|
|
from meshai.central.quake_handler import handle_quake
|
|||
|
|
env = fixture["envelope"]
|
|||
|
|
wire = handle_quake(env, fixture["subject"], data={}, now=int(now))
|
|||
|
|
return wire is not None
|
|||
|
|
|
|||
|
|
def _new_gate(self, fixture, *, now):
|
|||
|
|
"""New path: gating.quake.decide()."""
|
|||
|
|
from meshai.notifications.gating.quake import decide
|
|||
|
|
env = fixture["envelope"]
|
|||
|
|
inner = env.get("data") or {}
|
|||
|
|
d = inner.get("data") or {}
|
|||
|
|
geo = inner.get("geo") or {}
|
|||
|
|
cent = geo.get("centroid") or []
|
|||
|
|
lon, lat = (cent[0], cent[1]) if len(cent) >= 2 else (None, None)
|
|||
|
|
tms = d.get("time_ms")
|
|||
|
|
occurred_at = None
|
|||
|
|
if isinstance(tms, (int, float)):
|
|||
|
|
occurred_at = int(tms / 1000) if tms > 1e12 else int(tms)
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": d.get("magnitude"),
|
|||
|
|
"depth_km": d.get("depth_km") or d.get("depth"),
|
|||
|
|
"lat": lat,
|
|||
|
|
"lon": lon,
|
|||
|
|
"place": d.get("place"),
|
|||
|
|
"tsunami": bool(d.get("tsunami")),
|
|||
|
|
"pager": d.get("alert"),
|
|||
|
|
"occurred_at": occurred_at,
|
|||
|
|
"event_id": d.get("id") or inner.get("id"),
|
|||
|
|
}
|
|||
|
|
return decide(canonical, source="usgs_quake", now=float(now))
|
|||
|
|
|
|||
|
|
def test_gate_sequence_matches(self):
|
|||
|
|
"""Four-event sequence: old and new gates make identical broadcast/suppress decisions.
|
|||
|
|
|
|||
|
|
Sequence (each event has a DISTINCT event_id — the commit/suppress
|
|||
|
|
cycle is tested separately in test_suppress_after_commit):
|
|||
|
|
[0] M2.0, far (below all thresholds) → suppress
|
|||
|
|
[1] M2.7, within Idaho (regional gate) → broadcast
|
|||
|
|
[2] M3.5, anywhere (global floor) → broadcast
|
|||
|
|
[3] M6.0 + tsunami (any-magnitude tsunami gate) → broadcast
|
|||
|
|
|
|||
|
|
Gate decisions (broadcast True/False) must match between old and new.
|
|||
|
|
NOTE: PAGER/update-prefix are formatter-only tier-b changes; they do
|
|||
|
|
NOT affect gate decisions — any divergence here is a regression.
|
|||
|
|
"""
|
|||
|
|
t_base = 1_780_000_000.0
|
|||
|
|
|
|||
|
|
# [0] M2.0 far outside Idaho (lat=10.0, lon=140.0 → Japan)
|
|||
|
|
fx0 = _make_envelope(event_id="gs_seq_0", mag=2.0, lat=10.0, lon=140.0,
|
|||
|
|
time_ms=int(t_base * 1000))
|
|||
|
|
# [1] M2.7 within 250mi of Idaho centroid (Wyoming border)
|
|||
|
|
fx1 = _make_envelope(event_id="gs_seq_1", mag=2.7, lat=44.09, lon=-115.96,
|
|||
|
|
time_ms=int((t_base + 100) * 1000))
|
|||
|
|
# [2] M3.5 anywhere (global_mag_floor = 3.0 exceeded)
|
|||
|
|
fx2 = _make_envelope(event_id="gs_seq_2", mag=3.5, lat=10.0, lon=140.0,
|
|||
|
|
time_ms=int((t_base + 200) * 1000))
|
|||
|
|
# [3] M6.0 + tsunami (any magnitude with tsunami → broadcast)
|
|||
|
|
fx3 = _make_envelope(event_id="gs_seq_3", mag=6.0, lat=35.0, lon=141.0,
|
|||
|
|
tsunami=1, time_ms=int((t_base + 300) * 1000))
|
|||
|
|
|
|||
|
|
ordered = [fx0, fx1, fx2, fx3]
|
|||
|
|
timeline = [t_base, t_base + 100, t_base + 200, t_base + 300]
|
|||
|
|
|
|||
|
|
results = run_gate_sequence(
|
|||
|
|
self._old_gate,
|
|||
|
|
self._new_gate,
|
|||
|
|
ordered,
|
|||
|
|
timeline=timeline,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
mismatches = [r for r in results if not r["match"]]
|
|||
|
|
assert not mismatches, (
|
|||
|
|
"Gate sequence mismatch between old handle_quake and new decide():\n"
|
|||
|
|
+ "\n".join(
|
|||
|
|
f" step {r['fixture_n']}: old={r['old_broadcast']} "
|
|||
|
|
f"new={r['new_broadcast']} diffs={r['diffs']}"
|
|||
|
|
for r in mismatches
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Verify expected pattern
|
|||
|
|
assert results[0]["old_broadcast"] is False, "M2.0 far must be suppressed"
|
|||
|
|
assert results[1]["old_broadcast"] is True, "M2.7 Idaho must broadcast"
|
|||
|
|
assert results[2]["old_broadcast"] is True, "M3.5 global must broadcast"
|
|||
|
|
assert results[3]["old_broadcast"] is True, "M6.0+tsunami must broadcast"
|
|||
|
|
|
|||
|
|
def test_suppress_after_commit(self):
|
|||
|
|
"""After commit, the same event_id is suppressed by both old and new gates.
|
|||
|
|
|
|||
|
|
The run_gate_sequence harness does not call commits between steps, so
|
|||
|
|
the commit+suppress lifecycle is tested here separately by manual
|
|||
|
|
sequencing.
|
|||
|
|
"""
|
|||
|
|
from meshai.central.quake_handler import handle_quake
|
|||
|
|
from meshai.notifications.gating.quake import decide
|
|||
|
|
|
|||
|
|
t0 = 1_780_000_000.0
|
|||
|
|
event_id = "suppress_after_commit_test"
|
|||
|
|
|
|||
|
|
fx = _make_envelope(event_id=event_id, mag=3.5, lat=44.09, lon=-115.96,
|
|||
|
|
time_ms=int(t0 * 1000))
|
|||
|
|
env = fx["envelope"]
|
|||
|
|
|
|||
|
|
# First arrival: both old and new broadcast
|
|||
|
|
data1 = {}
|
|||
|
|
old_wire1 = handle_quake(env, fx["subject"], data=data1, now=int(t0))
|
|||
|
|
assert old_wire1 is not None, "First arrival must broadcast (old)"
|
|||
|
|
|
|||
|
|
# Build canonical from fixture for new gate
|
|||
|
|
inner = env["data"]
|
|||
|
|
d = inner["data"]
|
|||
|
|
geo = inner["geo"]
|
|||
|
|
cent = geo["centroid"]
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": d["magnitude"],
|
|||
|
|
"depth_km": d.get("depth_km") or d.get("depth"),
|
|||
|
|
"lat": cent[1], "lon": cent[0],
|
|||
|
|
"place": d.get("place"),
|
|||
|
|
"tsunami": bool(d.get("tsunami")),
|
|||
|
|
"pager": d.get("alert"),
|
|||
|
|
"occurred_at": int(d["time_ms"] / 1000),
|
|||
|
|
"event_id": event_id,
|
|||
|
|
}
|
|||
|
|
# Since old gate already wrote the row (INSERT), new gate sees the
|
|||
|
|
# same DB state. Both should broadcast on first arrival.
|
|||
|
|
# (We test new gate's second call AFTER commit below)
|
|||
|
|
|
|||
|
|
# Call commit (simulates confirmed delivery)
|
|||
|
|
assert "_on_broadcast_committed" in data1, "commit callback must be attached"
|
|||
|
|
data1["_on_broadcast_committed"](t0 + 1.0)
|
|||
|
|
|
|||
|
|
# Second arrival with same event_id — old gate must suppress
|
|||
|
|
old_wire2 = handle_quake(env, fx["subject"], data={}, now=int(t0 + 60))
|
|||
|
|
assert old_wire2 is None, "Old gate must suppress after commit"
|
|||
|
|
|
|||
|
|
# New gate must also suppress
|
|||
|
|
new_result2 = decide(canonical, source="usgs_quake", now=t0 + 60)
|
|||
|
|
assert new_result2.broadcast is False, "New gate must suppress after commit"
|
|||
|
|
|
|||
|
|
def test_severity_override_from_decide(self):
|
|||
|
|
"""decide() sets _severity_override=immediate for tsunami/PAGER."""
|
|||
|
|
from meshai.notifications.gating.quake import decide
|
|||
|
|
|
|||
|
|
# Tsunami
|
|||
|
|
canonical_tsunami = {
|
|||
|
|
"magnitude": 4.5, "depth_km": 10.0, "lat": 35.0, "lon": 141.0,
|
|||
|
|
"place": "off Japan", "tsunami": True, "pager": None,
|
|||
|
|
"occurred_at": None, "event_id": "sv_tsunami_test",
|
|||
|
|
}
|
|||
|
|
result_ts = decide(canonical_tsunami, source="usgs_quake", now=_AT)
|
|||
|
|
assert result_ts.broadcast is True
|
|||
|
|
assert result_ts.data_patch.get("_severity_override") == "immediate"
|
|||
|
|
|
|||
|
|
# PAGER orange
|
|||
|
|
canonical_pager = {
|
|||
|
|
"magnitude": 2.0, "depth_km": 10.0, "lat": 10.0, "lon": 140.0,
|
|||
|
|
"place": "Pacific Ocean", "tsunami": False, "pager": "orange",
|
|||
|
|
"occurred_at": None, "event_id": "sv_pager_test",
|
|||
|
|
}
|
|||
|
|
result_pg = decide(canonical_pager, source="usgs_quake", now=_AT)
|
|||
|
|
assert result_pg.broadcast is True
|
|||
|
|
assert result_pg.data_patch.get("_severity_override") == "immediate"
|
|||
|
|
|
|||
|
|
def test_dedup_suffix_is_empty(self):
|
|||
|
|
"""decide() data_patch has _dedup_suffix='' (bare event.id used for dedup)."""
|
|||
|
|
from meshai.notifications.gating.quake import decide
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": 3.5, "depth_km": 10.0, "lat": 44.0, "lon": -114.0,
|
|||
|
|
"place": "near Stanley, Idaho", "tsunami": False, "pager": None,
|
|||
|
|
"occurred_at": None, "event_id": "dedup_suffix_test",
|
|||
|
|
}
|
|||
|
|
result = decide(canonical, source="usgs_quake", now=_AT)
|
|||
|
|
assert result.broadcast is True
|
|||
|
|
assert result.data_patch.get("_dedup_suffix") == ""
|
|||
|
|
|
|||
|
|
def test_is_update_always_false_in_patch(self):
|
|||
|
|
"""decide() data_patch always has is_update=False (v0.5.9 no-Update rule)."""
|
|||
|
|
from meshai.notifications.gating.quake import decide
|
|||
|
|
|
|||
|
|
canonical = {
|
|||
|
|
"magnitude": 3.0, "depth_km": 8.0, "lat": 44.0, "lon": -114.5,
|
|||
|
|
"place": "central Idaho", "tsunami": False, "pager": None,
|
|||
|
|
"occurred_at": None, "event_id": "is_update_test",
|
|||
|
|
}
|
|||
|
|
result = decide(canonical, source="usgs_quake", now=_AT)
|
|||
|
|
assert result.broadcast is True
|
|||
|
|
assert result.data_patch.get("is_update") is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
# 4. Schema conformance — to_event() canonical data completeness
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
class TestSchemaConformance:
|
|||
|
|
"""env/usgs_quake.py to_event() emits exactly the canonical key set."""
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def adapter(self):
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
from meshai.env.usgs_quake import USGSQuakeAdapter
|
|||
|
|
cfg = MagicMock()
|
|||
|
|
cfg.feed_url = "https://example.com/feed"
|
|||
|
|
cfg.min_magnitude = 1.0
|
|||
|
|
cfg.bbox = []
|
|||
|
|
cfg.region = "magic_valley"
|
|||
|
|
cfg.tick_seconds = 300
|
|||
|
|
return USGSQuakeAdapter(cfg)
|
|||
|
|
|
|||
|
|
CANONICAL_KEYS = frozenset({
|
|||
|
|
"magnitude", "depth_km", "lat", "lon", "place",
|
|||
|
|
"tsunami", "pager", "occurred_at", "event_id",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
def _raw_evt(self, **overrides):
|
|||
|
|
base = {
|
|||
|
|
"event_id": "conform_test",
|
|||
|
|
"magnitude": 3.1,
|
|||
|
|
"place": "5 km NW of test, Idaho",
|
|||
|
|
"depth_km": 9.0,
|
|||
|
|
"lat": 44.0,
|
|||
|
|
"lon": -114.5,
|
|||
|
|
"quake_time": 1_783_000_000.0,
|
|||
|
|
"fetched_at": 1_783_000_010.0,
|
|||
|
|
"expires": 1_783_086_400.0,
|
|||
|
|
"severity": "routine",
|
|||
|
|
}
|
|||
|
|
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_extra_non_canonical_fields_cause_formatter_crash(self, adapter):
|
|||
|
|
"""Extra fields in event.data (e.g. raw USGS keys) don't crash the formatter."""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
event = adapter.to_event(self._raw_evt())
|
|||
|
|
assert event is not None
|
|||
|
|
|
|||
|
|
# Inject extra keys that might come from Central enrichment
|
|||
|
|
event.data["_enriched"] = {"geocoder": {"city": "TestCity"}}
|
|||
|
|
event.data["sig"] = 123
|
|||
|
|
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(event, now=_AT, budget=140)
|
|||
|
|
|
|||
|
|
assert result is not None
|
|||
|
|
assert "M3.1" in result
|
|||
|
|
assert len(result) <= 140
|
|||
|
|
|
|||
|
|
def test_tsunami_defaults_false(self, adapter):
|
|||
|
|
"""Native to_event() sets tsunami=False (native feed has no tsunami data)."""
|
|||
|
|
event = adapter.to_event(self._raw_evt())
|
|||
|
|
assert event.data["tsunami"] is False
|
|||
|
|
|
|||
|
|
def test_pager_defaults_none(self, adapter):
|
|||
|
|
"""Native to_event() sets pager=None (PAGER comes from Central only)."""
|
|||
|
|
event = adapter.to_event(self._raw_evt())
|
|||
|
|
assert event.data["pager"] is None
|
|||
|
|
|
|||
|
|
def test_event_id_matches_raw_evt(self, adapter):
|
|||
|
|
"""event.data["event_id"] matches the source event_id."""
|
|||
|
|
event = adapter.to_event(self._raw_evt(event_id="my_quake_id"))
|
|||
|
|
assert event.data["event_id"] == "my_quake_id"
|
|||
|
|
|
|||
|
|
def test_missing_depth_km_yields_none(self, adapter):
|
|||
|
|
"""to_event() handles missing depth gracefully (depth_km=None in data)."""
|
|||
|
|
raw = self._raw_evt()
|
|||
|
|
del raw["depth_km"] # simulate missing depth
|
|||
|
|
event = adapter.to_event(raw)
|
|||
|
|
assert event is not None
|
|||
|
|
assert event.data.get("depth_km") is None
|
|||
|
|
|
|||
|
|
def test_formatter_budget_respected(self, adapter):
|
|||
|
|
"""Formatter output fits within budget for worst-case place string."""
|
|||
|
|
from meshai.notifications.formatters.quake import format as qfmt
|
|||
|
|
|
|||
|
|
raw = self._raw_evt(
|
|||
|
|
magnitude=7.9,
|
|||
|
|
place="293 km SSW of a pathologically long place description island "
|
|||
|
|
"region in the remote northern pacific ocean near absolutely nowhere "
|
|||
|
|
"at all off the coast of the far edge of the map",
|
|||
|
|
depth_km=12.0,
|
|||
|
|
lat=44.123,
|
|||
|
|
lon=-114.987,
|
|||
|
|
)
|
|||
|
|
event = adapter.to_event(raw)
|
|||
|
|
assert event is not None
|
|||
|
|
with pinned_time(_AT):
|
|||
|
|
result = qfmt(event, now=_AT, budget=140)
|
|||
|
|
assert len(result) <= 140, f"{len(result)} chars:\n{result!r}"
|
|||
|
|
assert "M7.9" in result
|