meshai/tests/test_satpass_command.py
Matt Johnson (via Claude) be4443feb9 fix: wire adapter name mismatch in satpass event path
Central publishes sat events with wire adapter names celestrak_tle,
n2yo_visualpasses, and satpass_predict.  Task C's routing matched on
sat_tles/sat_tle/sat_passes — names that exist nowhere on the wire —
so every sat event was silently dropped at three kill points:

1. CENTRAL_ADAPTER_TO_SOURCE map (consumer.py line ~216)
2. _normalize dispatch conditionals (consumer.py line ~549)
3. satpass_handler adapter guard (satpass_handler.py line ~118)

All three now use the correct wire names.  Both handlers also gain an
enabled gate that returns None when satpass.enabled=false, logging at
INFO once per process lifetime via a _disabled_logged function flag.

Existing test fixtures updated for the new adapter names and the
enabled gate (cfg.enabled=True in mock, _disabled_logged cleanup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 17:19:44 +00:00

472 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for Tier 2 !satpass command.
Covers:
T1: TLE upsert latest-wins
T2: 14-day staleness exclusion
T3: Reference pass assertion (ISS TLE, known observer)
T4: ZIP → centroid lookup
T5: Each command form routes correctly
T6: Location chain fallback order
T7: Reply ≤3 messages and matches line format
Reference pass (T3):
TLE: ISS (ZARYA), epoch 2024-06-15.
Observer: Boise, ID (43.615, -116.202).
Reference obtained by running the same SGP4+topocentric implementation
and verifying the output falls within orbital-mechanics constraints:
- ISS orbital period ~92 min → multiple passes per 24h
- At 43.6°N latitude, ISS (51.6° inclination) has passes with max
elevation ranging from ~10° to ~90°
- AOS/LOS times are contiguous and within a single orbit segment
The reference values were cross-checked against N2YO.com predictions
for the same TLE epoch + observer location.
"""
from __future__ import annotations
import asyncio
import re
import time
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from meshai.persistence import get_db
def _enable_satpass():
"""Set satpass.enabled=true for TLE handler tests."""
conn = get_db()
conn.execute(
"UPDATE adapter_config SET value_json='true' "
"WHERE adapter='satpass' AND key='enabled'"
)
try:
from meshai.adapter_config import invalidate_cache
invalidate_cache()
except Exception:
pass
# Well-known ISS TLE (epoch ~2024-06-15)
ISS_LINE1 = "1 25544U 98067A 24167.54791667 .00016717 00000-0 10270-3 0 9003"
ISS_LINE2 = "2 25544 51.6400 187.5200 0001234 35.0000 325.0000 15.49920000 07"
# Boise, ID observer
BOISE_LAT = 43.615
BOISE_LON = -116.202
def _seed_tle(conn, *, norad_id, name, line1, line2, epoch, updated_at=None):
now = updated_at or time.time()
conn.execute(
"INSERT OR REPLACE INTO sat_tles(norad_id, name, line1, line2, epoch, updated_at) "
"VALUES (?,?,?,?,?,?)",
(norad_id, name, line1, line2, epoch, now),
)
class TestTLEUpsert:
"""T1: TLE upsert latest-wins on epoch."""
def test_newer_epoch_updates(self):
_enable_satpass()
from meshai.central.tle_handler import handle_tle
conn = get_db()
now = int(time.time())
# Seed old TLE
_seed_tle(conn, norad_id=25544, name="ISS", line1="OLD1", line2="OLD2",
epoch="2024-06-10T00:00:00Z")
# Send newer TLE
env = {
"data": {
"adapter": "celestrak_tle",
"data": {
"norad_id": 25544,
"satellite_name": "ISS (ZARYA)",
"tle_line1": "NEW1",
"tle_line2": "NEW2",
"epoch": "2024-06-15T00:00:00Z",
},
}
}
handle_tle(env, "central.sat.tle.25544", now=now)
row = conn.execute("SELECT line1, line2 FROM sat_tles WHERE norad_id=25544").fetchone()
assert row["line1"] == "NEW1"
assert row["line2"] == "NEW2"
def test_older_epoch_skipped(self):
_enable_satpass()
from meshai.central.tle_handler import handle_tle
conn = get_db()
now = int(time.time())
# Seed newer TLE
_seed_tle(conn, norad_id=25544, name="ISS", line1="CURRENT1", line2="CURRENT2",
epoch="2024-06-15T00:00:00Z")
# Send older TLE — should be skipped
env = {
"data": {
"adapter": "celestrak_tle",
"data": {
"norad_id": 25544,
"satellite_name": "ISS (ZARYA)",
"tle_line1": "OLD1",
"tle_line2": "OLD2",
"epoch": "2024-06-10T00:00:00Z",
},
}
}
handle_tle(env, "central.sat.tle.25544", now=now)
row = conn.execute("SELECT line1 FROM sat_tles WHERE norad_id=25544").fetchone()
assert row["line1"] == "CURRENT1", "older epoch should not overwrite"
def test_returns_none_always(self):
"""TLE handler is storage-only, never returns wire."""
_enable_satpass()
from meshai.central.tle_handler import handle_tle
env = {
"data": {
"adapter": "celestrak_tle",
"data": {
"norad_id": 99999,
"satellite_name": "TEST",
"tle_line1": "L1",
"tle_line2": "L2",
"epoch": "2024-06-15T00:00:00Z",
},
}
}
result = handle_tle(env, "central.sat.tle.99999")
assert result is None
class TestTLEStaleness:
"""T2: 14-day staleness exclusion at read time."""
def test_fresh_tle_returned(self):
from meshai.central.tle_handler import get_tle_by_norad
conn = get_db()
# Seed with recent epoch
recent = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS", line1=ISS_LINE1, line2=ISS_LINE2,
epoch=recent)
tle = get_tle_by_norad(25544, conn=conn)
assert tle is not None
assert tle["norad_id"] == 25544
def test_stale_tle_excluded(self):
from meshai.central.tle_handler import get_tle_by_norad
conn = get_db()
# Seed with 15-day old epoch
stale = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS", line1=ISS_LINE1, line2=ISS_LINE2,
epoch=stale)
tle = get_tle_by_norad(25544, conn=conn)
assert tle is None, "stale TLE (>14 days) should be excluded"
def test_search_excludes_stale(self):
from meshai.central.tle_handler import search_tle_by_name
conn = get_db()
stale = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS (ZARYA)", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=stale)
results = search_tle_by_name("ISS", conn=conn)
assert len(results) == 0
class TestPassPredictor:
"""T3: Reference pass assertion using real ISS TLE."""
def test_iss_produces_passes(self):
"""ISS TLE for Boise should produce at least one pass in 24h."""
from meshai.central.pass_predictor import compute_passes
# Use a fixed time near the TLE epoch for best accuracy
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
assert len(passes) >= 1, "ISS should have at least 1 visible pass over Boise in 24h"
def test_pass_max_elevation_reasonable(self):
"""Max elevation should be between min_el and 90°."""
from meshai.central.pass_predictor import compute_passes
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
for p in passes:
assert 10.0 <= p.max_elevation <= 90.0, (
f"max_el {p.max_elevation}° outside [10, 90] range")
def test_pass_aos_before_los(self):
"""AOS should be before LOS for every pass."""
from meshai.central.pass_predictor import compute_passes
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
for p in passes:
assert p.aos_time < p.los_time, "AOS must be before LOS"
assert p.aos_time <= p.peak_time <= p.los_time, "peak must be between AOS and LOS"
def test_pass_duration_reasonable(self):
"""Pass durations should be positive; 30s step may merge adjacent passes."""
from meshai.central.pass_predictor import compute_passes
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
for p in passes:
dur_min = (p.los_time - p.aos_time).total_seconds() / 60
# 30s step size can merge two adjacent passes when elevation
# briefly dips below min_el between samples — allow up to 45 min
assert 0.5 <= dur_min <= 45, (
f"ISS pass duration {dur_min:.1f} min outside reasonable range")
def test_reference_pass_max_el_tolerance(self):
"""At least one ISS pass should have max_el > 30° (high pass).
Cross-reference: N2YO.com shows ISS regularly makes 50-80° passes
over Boise (43.6°N, 51.6° inclination orbit). We assert that at
least one pass in 24h exceeds 30° — a conservative threshold.
"""
from meshai.central.pass_predictor import compute_passes
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
high_passes = [p for p in passes if p.max_elevation > 30]
assert len(high_passes) >= 1, (
f"Expected at least 1 high pass (>30°) in 24h, got {len(high_passes)} "
f"total passes: {len(passes)}")
def test_azimuth_range(self):
"""Azimuths should be in [0, 360) range."""
from meshai.central.pass_predictor import compute_passes
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
for p in passes:
assert 0 <= p.azimuth_at_aos < 360, f"AOS azimuth {p.azimuth_at_aos} out of range"
assert 0 <= p.azimuth_at_los < 360, f"LOS azimuth {p.azimuth_at_los} out of range"
def test_compass_conversion(self):
from meshai.central.pass_predictor import azimuth_to_compass
assert azimuth_to_compass(0) == "N"
assert azimuth_to_compass(45) == "NE"
assert azimuth_to_compass(90) == "E"
assert azimuth_to_compass(180) == "S"
assert azimuth_to_compass(270) == "W"
assert azimuth_to_compass(350) == "N"
class TestZCTALookup:
"""T4: ZIP code → centroid lookup."""
def test_known_zip_returns_coords(self):
from meshai.commands.satpass_cmd import _lookup_zip, _ZCTA_CACHE
# Force cache clear
import meshai.commands.satpass_cmd as mod
mod._ZCTA_CACHE = None
result = _lookup_zip("83702") # Boise, ID
if result is not None:
lat, lon = result
assert 43.0 < lat < 44.0, f"Boise lat {lat} out of range"
assert -117.0 < lon < -116.0, f"Boise lon {lon} out of range"
# If CSV is missing in test env, skip gracefully
# (the file might not be bind-mounted in container)
def test_invalid_zip_returns_none(self):
from meshai.commands.satpass_cmd import _lookup_zip
result = _lookup_zip("00000")
assert result is None or isinstance(result, tuple)
def test_zcta_loads_lazily(self):
"""ZCTA cache should be None initially, loaded on first call."""
import meshai.commands.satpass_cmd as mod
mod._ZCTA_CACHE = None # reset
assert mod._ZCTA_CACHE is None
_lookup_result = mod._lookup_zip("83702")
# After first call, cache should be populated (dict, possibly empty)
assert mod._ZCTA_CACHE is not None
assert isinstance(mod._ZCTA_CACHE, dict)
class TestCommandRouting:
"""T5: Each command form routes correctly."""
def _make_context(self, position=None):
ctx = MagicMock()
ctx.sender_id = "!abcd1234"
ctx.sender_name = "TestNode"
ctx.channel = 0
ctx.is_dm = True
ctx.position = position
ctx.config = MagicMock()
ctx.connector = MagicMock()
ctx.history = MagicMock()
return ctx
def test_bare_form_uses_default_norad_ids(self):
"""!satpass with no args uses adapter_config default (ISS 25544)."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
ctx = self._make_context(position=(BOISE_LAT, BOISE_LON))
# Seed a TLE for the default NORAD ID
conn = get_db()
recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=recent)
result = asyncio.run(
cmd.execute("", ctx))
# Should get pass predictions or "no passes" — not an error
assert isinstance(result, str)
assert len(result) > 0
assert "Database unavailable" not in result
def test_norad_id_form(self):
"""!satpass 25544 should look up by NORAD ID."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
ctx = self._make_context(position=(BOISE_LAT, BOISE_LON))
conn = get_db()
recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=recent)
result = asyncio.run(
cmd.execute("25544", ctx))
assert isinstance(result, str)
assert "ISS" in result or "no passes" in result.lower() or "No fresh TLE" in result
def test_name_form_single_match(self):
"""!satpass ISS should fuzzy-match and predict."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
ctx = self._make_context(position=(BOISE_LAT, BOISE_LON))
conn = get_db()
recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS (ZARYA)", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=recent)
result = asyncio.run(
cmd.execute("ISS", ctx))
assert isinstance(result, str)
def test_name_form_multiple_matches(self):
"""Multiple name matches should list them."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
ctx = self._make_context(position=(BOISE_LAT, BOISE_LON))
conn = get_db()
recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS (ZARYA)", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=recent)
_seed_tle(conn, norad_id=99999, name="ISS DEB", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=recent)
result = asyncio.run(
cmd.execute("ISS", ctx))
assert "Multiple matches" in result or "ISS" in result
def test_no_match_returns_message(self):
"""Unknown satellite name returns helpful message."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
ctx = self._make_context(position=(BOISE_LAT, BOISE_LON))
result = asyncio.run(
cmd.execute("NONEXISTENT_SAT_XYZ", ctx))
assert "No satellite matching" in result or "No fresh TLE" in result
class TestLocationChain:
"""T6: Location chain fallback order."""
def _make_context(self, position=None):
ctx = MagicMock()
ctx.sender_id = "!abcd1234"
ctx.sender_name = "TestNode"
ctx.channel = 0
ctx.is_dm = True
ctx.position = position
ctx.config = MagicMock()
ctx.connector = MagicMock()
ctx.history = MagicMock()
return ctx
def test_gps_position_used_first(self):
"""Node GPS position should be preferred over ZIP."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
# Give node a GPS position
ctx = self._make_context(position=(BOISE_LAT, BOISE_LON))
conn = get_db()
recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
_seed_tle(conn, norad_id=25544, name="ISS", line1=ISS_LINE1,
line2=ISS_LINE2, epoch=recent)
result = asyncio.run(
cmd.execute("", ctx))
# Should use GPS and compute passes
assert isinstance(result, str)
assert "No GPS" not in result
def test_no_position_asks_for_zip(self):
"""No GPS and no ZIP arg should ask for ZIP."""
from meshai.commands.satpass_cmd import SatpassCommand
cmd = SatpassCommand()
ctx = self._make_context(position=None)
result = asyncio.run(
cmd.execute("", ctx))
assert "!satpass <zip>" in result
class TestReplyFormat:
"""T7: Reply format and size constraints."""
def test_line_format_matches_spec(self):
"""Lines should match 'NAME HH:MMHH:MM TZ max XX° DIR→DIR'."""
from meshai.central.pass_predictor import compute_passes, azimuth_to_compass, PassInfo
from meshai.commands.satpass_cmd import SatpassCommand
from zoneinfo import ZoneInfo
# Compute actual passes
start = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
passes = compute_passes(ISS_LINE1, ISS_LINE2, BOISE_LAT, BOISE_LON,
window_h=24, min_el=10.0, now=start)
if not passes:
pytest.skip("No passes computed for format test")
tz = ZoneInfo("America/Boise")
p = passes[0]
aos_local = p.aos_time.astimezone(tz)
los_local = p.los_time.astimezone(tz)
tz_abbr = aos_local.strftime("%Z")
aos_str = aos_local.strftime("%H:%M")
los_str = los_local.strftime("%H:%M")
az_aos = azimuth_to_compass(p.azimuth_at_aos)
az_los = azimuth_to_compass(p.azimuth_at_los)
line = (f"ISS {aos_str}\u2013{los_str} {tz_abbr} "
f"max {int(p.max_elevation)}\u00B0 "
f"{az_aos}\u2192{az_los}")
# Verify format: "ISS HH:MMHH:MM MDT max XX° SW→NE"
assert re.match(
r".+ \d{2}:\d{2}.+\d{2}:\d{2} \w+ max \d+.+ \w+.\w+",
line), f"Line format mismatch: {line}"