bandsaunter/tests/test_licence.py
The Dust Council df080f6571 A window on the sky, and flags on the routes
The terminal board says what is overhead.  This says where: a real map
with the aircraft moving on it as the frames arrive, and beside each one a
box carrying everything known about the flight -- type and registration,
who operates it, where it came from and where it is going, height with a
rate of climb, speed and heading, how far away and on what bearing, its
position, how many frames it has sent and how long since the last one.

Qt is asked for and not required.  Four bindings are tried, the module
imports on a machine with none of them, and asking for the window without
one gets the instructions rather than a traceback -- before the receiver
is opened, since nothing is gained by taking the dongle for a window that
cannot be drawn.

In the menu, "listen now" is now "passive capture" with a realtime
display beside it.  Closing the window leaves exactly the files pressing
control-C leaves, because listen and watch share one read loop and one
finishing step; the receiver runs on its own thread, so a slow repaint
cannot cost a frame and a slow tile fetch cannot stall the picture.

The animation's labels grew to match: flight level and speed, type and
registration, and both ends of the route, each with a small flag of the
country its airport is in.  The flags are a table rather than a network --
twelve pixels by eight, where a flag is the arrangement that makes one
recognisable rather than a rendering of the real thing -- and a country
not in the table is named by its two letters, since a flag that is nearly
another country's is worse than none.  Where a route arrives as bare
codes the country comes from the ICAO prefix.

Four things found on the way.  The window ignored --seconds, so "listen
for ten minutes" meant something different with a window open; it closes
itself now.  The register was being asked twice per aircraft, once for
labels and once for airport positions.  Cached routes had no country in
them, so the first real redraw drew no flags at all -- routes are
versioned now.  And past fourteen aircraft on one frame the labels go
back to the callsign, the height and the speed, because five lines beside
each of three hundred aircraft is a page of overlapping text with a map
somewhere behind it.

Long names are folded rather than allowed to stretch a box, breaking at
the arrow of a route so the two ends stay whole; and the animation's
label placement gained the same ring search the window uses, having only
ever tried four spots.

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

211 lines
8 KiB
Python

"""The licence, and the instructions for installing under it.
Both are files a person reads rather than code that runs, which is exactly why
they are worth a test: nothing else notices when a licence file goes missing
from a package, when the version notice stops naming the terms, or when the
install instructions describe a command that no longer exists.
"""
import re
import subprocess
import sys
from pathlib import Path
import pytest
import bandsaunter
ROOT = Path(__file__).resolve().parent.parent
LICENCE = ROOT / "LICENSE"
INSTALL = ROOT / "INSTALL.md"
README = ROOT / "README.md"
# ---------------------------------------------------------------------------
# The licence itself
# ---------------------------------------------------------------------------
def test_the_licence_is_there_and_is_the_gnu_gpl():
assert LICENCE.is_file(), "no LICENSE file"
text = LICENCE.read_text()
assert "GNU GENERAL PUBLIC LICENSE" in text
assert "Version 3, 29 June 2007" in text
def test_the_licence_is_the_whole_of_it():
"""A truncated licence is not a licence. The GPL ends with the notice it
tells you to attach to a program, so if that is present, so is the rest."""
text = LICENCE.read_text()
assert "TERMS AND CONDITIONS" in text
assert "How to Apply These Terms to Your New Programs" in text
assert len(text.splitlines()) > 600
def test_the_packaging_metadata_says_the_same_licence():
body = (ROOT / "pyproject.toml").read_text()
assert 'license = {text = "GPL-3.0-or-later"}' in body
assert "GNU General Public License v3 or later" in body
def test_the_readme_says_which_licence_and_points_at_the_file():
body = README.read_text()
assert "GNU General Public License" in body
assert "[LICENSE](LICENSE)" in body
# ---------------------------------------------------------------------------
# What the programs say for themselves
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("program", ["bandsaunter", "saunterbrowse"])
def test_the_version_notice_names_the_version_and_the_terms(program):
notice = bandsaunter.version_notice(program)
lines = notice.splitlines()
assert lines[0] == f"{program} {bandsaunter.__version__}"
assert "Copyright (C)" in notice
assert "GNU GPL version 3 or later" in notice
assert "NO WARRANTY" in notice
def test_the_command_prints_it():
out = subprocess.run([sys.executable, "-m", "bandsaunter", "--version"],
capture_output=True, text=True, timeout=60)
assert bandsaunter.__version__ in out.stdout
assert "GPLv3+" in out.stdout
# Four lines under the version, not reflowed into a paragraph by argparse.
assert len(out.stdout.strip().splitlines()) == 5
# ---------------------------------------------------------------------------
# The manuals
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("page", ["bandsaunter.1", "saunterbrowse.1"])
def test_both_manuals_have_a_copying_section(page):
body = (ROOT / "packaging" / page).read_text()
assert ".SH COPYING" in body
assert "General\nPublic License" in body or "General Public License" in body
# ---------------------------------------------------------------------------
# The Debian package carries the licence where policy says it must
# ---------------------------------------------------------------------------
def test_the_package_ships_the_licence_at_the_path_policy_requires():
script = (ROOT / "packaging" / "build-deb.sh").read_text()
assert "usr/share/doc/bandsaunter/copyright" in script
assert '"$here/LICENSE"' in script
def test_the_package_ships_the_install_instructions_too():
assert 'INSTALL.md' in (ROOT / "packaging" / "build-deb.sh").read_text()
# ---------------------------------------------------------------------------
# The install instructions
# ---------------------------------------------------------------------------
def test_the_install_file_exists_and_is_pointed_at_from_the_readme():
assert INSTALL.is_file(), "no INSTALL.md"
assert "[INSTALL.md](INSTALL.md)" in README.read_text()
def test_the_install_file_covers_both_ways_of_installing():
body = INSTALL.read_text()
assert "./packaging/build-deb.sh" in body
assert "python3 -m venv" in body
assert "pip install ." in body
def test_it_warns_about_the_thing_every_debian_user_hits_first():
"""pip refuses to install outside a virtual environment on Debian and
Fedora. Someone who has not met PEP 668 reads that error as a broken
program."""
body = INSTALL.read_text()
assert "externally-managed-environment" in body
assert "PEP 668" in body
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"):
assert optional in body, optional
assert "Optional dependencies" in body
def test_it_says_how_to_install_the_recogniser_and_the_model():
"""The one part with a real download in it, and the part that has to be
put in the same environment bandsaunter runs from."""
body = INSTALL.read_text()
assert "pip install faster-whisper" in body
assert "--user --break-system-packages" in body # the ~/.local layout
assert "bandsaunter transcribe --engines" in body # how to check it took
assert "Systran/faster-whisper-base.en" in body # what gets downloaded
assert "~/.cache/huggingface" in body # and where it lands
assert "WhisperModel('base.en'" in body # fetching it on purpose
def test_the_model_the_instructions_name_is_the_one_the_program_asks_for():
"""The instructions download base.en because that is the default; if the
default moves, they are downloading the wrong thing."""
from bandsaunter.config import ScanConfig
assert ScanConfig().transcribe_model in INSTALL.read_text()
def test_it_names_the_ways_a_model_can_be_supplied_without_a_network():
body = INSTALL.read_text()
assert "BANDSAUNTER_MODEL_DIR" in body
assert "/usr/share/bandsaunter/models" in body
assert "build-repo.sh" in body
def test_it_says_where_the_engine_output_switch_is():
"""A silent minute on the first transcription reads as a hang."""
assert "BANDSAUNTER_ENGINE_OUTPUT=1" in INSTALL.read_text()
def test_the_environment_variables_it_names_are_the_ones_the_code_reads():
from bandsaunter import transcribe as tr
body = INSTALL.read_text()
for variable in ("BANDSAUNTER_MODEL_DIR", "BANDSAUNTER_ENGINE_OUTPUT"):
assert variable in body
assert str(tr.MODEL_DIR) in body or "BANDSAUNTER_MODEL_DIR" in body
def test_the_settings_it_tells_you_to_change_exist():
from bandsaunter.config import ScanConfig
fields = set(ScanConfig().__dict__)
told = set(re.findall(r"bandsaunter config ([a-z_]+)=", INSTALL.read_text()))
assert told, "the instructions change no settings at all"
assert told <= fields, told - fields
def test_it_says_what_to_do_when_the_dongle_is_not_found():
body = INSTALL.read_text()
assert "dvb_usb_rtl28xxu" in body
assert "librtlsdr" in body
def test_every_bandsaunter_command_it_tells_you_to_run_exists():
"""An instruction to run a command that was renamed is worse than none."""
from bandsaunter.cli import build_parser
known = set()
for action in build_parser()._subparsers._group_actions:
known.update(action.choices)
told = set(re.findall(r"^\s*(?:\$ )?bandsaunter ([a-z]+)",
INSTALL.read_text(), re.MULTILINE))
assert told, "the instructions run no commands at all"
assert told <= known | {"--version"}, told - known
def test_the_flags_it_tells_you_to_use_are_real():
from bandsaunter.cli import build_parser
parser = build_parser()
for line in ("scan -b 2m --simulate", "adsb --simulate --seconds 30",
"devices --test", "transcribe --engines"):
parser.parse_args(line.split()) # raises SystemExit if not