Aircraft, from the menus, on a live board, over a real map
Four things the ADS-B mode was missing, and one it was actively getting
wrong.
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently. Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it. It is not refused: looking at the raw spectrum
is a fair thing to want.
Menu 5, Aircraft (ADS-B), is the whole mode without a command line. Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options. The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.
While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up. The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.
--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles. The log stays in knots, which is what the aircraft broadcast.
And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture. The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on. Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.
conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
4239635f74
commit
96fc21ac7d
18 changed files with 3298 additions and 281 deletions
346
tests/test_aircraft_menu.py
Normal file
346
tests/test_aircraft_menu.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue