"""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() == ""