Cover every option in the help, the manual and the readme, and say what to install
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
This commit is contained in:
parent
6d2436cde1
commit
8ed01f991f
16 changed files with 1012 additions and 46 deletions
|
|
@ -527,3 +527,68 @@ def test_a_group_number_at_the_top_does_not_edit_the_option_of_that_number(
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -1566,13 +1566,14 @@ def test_an_airport_cannot_be_mistaken_for_an_aircraft():
|
|||
# The flag on the receiver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_background_flies_a_flag_where_the_receiver_was_told_it_is():
|
||||
def test_the_frame_flies_a_flag_where_the_receiver_was_told_it_is():
|
||||
track = straight()
|
||||
view = fm.fit([track], width=600)
|
||||
middle = ((view.south + view.north) / 2, (view.west + view.east) / 2)
|
||||
plain = fm.background(view, unit="knots")
|
||||
flagged = fm.background(view, unit="knots", home=middle)
|
||||
assert not (plain == fm.HOME).any()
|
||||
base = fm.background(view, unit="knots")
|
||||
assert not (base == fm.HOME).any(), "the ground still flies the flag"
|
||||
flagged = fm.render_frame(base, view, [track], track.fixes[0].at,
|
||||
unit="knots", home=middle)
|
||||
assert (flagged == fm.HOME).any(), "no flag was drawn"
|
||||
# The foot of the pole is the position it is pointing at.
|
||||
x, y = view.xy(*middle)
|
||||
|
|
@ -1584,14 +1585,37 @@ def test_no_flag_where_nobody_said_the_receiver_is():
|
|||
not a place anybody is standing, and a flag on it would say one is."""
|
||||
track = straight()
|
||||
view = fm.fit([track], width=600)
|
||||
assert not (fm.background(view, unit="knots") == fm.HOME).any()
|
||||
base = fm.background(view, unit="knots")
|
||||
assert not (fm.render_frame(base, view, [track], track.fixes[0].at,
|
||||
unit="knots") == fm.HOME).any()
|
||||
|
||||
|
||||
def test_a_receiver_outside_the_picture_is_not_flagged_at_its_edge():
|
||||
track = straight()
|
||||
view = fm.fit([track], width=600)
|
||||
base = fm.background(view, unit="knots")
|
||||
away = (view.north + 20.0, view.east + 20.0)
|
||||
assert not (fm.background(view, unit="knots", home=away) == fm.HOME).any()
|
||||
assert not (fm.render_frame(base, view, [track], track.fixes[0].at,
|
||||
unit="knots", home=away) == fm.HOME).any()
|
||||
|
||||
|
||||
def test_nothing_is_drawn_over_the_flag():
|
||||
"""It says where the receiver is standing, which is the one thing on
|
||||
the picture that must never be hidden behind an aeroplane that happened
|
||||
to fly over it."""
|
||||
track = straight()
|
||||
view = fm.fit([track], width=600)
|
||||
base = fm.background(view, unit="knots")
|
||||
# An aircraft exactly on top of the receiver, with a label and a trail.
|
||||
here = (track.fixes[0].latitude, track.fixes[0].longitude)
|
||||
img = fm.render_frame(base, view, [track], track.fixes[0].at,
|
||||
unit="knots", labels=True, home=here,
|
||||
trail_seconds=600.0)
|
||||
x, y = view.xy(*here)
|
||||
assert img[y, x] == fm.HOME, "the aircraft was drawn over the flag"
|
||||
# The whole pole, not just its foot.
|
||||
pole = img[y - fm.HOME_POLE:y + 1, x]
|
||||
assert (pole == fm.HOME).all(), "part of the pole was painted over"
|
||||
|
||||
|
||||
def test_the_animation_flies_the_flag_only_where_it_was_given_a_centre(tmp_path):
|
||||
|
|
@ -1789,9 +1813,10 @@ def test_no_rings_without_a_radius_or_without_a_position():
|
|||
assert np.array_equal(
|
||||
fm.background(view, unit="knots", ground=ground, home=None,
|
||||
rings=60.0), plain)
|
||||
flagged = fm.background(view, unit="knots", ground=ground, home=home)
|
||||
assert (flagged == fm.HOME).any() # the flag, but no rings
|
||||
assert not (flagged == fm.GROUND + fm.RING_LIFT).any()
|
||||
# A position on its own is not a reason to draw rings: it is the radius
|
||||
# that says how far out they go.
|
||||
placed = fm.background(view, unit="knots", ground=ground, home=home)
|
||||
assert not (placed == fm.GROUND + fm.RING_LIFT).any()
|
||||
|
||||
|
||||
def _labels_drawn(**over):
|
||||
|
|
@ -1851,3 +1876,74 @@ def test_a_distance_field_measures_from_the_place_it_was_given():
|
|||
assert away[0, 0] == pytest.approx(0.0, abs=0.01)
|
||||
# A degree of latitude is sixty nautical miles, near enough.
|
||||
assert away[1, 0] == pytest.approx(60.0, abs=0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The card behind a label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _labelled(opacity, shade=20):
|
||||
track = straight()
|
||||
view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
|
||||
ground = np.full((view.height, view.width), shade, dtype=np.uint8)
|
||||
base = fm.background(view, unit="knots", ground=ground, brightness=1.0)
|
||||
img = base.copy()
|
||||
fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
|
||||
entry=_Entry(), opacity=opacity)
|
||||
return base, img
|
||||
|
||||
|
||||
def test_no_card_at_all_puts_the_words_straight_on_the_map():
|
||||
"""Which is what the pictures used to look like, and still can."""
|
||||
base, img = _labelled(0.0)
|
||||
# Only pixels that are still map in both: the words themselves turn map
|
||||
# into ink, and that is the label being drawn rather than a card.
|
||||
ground = (base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)
|
||||
still = ground & (img >= fm.GROUND) & (img < fm.GROUND + fm.GROUND_SHADES)
|
||||
assert np.array_equal(img[still], base[still]), "the map was darkened"
|
||||
|
||||
|
||||
def test_a_card_darkens_the_map_under_the_words():
|
||||
base, img = _labelled(0.6)
|
||||
darker = (img < base) & (base >= fm.GROUND) & \
|
||||
(base < fm.GROUND + fm.GROUND_SHADES)
|
||||
assert darker.sum() > 200, "nothing was darkened"
|
||||
# And the coastline still shows through: not everything went to one shade.
|
||||
under = img[darker]
|
||||
assert under.max() > under.min() or under.min() > fm.GROUND
|
||||
|
||||
|
||||
def test_the_card_gets_darker_the_more_of_it_is_asked_for():
|
||||
def under(opacity):
|
||||
base, img = _labelled(opacity)
|
||||
return int(img[195:215, 295:400].astype(int).sum())
|
||||
|
||||
steps = [under(part) for part in (0.0, 0.3, 0.6, 0.9)]
|
||||
assert steps == sorted(steps, reverse=True), steps
|
||||
|
||||
|
||||
def test_a_card_the_whole_way_up_is_a_solid_panel():
|
||||
base, img = _labelled(1.0)
|
||||
assert (img == fm.PANEL).any(), "the panel colour was never used"
|
||||
ground = (base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)
|
||||
covered = ground & (img == fm.PANEL)
|
||||
assert covered.sum() > 200, "the map still shows through a solid panel"
|
||||
|
||||
|
||||
def test_the_card_fades_with_the_label_it_is_behind():
|
||||
"""A card at full strength under a label on its way out would be the
|
||||
brightest thing left of it."""
|
||||
track = straight()
|
||||
view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
|
||||
ground = np.full((view.height, view.width), 20, dtype=np.uint8)
|
||||
base = fm.background(view, unit="knots", ground=ground, brightness=1.0)
|
||||
|
||||
def darkness(strength):
|
||||
img = base.copy()
|
||||
fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
|
||||
entry=_Entry(), opacity=0.9, strength=strength)
|
||||
# How much darker, not how many pixels: a fainter card covers the
|
||||
# same rectangle and merely takes less out of it.
|
||||
return int((base.astype(int) - img.astype(int)).clip(0).sum())
|
||||
|
||||
assert darkness(1.0) > darkness(0.5) > 0
|
||||
|
|
|
|||
|
|
@ -128,9 +128,43 @@ def test_it_warns_about_the_thing_every_debian_user_hits_first():
|
|||
def test_it_lists_the_optional_dependencies_and_what_each_one_buys():
|
||||
body = INSTALL.read_text()
|
||||
for optional in ("espeak-ng", "ffmpeg", "faster-whisper", "vosk",
|
||||
"rtl-sdr", "pyqt6"):
|
||||
"rtl-sdr", "pyqt6", "openai-whisper", "pocketsphinx"):
|
||||
assert optional in body, optional
|
||||
assert "Optional dependencies" in body
|
||||
# What each one buys, and what is lost without it: the two columns are
|
||||
# the point of the table, not the list of names.
|
||||
assert "Gives you" in body and "Without it" in body
|
||||
assert "Optional." in body
|
||||
|
||||
|
||||
def test_it_lists_the_required_dependencies_and_who_needs_them():
|
||||
"""Somebody installing from scratch has to be told the whole of it,
|
||||
including the one piece pip cannot bring."""
|
||||
body = INSTALL.read_text()
|
||||
for required in ("python3-numpy", "python3-scipy", "python3-rich",
|
||||
"python3-yaml", "librtlsdr0"):
|
||||
assert required in body, required
|
||||
assert "Fedora" in body and "Arch" in body
|
||||
|
||||
|
||||
def test_it_says_which_dependency_pip_cannot_install():
|
||||
"""librtlsdr is a C library, so no virtual environment brings it and it
|
||||
is the one thing that has to come from the distribution by hand."""
|
||||
body = INSTALL.read_text()
|
||||
assert "librtlsdr" in body
|
||||
assert "pip` cannot install it" in body or "cannot come from pip" in body
|
||||
assert "dnf install rtl-sdr" in body
|
||||
assert "pacman -S rtl-sdr" in body
|
||||
assert "brew install librtlsdr" in body
|
||||
|
||||
|
||||
def test_it_says_what_reaches_a_network_and_where_it_is_cached():
|
||||
"""Nothing is fetched behind anybody's back, and somebody installing
|
||||
this on a metered or air-gapped machine has to be able to see that."""
|
||||
body = INSTALL.read_text()
|
||||
for source in ("api.adsbdb.com", "hexdb.io", "tile.openstreetmap.org",
|
||||
"overpass-api.de"):
|
||||
assert source in body, source
|
||||
assert "~/.cache/bandsaunter/tiles" in body
|
||||
|
||||
|
||||
def test_it_says_how_to_install_the_recogniser_and_the_model():
|
||||
|
|
|
|||
|
|
@ -1843,3 +1843,79 @@ def test_the_ring_labels_are_in_the_unit_the_rest_of_the_window_uses():
|
|||
["30 mi", "60 mi", "90 mi"]
|
||||
assert [text for _nm, text in ring_labels(100.0, "kph")] == \
|
||||
["46 km", "93 km", "139 km"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The card behind a box, and the flag over everything
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@qt
|
||||
def test_the_card_behind_a_box_is_as_solid_as_it_was_asked_to_be(app):
|
||||
"""The words beside an aircraft are readable over water and not over a
|
||||
city, so a card goes behind them; how much of the map it hides is the
|
||||
setting."""
|
||||
from bandsaunter.livemap import SkyView
|
||||
|
||||
from bandsaunter.flightmap import PALETTE, PANEL
|
||||
|
||||
want = PALETTE[PANEL]
|
||||
|
||||
def solid(part):
|
||||
"""Pixels that came out the panel colour exactly, which only a card
|
||||
drawn at full strength does."""
|
||||
view = SkyView(a_sky(a_blip(), box_opacity=part))
|
||||
view.show_ground = False
|
||||
picture = _rendered(view)
|
||||
return int(((picture[:, :, 2] == want[0])
|
||||
& (picture[:, :, 1] == want[1])
|
||||
& (picture[:, :, 0] == want[2])).sum())
|
||||
|
||||
# Measured against the picture with no card at all: the header strip is
|
||||
# drawn in the panel colour too, and that is not a card.
|
||||
none = solid(0.0)
|
||||
assert solid(1.0) - none > 500, "no solid card was drawn"
|
||||
# Half way is neither one nor the other: the map shows through it, so
|
||||
# hardly a pixel comes out the panel colour exactly.
|
||||
assert solid(0.5) - none < (solid(1.0) - none) / 4
|
||||
|
||||
|
||||
@qt
|
||||
def test_a_box_with_no_card_lets_the_map_through(app):
|
||||
from bandsaunter.livemap import SkyView
|
||||
|
||||
sky = a_sky(a_blip(), box_opacity=0.0)
|
||||
assert sky.box_opacity == 0.0
|
||||
view = SkyView(sky)
|
||||
view.show_ground = False
|
||||
assert _rendered(view) is not None # it draws, and does not fail
|
||||
|
||||
|
||||
def test_the_card_setting_is_kept_inside_its_range():
|
||||
assert Sky(box_opacity=-2.0).box_opacity == 0.0
|
||||
assert Sky(box_opacity=9.0).box_opacity == 1.0
|
||||
|
||||
|
||||
@qt
|
||||
def test_nothing_in_the_window_is_drawn_over_the_flag(app):
|
||||
"""It says where the receiver is standing, which is the one thing on
|
||||
the picture that must never end up behind an aeroplane that happened to
|
||||
fly over it, or behind the box belonging to one."""
|
||||
from bandsaunter.flightmap import HOME_POLE, HOME_RED
|
||||
from bandsaunter.livemap import SkyView
|
||||
|
||||
home = (32.4325, -111.0841)
|
||||
# An aircraft sitting exactly on the receiver, with a solid box.
|
||||
sky = a_sky(a_blip(lat=home[0], lon=home[1]), home=home, box_opacity=1.0)
|
||||
view = SkyView(sky)
|
||||
view.show_ground = False
|
||||
picture = _rendered(view)
|
||||
projection = view.projection()
|
||||
x, y = projection.xy(*home)
|
||||
|
||||
def is_flag(px, py):
|
||||
pixel = picture[py, px]
|
||||
return (int(pixel[2]), int(pixel[1]), int(pixel[0])) == HOME_RED
|
||||
|
||||
assert is_flag(x, y), "the foot of the pole was painted over"
|
||||
assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
|
||||
"part of the pole was painted over"
|
||||
|
|
|
|||
|
|
@ -135,3 +135,15 @@ def test_the_browser_page_renders_without_complaint(browse_page, tmp_path):
|
|||
capture_output=True, text=True)
|
||||
assert done.returncode == 0, done.stderr
|
||||
assert not done.stderr.strip(), done.stderr
|
||||
|
||||
|
||||
def test_the_manual_lists_every_aircraft_option(page):
|
||||
"""It is generated from the same table the menu and the flags are, so
|
||||
an option added to the program cannot quietly fail to be documented."""
|
||||
from bandsaunter import aircraft as air
|
||||
|
||||
for option in air.OPTIONS:
|
||||
flags = tuple(option.flags) + tuple(option.off_flags)
|
||||
assert any(flag in page for flag in flags), \
|
||||
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
||||
assert option.key in page, f"{option.key} is not named in the manual"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue