bandsaunter/tests/test_aircraft_menu.py
The Dust Council e50d43d6e2 Frame the map on the receiver, and throw out what never happened
A first real capture came back as a map spanning 240 degrees north to 20
south, with the aircraft an indistinguishable smudge in one corner.  Two
separate faults, one of them mine from the start.

A position is sent as half a position -- an even frame and an odd one --
and the pair only means anything while the aircraft has not moved between
them.  The registry kept the last of each forever and paired them
regardless of age, so an even frame from ten minutes ago decoded against
a fresh odd one to a place on the wrong side of the world.  Measured: a
pair 300 seconds apart puts the aircraft 2,566 nm from where it is, and
one night's log had it happening to two aircraft in three, with eleven
positions off the planet altogether.  A pair is now good for ten seconds,
the answer has to be on Earth, and the aircraft has to have been able to
reach it.

--radius, defaulting to a hundred, frames the picture on the receiver
rather than on whatever was heard, so the scale is the same from one
evening to the next.  In the same unit as the speeds.  The centre is the
median of everything heard, which a handful of wrong positions cannot
move, or --at LAT,LON says where the aerial is.

--recheck repairs a log recorded before all this: for each aircraft it
keeps the longest run of positions that could describe one aeroplane.
Not a forward walk dropping whatever disagrees with the last position
kept -- that lets one bad fix become the reference, and on the same log
it discarded a fifth of everything, most of it the truth.

Two calibrations came from the recording rather than from taste.  The
failures separate cleanly -- a hundred artefacts under a mile, twelve
hundred real errors over fifty, and nothing in between -- because
positions are stamped to the millisecond, so two a thousandth of a second
apart imply thousands of knots across a few yards.  Nothing under two
miles is called an error.  Afterwards the worst surviving jump is 513 kt.

One rendering bug the tighter frame exposed: an aircraft just off the top
left painted 743,774 pixels of an 844,200-pixel picture, because the dot
at a marker's centre clamped its near edge and left the far one alone, and
numpy reads a negative slice end as counting back from the far side.  And
a still of a whole evening was dead-reckoning every aircraft forward to
the final moment, which for a ten-hour log flew 291 of 335 clean off the
picture; a still now draws each where it was last actually heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 12:25:39 -07:00

431 lines
17 KiB
Python

"""The aircraft menu: listening and drawing without a command line.
Everything here runs against the invented sky, so nothing needs a receiver,
an aerial or a network.
"""
import json
import pytest
from rich.console import Console
from bandsaunter import aircraft as air, tui
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
@pytest.fixture
def console():
return Console(width=100, file=open("/dev/null", "w"),
force_terminal=False)
@pytest.fixture
def settings_dir(tmp_path, monkeypatch):
"""Options are saved beside the settings, which must not be the real ones."""
monkeypatch.setenv("BANDSAUNTER_CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setattr("bandsaunter.config.DEFAULT_CONFIG_DIR",
tmp_path / "cfg")
return tmp_path / "cfg"
class _Done(Exception):
"""Raised when the script runs out, to break out of a menu loop."""
def drive(monkeypatch, answers):
script = list(answers)
def fake_ask(console, prompt, default=""):
if not script:
raise _Done()
return script.pop(0)
monkeypatch.setattr(tui, "_ask", fake_ask)
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
return script
def number(key: str) -> str:
"""The menu number of one option, looked up rather than counted.
The numbering moves whenever an option is added, and a test that has
memorised it silently edits the wrong one.
"""
return str(air.OPTIONS.index(air.by_key(key)) + 1)
def run(monkeypatch, console, answers, cfg):
drive(monkeypatch, answers)
try:
tui.aircraft_menu(console, cfg)
except _Done:
pass
# ---------------------------------------------------------------------------
# The band that is not a scan
# ---------------------------------------------------------------------------
def test_the_band_plan_still_lists_where_ads_b_is():
"""It is a real band and belongs in the plan; the warning is the fix,
not removing it."""
from bandsaunter.bandplan import PRESETS
assert any(p.key == "adsb" for p in PRESETS)
@pytest.mark.parametrize("spec", ["1089M-1091M", "1080M-1100M", "1.09G-1.1G"])
def test_a_sweep_over_1090_says_it_cannot_decode_it(spec):
warning = air.scanning_aircraft_band(parse_range_list(spec))
assert "ADS-B" in warning and "cannot decode" in warning
def test_the_uat_band_is_named_too():
assert "UAT" in air.scanning_aircraft_band(parse_range_list("977M-979M"))
@pytest.mark.parametrize("spec", ["144M-148M", "462M-468M", "88M-108M"])
def test_an_ordinary_sweep_is_not_warned_about(spec):
assert air.scanning_aircraft_band(parse_range_list(spec)) == ""
def test_nothing_configured_warns_about_nothing():
assert air.scanning_aircraft_band([]) == ""
assert air.scanning_aircraft_band(None) == ""
def test_the_menu_says_so_the_moment_the_band_is_chosen(console, capsys):
"""The band plan is where this mistake is made, so that is where it is
caught."""
loud = Console(width=100)
cfg = ScanConfig(ranges=parse_range_list("1089M-1091M"))
tui.warn_about_aircraft_bands(loud, cfg)
printed = capsys.readouterr().out
assert "aircraft mode" in printed
assert "Aircraft (ADS-B)" in printed
def test_the_scan_command_says_so_before_it_starts(capsys):
from bandsaunter import cli
cfg = ScanConfig(ranges=parse_range_list("1089M-1091M"))
cli._warn_about_aircraft_bands(cfg)
printed = capsys.readouterr().out
assert "bandsaunter adsb" in printed
assert "flights" in printed
def test_a_scan_of_an_ordinary_band_says_nothing(capsys):
from bandsaunter import cli
cli._warn_about_aircraft_bands(ScanConfig(ranges=parse_range_list("2m")))
assert capsys.readouterr().out.strip() == ""
# ---------------------------------------------------------------------------
# The options
# ---------------------------------------------------------------------------
def test_every_option_has_help_and_a_home_on_the_screen():
for option in air.OPTIONS:
assert option.help, option.key
assert option.detail, option.key
assert option.group in air.OPTION_GROUPS, option.key
assert hasattr(air.AircraftOptions(), option.key), option.key
def test_the_options_are_the_ones_the_listening_and_drawing_take():
"""Anything settable must be something the code actually reads."""
named = {o.key for o in air.OPTIONS}
assert named == set(air.AircraftOptions().__dict__)
def test_a_zero_that_means_something_says_what_it_means():
options = air.AircraftOptions()
assert air.format_option(air.by_key("seconds"), 0.0) == "until stopped"
assert air.format_option(air.by_key("trail"), 0.0) == "the whole path"
assert air.format_option(air.by_key("seconds"), 60.0) == "60 s"
assert "gif" in air.describe(options)
def test_an_option_is_changed_from_the_menu(monkeypatch, console, settings_dir,
tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("width"), "640", "b"], cfg)
assert air.load_options().width == 960 # not saved yet
run(monkeypatch, console, [number("width"), "640", "s", "b"], cfg)
assert air.load_options().width == 640 # saved on request
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("width"), "banana", "", "s", "b"], cfg)
assert air.load_options().width == 960
def test_a_value_the_options_refuse_is_rolled_back(monkeypatch, console,
settings_dir, tmp_path):
"""Two megasamples a second is the floor; below it a bit cannot be seen."""
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("rate"), "500000", "", "s", "b"], cfg)
assert air.load_options().rate >= 2_000_000
def test_help_for_one_option_is_shown_without_changing_it(monkeypatch,
settings_dir,
tmp_path, capsys):
loud = Console(width=100)
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, loud, ["?" + number("simulate"), "b"], cfg)
printed = capsys.readouterr().out
assert "Invent a sky" in printed
assert "command line:" in printed and "--simulate" in printed
assert air.load_options().simulate is False
def test_the_options_survive_being_saved_and_read_back(settings_dir):
options = air.AircraftOptions(seconds=90.0, picture="png", simulate=True,
width=640, labels=False)
air.save_options(options)
again = air.load_options()
assert (again.seconds, again.picture, again.simulate) == (90.0, "png", True)
assert again.width == 640 and again.labels is False
def test_a_broken_options_file_falls_back_to_the_defaults(settings_dir):
air.options_path().parent.mkdir(parents=True, exist_ok=True)
air.options_path().write_text("{{{ not yaml at all")
assert air.load_options().picture == "png" or True # must not raise
assert air.load_options().width == 960
# ---------------------------------------------------------------------------
# Listening and drawing, from the menu alone
# ---------------------------------------------------------------------------
def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console,
[number("simulate"), "yes", # invent a sky
number("seconds"), "3", # listen for three seconds
number("picture"), picture, # what to draw
number("lookup"), "no", # no lookups: no network in a test
number("basemap"), "no", # nor a tile server
"l", # listen now
"m", "1", # draw the newest log
"b"], cfg)
return cfg
def test_listening_from_the_menu_writes_a_log_and_a_report(monkeypatch,
console,
settings_dir,
tmp_path):
_listen_and_draw(monkeypatch, console, tmp_path)
logs = list(tmp_path.glob("adsb_*.jsonl"))
assert len(logs) == 1
lines = [json.loads(x) for x in logs[0].read_text().splitlines() if x]
assert lines[0]["log"] == "bandsaunter-adsb"
assert any(line.get("icao") for line in lines[1:])
told = logs[0].with_suffix(".txt")
assert told.is_file() and "aircraft" in told.read_text()
def test_drawing_from_the_menu_writes_the_picture(monkeypatch, console,
settings_dir, tmp_path):
from bandsaunter.images import PNG_SIGNATURE
_listen_and_draw(monkeypatch, console, tmp_path)
pictures = list(tmp_path.glob("adsb_*.png"))
assert len(pictures) == 1
assert pictures[0].read_bytes()[:8] == PNG_SIGNATURE
def test_the_animation_can_be_chosen_instead(monkeypatch, console,
settings_dir, tmp_path):
_listen_and_draw(monkeypatch, console, tmp_path, picture="gif")
made = list(tmp_path.glob("adsb_*.gif"))
assert len(made) == 1
assert made[0].read_bytes()[:6] == b"GIF89a"
def test_drawing_when_there_is_nothing_to_draw_says_so(monkeypatch,
settings_dir,
tmp_path, capsys):
loud = Console(width=100)
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, loud, ["m", "b"], cfg)
assert "no logs" in capsys.readouterr().out
def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console,
settings_dir, tmp_path):
"""One key, from nothing to a picture."""
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console,
[number("simulate"), "yes", number("seconds"), "3",
number("lookup"), "no", number("basemap"), "no",
number("draw_after"), "yes",
number("picture"), "png", "l", "b"], cfg)
assert list(tmp_path.glob("adsb_*.png"))
def test_a_receiver_that_cannot_be_opened_returns_to_the_menu(monkeypatch,
settings_dir,
tmp_path,
capsys):
"""No dongle, or one in use by something else: say so and come back."""
from bandsaunter.device import RtlSdrError
def refuse(*a, **k):
raise RtlSdrError("no device found")
monkeypatch.setattr("bandsaunter.device.RtlSdrDevice", refuse)
loud = Console(width=100)
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, loud, ["l", "b"], cfg) # simulate is off
assert "cannot open the receiver" in capsys.readouterr().out
def test_the_logs_are_listed_newest_first(tmp_path):
import os
import time
for i, name in enumerate(("adsb_a.jsonl", "adsb_b.jsonl", "adsb_c.jsonl")):
path = tmp_path / name
path.write_text("{}\n")
os.utime(path, (time.time() + i, time.time() + i))
assert [p.name for p in air.logs_in(tmp_path)] == \
["adsb_c.jsonl", "adsb_b.jsonl", "adsb_a.jsonl"]
def test_the_main_menu_offers_it(monkeypatch, console):
"""It has to be reachable, or none of the above matters."""
from bandsaunter import tui as menus
seen = {}
monkeypatch.setattr(menus, "aircraft_menu",
lambda console, cfg: seen.setdefault("opened", True))
drive(monkeypatch, ["5", "q"])
menus._main_loop(console, ScanConfig())
assert seen.get("opened")
def test_the_speed_unit_is_an_option_with_the_three_choices():
option = air.by_key("speed_unit")
assert option is not None
assert set(option.choices) == {"knots", "mph", "kph"}
assert "distance" in option.detail # it moves the miles too
def test_the_speed_unit_is_changed_from_the_menu(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("speed_unit"), "mph", "s", "b"], cfg)
assert air.load_options().speed_unit == "mph"
def test_a_unit_that_is_not_one_of_the_three_is_refused(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console,
[number("speed_unit"), "furlongs", "", "s", "b"], cfg)
assert air.load_options().speed_unit == "knots"
def test_the_map_underneath_is_an_option_that_can_be_turned_off(monkeypatch,
console,
settings_dir,
tmp_path):
"""It fetches from a tile server the first time an area is drawn, so it
has to be possible to say no."""
assert air.AircraftOptions().basemap is True
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("basemap"), "no", "s", "b"], cfg)
assert air.load_options().basemap is False
# ---------------------------------------------------------------------------
# How far the map reaches
# ---------------------------------------------------------------------------
def test_the_radius_defaults_to_a_hundred():
"""An aerial hears about that far; a map drawn to fit everything heard
is drawn to fit the mistakes."""
assert air.AircraftOptions().radius == 100.0
def test_the_radius_is_changed_from_the_menu(monkeypatch, console,
settings_dir, tmp_path):
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("radius"), "40", "s", "b"], cfg)
assert air.load_options().radius == 40.0
def test_the_radius_follows_the_unit_the_speeds_are_in():
"""A picture measuring its speeds in one unit and its own extent in
another would be a puzzle rather than a map."""
assert air.radius_in_nm(air.AircraftOptions(radius=100)) == 100.0
assert air.radius_in_nm(
air.AircraftOptions(radius=100, speed_unit="mph")) == pytest.approx(
86.9, abs=0.1)
assert air.radius_in_nm(
air.AircraftOptions(radius=100, speed_unit="kph")) == pytest.approx(
54.0, abs=0.1)
def test_no_radius_at_all_goes_back_to_fitting_what_was_heard():
assert air.radius_in_nm(air.AircraftOptions(radius=0)) == 0.0
def test_the_receiver_position_can_be_given_or_worked_out():
from bandsaunter.flightlog import read_position
assert read_position("32.54,-111.17") == pytest.approx((32.54, -111.17))
assert read_position("") is None
assert read_position("somewhere near Tucson") is None
assert read_position("240.0,-111.0") is None # not a place
assert air.AircraftOptions().location == "" # worked out by default
def test_rechecking_is_an_option_and_is_off_unless_asked(monkeypatch, console,
settings_dir,
tmp_path):
"""Logs written by this version have the check applied as they are
written, so it is a repair for older ones rather than a default."""
assert air.AircraftOptions().recheck is False
cfg = ScanConfig(output_dir=str(tmp_path))
run(monkeypatch, console, [number("recheck"), "yes", "s", "b"], cfg)
assert air.load_options().recheck is True
def test_rechecking_says_what_it_threw_out(capsys):
from bandsaunter.flightlog import Fix, Track
loud = Console(width=100)
track = Track(icao="4CA1FA", fixes=[
Fix(at=0.0, latitude=51.5, longitude=-0.12),
Fix(at=0.5, latitude=-9.5, longitude=-112.5),
Fix(at=1.0, latitude=51.5, longitude=-0.12)])
air.checked(loud, air.AircraftOptions(recheck=True), [track])
printed = capsys.readouterr().out
assert "dropped" in printed and "could have been in" in printed
def test_a_clean_log_is_told_so_rather_than_left_silent(capsys):
loud = Console(width=100)
air.checked(loud, air.AircraftOptions(recheck=True), [])
assert "checks out" in capsys.readouterr().out
def test_leaving_it_off_changes_nothing(capsys):
from bandsaunter.flightlog import Fix, Track
loud = Console(width=100)
track = Track(icao="4CA1FA", fixes=[
Fix(at=0.0, latitude=51.5, longitude=-0.12),
Fix(at=0.5, latitude=-9.5, longitude=-112.5)])
same = air.checked(loud, air.AircraftOptions(recheck=False), [track])
assert same[0] is track
assert capsys.readouterr().out.strip() == ""