feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)

meshai can now predict + broadcast satellite passes locally without Central.

Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
  upserts the existing sat_tles table via a shared upsert_tle() helper
  extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
  seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
  min_elevation_deg, window_hours

Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
  consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
  + deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
  pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
  bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
  entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
  event.data so satpass_events dedups across ticks — NO satpass_pending, NO
  Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native

32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-05 02:25:55 -06:00 committed by GitHub
commit 53eadf5135
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1405 additions and 53 deletions

View file

@ -0,0 +1,126 @@
"""Tests for observer_locations (v23): migration, accessors, seed-from-config."""
from __future__ import annotations
import pytest
from meshai.config import SatpassConfig
from meshai.persistence import SCHEMA_VERSION, get_db
from meshai.persistence.observer_locations import (
get_observers,
seed_observers_from_config,
upsert_observer,
)
# -- schema / migration -------------------------------------------------------
def test_schema_version_is_23():
assert SCHEMA_VERSION == 23
def test_observer_locations_table_exists():
conn = get_db()
tables = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert "observer_locations" in tables
def test_schema_meta_at_23():
conn = get_db()
row = conn.execute(
"SELECT value FROM schema_meta WHERE key='version'").fetchone()
assert int(row["value"]) == 23
# -- accessors ----------------------------------------------------------------
def test_upsert_and_get_roundtrip():
upsert_observer("boise", "Boise", 43.615, -116.202, alt_m=824.0)
obs = get_observers()
assert len(obs) == 1
r = obs[0]
assert r["slug"] == "boise"
assert r["name"] == "Boise"
assert r["lat"] == pytest.approx(43.615)
assert r["lon"] == pytest.approx(-116.202)
assert r["alt_m"] == pytest.approx(824.0)
def test_upsert_updates_existing():
upsert_observer("site1", "Old Name", 40.0, -110.0)
upsert_observer("site1", "New Name", 41.0, -111.0, alt_m=500.0)
obs = {o["slug"]: o for o in get_observers()}
assert obs["site1"]["name"] == "New Name"
assert obs["site1"]["lat"] == pytest.approx(41.0)
assert obs["site1"]["alt_m"] == pytest.approx(500.0)
def test_disabled_observers_excluded():
upsert_observer("on", "Enabled", 40.0, -110.0, enabled=True)
upsert_observer("off", "Disabled", 41.0, -111.0, enabled=False)
slugs = {o["slug"] for o in get_observers()}
assert "on" in slugs
assert "off" not in slugs
def test_alt_m_defaults_to_zero():
upsert_observer("noalt", "No Altitude", 40.0, -110.0)
r = get_observers()[0]
assert r["alt_m"] == pytest.approx(0.0)
# -- seed from config ---------------------------------------------------------
def test_seed_observers_from_config():
cfg = SatpassConfig(observers=[
{"slug": "boise", "name": "Boise", "lat": 43.615, "lon": -116.202, "alt_m": 824.0},
{"slug": "twin", "name": "Twin Falls", "lat": 42.563, "lon": -114.461},
])
n = seed_observers_from_config(cfg)
assert n == 2
slugs = {o["slug"] for o in get_observers()}
assert slugs == {"boise", "twin"}
def test_seed_respects_disabled_flag():
cfg = SatpassConfig(observers=[
{"slug": "a", "name": "A", "lat": 40.0, "lon": -110.0},
{"slug": "b", "name": "B", "lat": 41.0, "lon": -111.0, "enabled": False},
])
seed_observers_from_config(cfg)
slugs = {o["slug"] for o in get_observers()}
assert "a" in slugs
assert "b" not in slugs
def test_seed_is_idempotent_and_updates():
cfg = SatpassConfig(observers=[
{"slug": "x", "name": "X", "lat": 40.0, "lon": -110.0},
])
assert seed_observers_from_config(cfg) == 1
# Re-seed with an edited name — upsert, not duplicate.
cfg2 = SatpassConfig(observers=[
{"slug": "x", "name": "X Renamed", "lat": 40.0, "lon": -110.0},
])
seed_observers_from_config(cfg2)
obs = get_observers()
assert len(obs) == 1
assert obs[0]["name"] == "X Renamed"
def test_seed_skips_malformed_entries():
cfg = SatpassConfig(observers=[
{"slug": "good", "name": "Good", "lat": 40.0, "lon": -110.0},
{"name": "MissingSlug", "lat": 41.0, "lon": -111.0}, # no slug
"not-a-dict",
])
n = seed_observers_from_config(cfg)
assert n == 1
assert {o["slug"] for o in get_observers()} == {"good"}
def test_seed_empty_config_noop():
cfg = SatpassConfig(observers=[])
assert seed_observers_from_config(cfg) == 0
assert get_observers() == []

View file

@ -0,0 +1,307 @@
"""Tests for the native SGP4 satpass adapter (env.satpass).
The native adapter computes every observer for a satellite in ONE tick, so it
consolidates in-memory and gates synchronously via the SHARED
`satpass_handler.gate_consolidated_pass` with NO `satpass_pending` buffer and
NO Central consumer/timer. These tests monkeypatch `compute_passes` and
`get_observers` (no SGP4 / no network) and seed a fresh `sat_tles` row, then
exercise: multi-observer consolidation, cross-tick dedup via `satpass_events`,
resilient empty cases, the precomposed aospeaklos wire, and a guard that the
Central path still feeds the shared gate the correctly-merged consolidation.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
import pytest
from meshai.env.satpass import SatpassAdapter
from meshai.config import SatpassConfig
from meshai.central.pass_predictor import PassInfo
from meshai.central.tle_handler import upsert_tle
from meshai.persistence import get_db
# A valid ISS TLE so _resolve_tles / get_tle_by_norad has real data to return.
ISS_L1 = "1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008"
ISS_L2 = "2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12345"
# An hour-aligned base so both observers' AOS land in the same hour bucket
# (same canonical id) and therefore consolidate into ONE pass.
T0 = (1783000000 // 3600) * 3600 + 100 # aligned + 100s
_BOISE = {"slug": "boise", "name": "Boise", "lat": 43.6, "lon": -116.2, "alt_m": 0.0}
_TWIN = {"slug": "twin", "name": "Twin Falls", "lat": 42.5, "lon": -114.4, "alt_m": 0.0}
# ── helpers ──────────────────────────────────────────────────────────────
def _dt(epoch: int) -> datetime:
return datetime.fromtimestamp(epoch, tz=timezone.utc)
def _pass(aos: int, los: int, max_el: float,
az_aos: float, az_los: float, az_peak: float) -> PassInfo:
peak = (aos + los) // 2
return PassInfo(
aos_time=_dt(aos), los_time=_dt(los), peak_time=_dt(peak),
max_elevation=max_el,
azimuth_at_aos=az_aos, azimuth_at_los=az_los, azimuth_at_peak=az_peak,
)
def _seed_iss_tle():
"""Seed a FRESH ISS TLE into sat_tles so the adapter resolves it."""
conn = get_db()
fresh = datetime.now(timezone.utc).isoformat()
upsert_tle(conn, 25544, "ISS (ZARYA)", ISS_L1, ISS_L2, fresh)
def _enable_satpass_db(dry_run=False, max_per_hour=100, norad_ids=None):
"""Set satpass adapter_config in the test DB (the gate reads it)."""
from meshai.adapter_config import invalidate_cache
conn = get_db()
conn.execute("UPDATE adapter_config SET value_json='true' "
"WHERE adapter='satpass' AND key='enabled'")
conn.execute("UPDATE adapter_config SET value_json=? "
"WHERE adapter='satpass' AND key='dry_run'",
(json.dumps(dry_run),))
conn.execute("UPDATE adapter_config SET value_json=? "
"WHERE adapter='satpass' AND key='max_broadcasts_per_hour'",
(json.dumps(max_per_hour),))
if norad_ids is not None:
conn.execute("UPDATE adapter_config SET value_json=? "
"WHERE adapter='satpass' AND key='norad_ids'",
(json.dumps(norad_ids),))
invalidate_cache()
def _adapter(**overrides) -> SatpassAdapter:
cfg = SatpassConfig(enabled=True, feed_source="native",
norad_ids=[25544], min_elevation_deg=10.0,
window_hours=24, **overrides)
return SatpassAdapter(cfg)
def _patch_predictor(monkeypatch, fake):
monkeypatch.setattr("meshai.central.pass_predictor.compute_passes", fake)
def _patch_observers(monkeypatch, observers):
monkeypatch.setattr(
"meshai.persistence.observer_locations.get_observers",
lambda *a, **k: list(observers))
# Two observers, same pass: boise rises first (entry), twin sets last (exit)
# AND twin has the higher max elevation (so it supplies max_el + peak).
def _two_observer_pass(l1, l2, lat, lon, alt, window_h, min_el, now):
if abs(lat - _BOISE["lat"]) < 0.1:
return [_pass(T0, T0 + 300, 40.0, az_aos=225, az_los=300, az_peak=90)]
if abs(lat - _TWIN["lat"]) < 0.1:
return [_pass(T0 + 60, T0 + 400, 70.0, az_aos=270, az_los=45, az_peak=180)]
return []
# ══════════════════════════════════════════════════════════════════════
# 1. MULTI-OBSERVER CONSOLIDATION
# ══════════════════════════════════════════════════════════════════════
def test_two_observers_consolidate_to_one_broadcast(monkeypatch):
_enable_satpass_db(dry_run=False)
_seed_iss_tle()
_patch_observers(monkeypatch, [_BOISE, _TWIN])
_patch_predictor(monkeypatch, _two_observer_pass)
adapter = _adapter()
changed = adapter.tick(now=T0 - 3600) # any now; predictor ignores it
assert changed is True
staged = adapter.get_events()
assert len(staged) == 1, "two observers, one pass -> ONE consolidated event"
evt = staged[0]
cid = f"25544:{T0 // 3600}"
assert evt["event_id"] == cid
# satpass_events row proves the merge: earliest AOS, latest LOS,
# max-elevation from the higher observer, both observers recorded.
row = get_db().execute(
"SELECT observer, max_elevation, aos_at, los_at FROM satpass_events "
"WHERE event_id=?", (cid,)).fetchone()
assert row is not None
assert row["max_elevation"] == 70.0 # twin (higher)
assert row["aos_at"] == T0 # boise (earliest AOS)
assert row["los_at"] == T0 + 400 # twin (latest LOS)
assert "boise" in row["observer"] and "twin" in row["observer"]
# Wire carries entry->exit region + aos->peak->los compass sweep.
wire = evt["wire"]
assert "boise→twin" in wire # entry -> exit
assert "SW→S→NE" in wire # aos -> peak(twin) -> los
# ══════════════════════════════════════════════════════════════════════
# 2. CROSS-TICK DEDUP (satpass_events remembers after commit)
# ══════════════════════════════════════════════════════════════════════
def test_second_tick_does_not_rebroadcast_after_commit(monkeypatch):
_enable_satpass_db(dry_run=False)
_seed_iss_tle()
_patch_observers(monkeypatch, [_BOISE, _TWIN])
_patch_predictor(monkeypatch, _two_observer_pass)
adapter = _adapter()
# Tick 1: stages the pass. Simulate a successful mesh send by firing the
# commit closure the gate attached (this is what the dispatcher does).
assert adapter.tick(now=T0 - 3600) is True
staged = adapter.get_events()
assert len(staged) == 1
commit = staged[0]["data"]["_on_broadcast_committed"]
commit(float(T0)) # marks satpass_events.last_broadcast_at
# Tick 2 (interval elapsed): same canonical pass must be suppressed.
assert adapter.tick(now=T0 + 2000) is False
assert adapter.get_events() == []
def test_second_tick_without_commit_is_not_deduped(monkeypatch):
# Guard the semantics: dedup persists ONLY after the broadcast commits.
_enable_satpass_db(dry_run=False)
_seed_iss_tle()
_patch_observers(monkeypatch, [_BOISE, _TWIN])
_patch_predictor(monkeypatch, _two_observer_pass)
adapter = _adapter()
assert adapter.tick(now=T0 - 3600) is True
# No commit fired -> last_broadcast_at still NULL -> re-stages next tick.
assert adapter.tick(now=T0 + 2000) is True
# ══════════════════════════════════════════════════════════════════════
# 3. RESILIENT EMPTY CASES (never crash, yield nothing)
# ══════════════════════════════════════════════════════════════════════
def test_no_observers_yields_nothing(monkeypatch):
_enable_satpass_db(dry_run=False)
_seed_iss_tle()
_patch_observers(monkeypatch, [])
_patch_predictor(monkeypatch, _two_observer_pass)
adapter = _adapter()
assert adapter.tick(now=T0) is False
assert adapter.get_events() == []
assert adapter.health_status["is_loaded"] is True
def test_no_fresh_tles_yields_nothing(monkeypatch):
_enable_satpass_db(dry_run=False)
# Do NOT seed sat_tles -> get_tle_by_norad returns None.
_patch_observers(monkeypatch, [_BOISE, _TWIN])
_patch_predictor(monkeypatch, _two_observer_pass)
adapter = _adapter()
assert adapter.tick(now=T0) is False
assert adapter.get_events() == []
def test_below_min_elevation_yields_nothing(monkeypatch):
_enable_satpass_db(dry_run=False)
_seed_iss_tle()
_patch_observers(monkeypatch, [_BOISE, _TWIN])
# Predictor filters by min_el internally; a too-low pass => empty list.
_patch_predictor(monkeypatch, lambda *a, **k: [])
adapter = _adapter()
assert adapter.tick(now=T0) is False
assert adapter.get_events() == []
# ══════════════════════════════════════════════════════════════════════
# 4. PRECOMPOSED WIRE renders aos->peak->los through format_pass
# ══════════════════════════════════════════════════════════════════════
def test_emitted_event_renders_aos_peak_los(monkeypatch):
from meshai.notifications.renderers.composer import compose_mesh_message
_enable_satpass_db(dry_run=False)
_seed_iss_tle()
_patch_observers(monkeypatch, [_BOISE, _TWIN])
_patch_predictor(monkeypatch, _two_observer_pass)
adapter = _adapter()
adapter.tick(now=T0 - 3600)
evt = adapter.get_events()[0]
event = adapter.to_event(evt)
assert event is not None
assert event.category == "sat_pass"
assert event.severity == "immediate" # max_el 70 -> immediate
assert event.data.get("_meshai_precomposed") is True
assert callable(event.data.get("_on_broadcast_committed"))
# Precomposed: composer returns the wire verbatim, with the peak point
# rendered between aos and los.
composed = compose_mesh_message(event)
assert composed == evt["wire"]
assert "SW→S→NE" in composed # aos -> peak -> los
assert composed.startswith("\U0001F6F0") # satellite emoji
# ══════════════════════════════════════════════════════════════════════
# 5. SHARED-GATE EXTRACTION GUARD (Central path still feeds the gate right)
# ══════════════════════════════════════════════════════════════════════
def test_central_consolidate_feeds_shared_gate_merged(monkeypatch):
"""consolidate_satpass_pending must merge observers and hand the SAME
shared gate a correctly-consolidated dict (earliest AOS / latest LOS /
max-el observer / entry+exit / observer_list). Spying the gate proves the
Central path routes through the extracted, source-agnostic function."""
import meshai.central.satpass_handler as sh
conn = get_db()
cid = f"25544:{T0 // 3600}"
# Two pending rows for the same canonical id (boise entry, twin exit+peak).
rows = [
(cid, "boise", "ISS", 25544, 40.0, T0, T0 + 300, "SW", "NW", "E", T0, T0 + 5),
(cid, "twin", "ISS", 25544, 70.0, T0 + 60, T0 + 400, "W", "NE", "S", T0, T0 + 5),
]
for r in rows:
conn.execute(
"INSERT OR REPLACE INTO satpass_pending("
"consolidated_id, observer, sat_name, norad_id, max_elevation, "
"aos_at, los_at, aos_compass, los_compass, peak_compass, "
"received_at, due_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", r)
captured = {}
def _spy(consolidated, *, now):
captured["c"] = consolidated
captured["now"] = now
return None # short-circuit: no broadcast side effects
monkeypatch.setattr(sh, "gate_consolidated_pass", _spy)
result = sh.consolidate_satpass_pending(cid)
assert result is None
c = captured["c"]
assert c["consolidated_id"] == cid
assert c["norad_id"] == 25544
assert c["max_elevation"] == 70.0 # twin (higher)
assert c["aos_epoch"] == T0 # boise earliest AOS
assert c["los_epoch"] == T0 + 400 # twin latest LOS
assert c["aos_compass"] == "SW" # boise
assert c["los_compass"] == "NE" # twin
assert c["peak_compass"] == "S" # twin (max-el observer)
assert c["entry_observer"] == "boise"
assert c["exit_observer"] == "twin"
assert c["observer_list"] == "boise,twin"
# Pending buffer is drained regardless of the gate's decision.
left = conn.execute(
"SELECT COUNT(*) AS n FROM satpass_pending WHERE consolidated_id=?",
(cid,)).fetchone()
assert left["n"] == 0

View file

@ -98,12 +98,13 @@ def _ingest_envelope(norad_id=25544, observer="Boise", max_el=72.5,
# ── schema / migration ───────────────────────────────────────────────
def test_schema_version_is_22():
assert SCHEMA_VERSION == 22
def test_schema_version_is_current():
# Bumped to 23 by the native-satpass observer_locations migration (v23).
assert SCHEMA_VERSION == 23
def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
"""Fresh DB migrates cleanly to v22 and satpass_pending has due_at."""
"""Fresh DB migrates cleanly and satpass_pending has due_at (v22 column)."""
from meshai.persistence import close_thread_connection
from meshai.persistence import db as persistence_db
db = str(tmp_path / "fresh-v22.sqlite")
@ -112,7 +113,7 @@ def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
close_thread_connection()
conn = init_db()
row = conn.execute("SELECT value FROM schema_meta WHERE key='version'").fetchone()
assert int(row["value"]) == 22
assert int(row["value"]) == 23
cols = {r["name"] for r in conn.execute("PRAGMA table_info(satpass_pending)")}
assert "due_at" in cols
close_thread_connection()

View file

@ -0,0 +1,187 @@
"""Tests for the native Celestrak TLE fetcher (env.tle_fetch).
Covers epoch parsing, 3-line block parsing, upsert into the shared
sat_tles table, latest-epoch-wins on re-fetch, and malformed-block
tolerance. HTTP is monkeypatched no network.
"""
from __future__ import annotations
import pytest
from meshai.env.tle_fetch import (
TLEFetchAdapter,
parse_tle_block,
parse_tle_epoch,
)
from meshai.central.tle_handler import get_tle_by_norad
from meshai.config import SatpassConfig
from meshai.persistence import get_db
# A valid ISS 3-line set. Line-1 epoch field (cols 19-32) = 26182.50000000
# -> 2026 day-of-year 182.5 -> 2026-07-01T12:00:00+00:00.
ISS_TLE = (
"ISS (ZARYA)\n"
"1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008\n"
"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12345\n"
)
# Same satellite, a NEWER epoch (26183.50 -> 2026-07-02T12:00Z).
ISS_TLE_NEWER = (
"ISS (ZARYA)\n"
"1 25544U 98067A 26183.50000000 .00016717 00000-0 10270-3 0 9010\n"
"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12347\n"
)
# Same satellite, an OLDER epoch (26181.50 -> 2026-06-30T12:00Z).
ISS_TLE_OLDER = (
"ISS (ZARYA)\n"
"1 25544U 98067A 26181.50000000 .00016717 00000-0 10270-3 0 9006\n"
"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12343\n"
)
def _adapter(**overrides) -> TLEFetchAdapter:
cfg = SatpassConfig(enabled=True, feed_source="native",
tle_groups=[], norad_ids=[25544], **overrides)
return TLEFetchAdapter(cfg)
# -- epoch parsing ------------------------------------------------------------
def test_parse_tle_epoch_iso():
iso = parse_tle_epoch(
"1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008")
assert iso.startswith("2026-07-01T12:00:00")
assert "+00:00" in iso
def test_parse_tle_epoch_two_digit_year_window():
# YY=98 -> 1998 (>= 57 maps to 1900s).
iso = parse_tle_epoch("1 25544U 98067A 98001.00000000 .0 0 0 0 1")
assert iso.startswith("1998-01-01")
# -- block parsing ------------------------------------------------------------
def test_parse_tle_block_basic():
recs = parse_tle_block(ISS_TLE)
assert len(recs) == 1
r = recs[0]
assert r["norad_id"] == 25544
assert r["name"] == "ISS (ZARYA)"
assert r["line1"].startswith("1 25544")
assert r["line2"].startswith("2 25544")
assert r["epoch"].startswith("2026-07-01T12:00:00")
def test_parse_tle_block_skips_malformed():
# First triple is garbage (line1 doesn't start with "1 "); second is valid.
block = (
"GARBAGE SAT\n"
"not a real line1\n"
"also not line2\n"
+ ISS_TLE
)
recs = parse_tle_block(block)
norads = [r["norad_id"] for r in recs]
assert 25544 in norads
# The garbage entry must not have produced a record.
assert all(isinstance(n, int) for n in norads)
# -- fetch + upsert -----------------------------------------------------------
def test_tick_upserts_into_sat_tles(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_fetch", lambda url: ISS_TLE)
changed = adapter.tick(now=1_000_000)
assert changed is True
row = get_tle_by_norad(25544)
assert row is not None
assert row["name"] == "ISS (ZARYA)"
assert row["line1"].startswith("1 25544")
assert row["line2"].startswith("2 25544")
assert row["epoch"].startswith("2026-07-01T12:00:00")
def test_latest_epoch_wins_on_refetch(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_fetch", lambda url: ISS_TLE)
assert adapter.tick(now=1_000_000) is True
first = get_tle_by_norad(25544)["epoch"]
# A newer epoch replaces it.
adapter._last_tick = 0 # bypass interval gate for the test
monkeypatch.setattr(adapter, "_fetch", lambda url: ISS_TLE_NEWER)
assert adapter.tick(now=2_000_000) is True
newer = get_tle_by_norad(25544)["epoch"]
assert newer > first
# An older epoch is ignored (no write).
adapter._last_tick = 0
monkeypatch.setattr(adapter, "_fetch", lambda url: ISS_TLE_OLDER)
changed = adapter.tick(now=3_000_000)
assert changed is False
assert get_tle_by_norad(25544)["epoch"] == newer
def test_malformed_block_does_not_crash_tick(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(
adapter, "_fetch",
lambda url: "COMPLETE GARBAGE\nno lines here\n")
# Should complete without raising and write nothing.
changed = adapter.tick(now=1_000_000)
assert changed is False
assert get_tle_by_norad(25544) is None
def test_fetch_error_is_isolated(monkeypatch):
adapter = _adapter()
def boom(url):
raise RuntimeError("HTTP 503")
monkeypatch.setattr(adapter, "_fetch", boom)
changed = adapter.tick(now=1_000_000)
assert changed is False
assert adapter.health_status["last_error"] is not None
assert adapter.health_status["consecutive_errors"] == 1
def test_storage_only_no_events(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_fetch", lambda url: ISS_TLE)
adapter.tick(now=1_000_000)
assert adapter.get_events() == []
assert adapter.to_event({}) is None
def test_interval_gate_skips_early_ticks(monkeypatch):
adapter = _adapter(tle_refresh_seconds=21600)
calls = []
monkeypatch.setattr(adapter, "_fetch",
lambda url: calls.append(url) or ISS_TLE)
adapter.tick(now=1_000_000.0) # first tick fetches
adapter.tick(now=1_000_100.0) # 100s later, within interval -> skipped
assert len(calls) == 1
def test_no_targets_configured_is_noop():
cfg = SatpassConfig(enabled=True, feed_source="native",
tle_groups=[], norad_ids=[])
adapter = TLEFetchAdapter(cfg)
assert adapter.tick(now=1_000_000) is False
def test_group_and_catnr_urls():
cfg = SatpassConfig(tle_groups=["weather"], norad_ids=[25544])
adapter = TLEFetchAdapter(cfg)
urls = [u for _, u in adapter._targets()]
assert any("GROUP=weather&FORMAT=tle" in u for u in urls)
assert any("CATNR=25544&FORMAT=tle" in u for u in urls)
assert all(u.startswith("https://celestrak.org/NORAD/elements/gp.php") for u in urls)