bandsaunter/tests/test_licence.py
The Dust Council 8ed01f991f 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
2026-09-06 14:12:33 -07:00

245 lines
9.5 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", "openai-whisper", "pocketsphinx"):
assert optional in body, optional
# 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():
"""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