An audit rather than a feature, prompted by wanting this fit to hand to somebody else. Five options had no command-line flag written down in the table the manual is generated from -- location, hold, schedules, tile_url and speed_unit -- so five flags that exist were missing from the manual. Four of them did exist under other names and are now recorded; hold had no flag at all and has one. Four switches could be turned off from the command line and not back on: --no-lookup, --no-basemap, --no-airports and --no-labels had no positive halves, so an option turned off in the saved settings could not be turned on again for one run. All four now have both. And adsb, which opens the window and draws a map when it stops, could not be given any of the settings that decide what those look like: no --at, no --radius, no --tiles, no --map-brightness, no --width, --fps, --trail, --fade, --stale, --airports or --labels. It takes all of them now. The manual had no list of the aircraft options at all -- the ADS-B sections were hand-written prose -- so five of them appeared nowhere in it. It now generates an AIRCRAFT OPTIONS section from the same table the menu and the flags come from, and the readme carries a table of all thirty-four with their flags and defaults. Three tests hold the three of them together: one that every option records its flag, one that every flag the table claims actually exists on a command, and one that the readme names them all. The installing instructions now list every dependency rather than only the optional ones: the four Python packages with their names in Debian, Fedora and Arch, and librtlsdr, which is a C library and therefore the one thing pip cannot bring and a virtual environment cannot supply. What reaches a network is written down too -- which host, when, and which file it is cached in -- since somebody installing this on a metered or air-gapped machine has to be able to see that nothing is fetched behind their back. build-repo.sh needs dpkg-dev and apt-utils, which a minimal system does not have, and now says so. Verified rather than asserted: a clean virtual environment, pip install from this tree, and a real log read back through the installed command. It pulls seven wheels rather than the four the page claimed, the other three being what Rich brings with it. Also: matplotlib is gone from the readme's dependency table, nothing having imported it; ffmpeg and Qt are in it, both having been missing; ffmpeg is a Suggests on the package; and resume.sh is ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
594 lines
24 KiB
Python
594 lines
24 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 reach(key: str) -> str:
|
|
"""What to type at the top of the aircraft menu to edit one option.
|
|
|
|
Its name: the options live in groups now, so a bare number there opens
|
|
a group. A name that matches one exactly goes straight to it, which is
|
|
how somebody who knows what they are looking for gets at it without
|
|
hunting through the groups first.
|
|
"""
|
|
return key
|
|
|
|
|
|
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,
|
|
[reach("simulate"), "yes", # invent a sky
|
|
reach("seconds"), "3", # listen for three seconds
|
|
reach("picture"), picture, # what to draw
|
|
reach("lookup"), "no", # no lookups: no network in a test
|
|
reach("basemap"), "no", # nor a tile server
|
|
reach("airports"), "no", # nor the map data
|
|
"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,
|
|
[reach("simulate"), "yes", reach("seconds"), "3",
|
|
reach("lookup"), "no", reach("basemap"), "no",
|
|
reach("airports"), "no", reach("draw_after"), "yes",
|
|
reach("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() == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The options, in groups
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_group_is_long_enough_to_need_scrolling():
|
|
"""Thirty-three options on one screen is a wall. The point of the
|
|
groups is that each of them fits in front of you at once."""
|
|
for group in air.OPTION_GROUPS:
|
|
items = air.in_group(group)
|
|
assert 1 <= len(items) <= 10, (group, len(items))
|
|
|
|
|
|
def test_every_option_is_in_exactly_one_group():
|
|
seen = [o for group in air.OPTION_GROUPS for o in air.in_group(group)]
|
|
assert len(seen) == len(air.OPTIONS)
|
|
assert {o.key for o in seen} == {o.key for o in air.OPTIONS}
|
|
|
|
|
|
def test_each_group_sits_together_in_the_numbering():
|
|
"""The number beside an option is its place in the whole list, so that
|
|
the same number means the same option wherever it is typed. That only
|
|
reads sensibly if a group's options are next to each other."""
|
|
for group in air.OPTION_GROUPS:
|
|
places = [air.OPTIONS.index(o) for o in air.in_group(group)]
|
|
assert places == list(range(places[0], places[0] + len(places))), group
|
|
|
|
|
|
def test_a_name_typed_in_full_goes_straight_to_that_option():
|
|
"""Typing "seconds" should reach the setting called seconds, not that
|
|
one and every other whose description mentions the word."""
|
|
for key in ("seconds", "picture", "simulate", "speed", "width", "rings"):
|
|
found = tui._find_options(key)
|
|
assert [o.key for o in found] == [key], (key, [o.key for o in found])
|
|
|
|
|
|
def test_a_part_of_a_name_finds_everything_it_could_mean():
|
|
found = [o.key for o in tui._find_options("ring")]
|
|
assert "rings" in found and "window_rings" in found
|
|
|
|
|
|
def test_a_name_nobody_has_finds_nothing():
|
|
assert tui._find_options("zzz") == []
|
|
assert tui._find_options("") == []
|
|
|
|
|
|
def test_opening_a_group_shows_its_options_and_nothing_else(monkeypatch,
|
|
console, capsys,
|
|
settings_dir):
|
|
from rich.console import Console
|
|
|
|
loud = Console(width=100, force_terminal=False, no_color=True)
|
|
where = air.OPTION_GROUPS.index("The map") + 1
|
|
run(monkeypatch, loud, [str(where), "b", "b"], ScanConfig())
|
|
printed = capsys.readouterr().out
|
|
for option in air.in_group("The map"):
|
|
assert option.label.split()[0] in printed, option.key
|
|
# An option from another group is not on that screen.
|
|
after = printed.split("the map", 2)[-1]
|
|
assert "Tuner gain" not in after and "Listen for" not in after
|
|
|
|
|
|
def test_typing_an_option_name_at_the_top_opens_that_option(monkeypatch,
|
|
console,
|
|
settings_dir):
|
|
"""The way in for somebody who knows what they are looking for and does
|
|
not want to hunt through the groups for it."""
|
|
held = air.AircraftOptions()
|
|
monkeypatch.setattr(air, "load_options", lambda *a, **kw: held)
|
|
run(monkeypatch, console, ["map brightness", "45", "b"], ScanConfig())
|
|
assert held.map_brightness == 45
|
|
|
|
|
|
def test_a_group_number_at_the_top_does_not_edit_the_option_of_that_number(
|
|
monkeypatch, console, settings_dir):
|
|
"""A bare number at the top of the menu opens a group. It used to edit
|
|
the option with that number, and the two would otherwise disagree."""
|
|
held = air.AircraftOptions()
|
|
was = held.seconds
|
|
monkeypatch.setattr(air, "load_options", lambda *a, **kw: held)
|
|
# Option 1 is the receiver; group 1 is the receiver group. Typing 1
|
|
# and then going back must leave everything alone.
|
|
run(monkeypatch, console, ["1", "b", "b"], ScanConfig())
|
|
assert held.seconds == was
|
|
assert held.device == air.AircraftOptions().device
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Every option reachable from the command line as well as the menu
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _help_for(command: str) -> str:
|
|
import subprocess
|
|
import sys
|
|
|
|
return subprocess.run([sys.executable, "-m", "bandsaunter.cli", command,
|
|
"--help"], capture_output=True, text=True,
|
|
timeout=120).stdout
|
|
|
|
|
|
def test_every_option_records_the_flag_that_sets_it():
|
|
"""The manual is generated from this table, so an option whose flag is
|
|
not written down here is a flag the manual does not mention."""
|
|
missing = [o.key for o in air.OPTIONS if not (o.flags or o.off_flags)]
|
|
assert missing == [], missing
|
|
|
|
|
|
def test_every_flag_the_table_claims_actually_exists():
|
|
"""The other way round: a flag written down here and never added to a
|
|
parser is a promise the program does not keep."""
|
|
both = _help_for("adsb") + _help_for("flights")
|
|
for option in air.OPTIONS:
|
|
for flag in tuple(option.flags) + tuple(option.off_flags):
|
|
assert flag in both, f"{option.key}: {flag} is on no command"
|
|
|
|
|
|
def test_a_switch_can_be_turned_back_on_as_well_as_off():
|
|
"""An option turned off in the saved settings could not be turned back
|
|
on for one run: only the off half of each pair had a flag."""
|
|
both = _help_for("adsb") + _help_for("flights")
|
|
for key in ("lookup", "basemap", "airports", "labels"):
|
|
option = air.by_key(key)
|
|
assert option.flags and option.off_flags, key
|
|
for flag in tuple(option.flags) + tuple(option.off_flags):
|
|
assert flag in both, f"{key}: {flag}"
|
|
|
|
|
|
def test_the_window_takes_the_options_it_draws_with():
|
|
"""adsb opens the window and draws a map when it stops, so it has to
|
|
accept the settings that decide what those look like."""
|
|
text = _help_for("adsb")
|
|
for flag in ("--at", "--radius", "--theme", "--map-brightness", "--tiles",
|
|
"--rings", "--window-rings", "--box-opacity", "--fade",
|
|
"--hold", "--speed-unit"):
|
|
assert flag in text, flag
|
|
|
|
|
|
def test_the_readme_lists_every_option_and_its_flag():
|
|
"""The readme carries a table of them. A table written by hand goes
|
|
stale the first time an option is added, so this says when it has."""
|
|
from pathlib import Path
|
|
|
|
readme = Path(__file__).resolve().parent.parent / "README.md"
|
|
if not readme.exists(): # an installed copy has none
|
|
pytest.skip("no README beside the tests")
|
|
text = readme.read_text()
|
|
for option in air.OPTIONS:
|
|
assert f"| {option.label} |" in text, f"{option.key} is not in README"
|
|
for flag in tuple(option.flags) + tuple(option.off_flags):
|
|
assert f"`{flag}`" in text, f"{option.key}: {flag} is not in README"
|