bandsaunter/tests/test_manpage.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

149 lines
5.9 KiB
Python

"""The manual page, which is generated from the settings table."""
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from bandsaunter import settings as st
GENERATOR = Path(__file__).resolve().parent.parent / "packaging" / "make-man.py"
@pytest.fixture(scope="module")
def page(tmp_path_factory):
out = tmp_path_factory.mktemp("man") / "bandsaunter.1"
subprocess.run([sys.executable, str(GENERATOR), str(out)],
check=True, capture_output=True)
return out.read_text()
def test_every_setting_is_documented(page):
"""A setting the manual does not mention is one nobody can look up."""
missing = [s.key for s in st.SETTINGS if s.key not in page]
assert not missing, f"settings missing from the manual: {missing}"
def test_every_flag_is_documented(page):
missing = [f for s in st.SETTINGS for f in s.flags + s.off_flags
if f.replace("-", "\\-") not in page and f not in page]
assert not missing, f"flags missing from the manual: {missing}"
def test_every_setting_explains_itself_in_plain_words(page):
"""The guidance is the point of the manual: what it is, when to change it."""
for s in st.SETTINGS:
assert s.guidance, f"{s.key} has no plain-language guidance"
assert len(s.guidance) > 80, f"{s.key}'s guidance says too little"
# The first sentence has to stand on its own for someone skimming.
assert s.guidance.rstrip().endswith("."), s.key
def test_the_commands_and_the_keys_are_documented(page):
for word in ("scan", "bands", "config", "transcribe", "devices",
"profiles", "analyze"):
assert f".B {word}\n" in page, f"command {word} undocumented"
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES"):
assert f".SH {section}" in page
@pytest.mark.skipif(not shutil.which("groff"), reason="groff not installed")
def test_it_renders_without_complaint(page, tmp_path):
"""Troff is unforgiving: an unescaped leading dot silently eats a line."""
src = tmp_path / "bandsaunter.1"
src.write_text(page)
proc = subprocess.run(["groff", "-man", "-Tutf8", "-ww", "-z", str(src)],
capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
assert not proc.stderr.strip(), proc.stderr
@pytest.mark.skipif(not shutil.which("groff"), reason="groff not installed")
def test_the_guidance_survives_into_the_rendered_page(page, tmp_path):
src = tmp_path / "bandsaunter.1"
src.write_text(page)
rendered = subprocess.run(["groff", "-man", "-Tutf8", str(src)],
capture_output=True, text=True).stdout
flat = " ".join(rendered.replace("\b", "").split())
# A sentence from one setting's guidance, chosen because it is the one a
# newcomer most needs: what the squelch actually is.
assert "This is the squelch knob." in flat
# -- the browser's page ------------------------------------------------------
BROWSE_GENERATOR = (Path(__file__).resolve().parent.parent / "packaging"
/ "make-browse-man.py")
@pytest.fixture(scope="module")
def browse_page(tmp_path_factory):
out = tmp_path_factory.mktemp("man") / "saunterbrowse.1"
subprocess.run([sys.executable, str(BROWSE_GENERATOR), str(out)],
check=True, capture_output=True)
return out.read_text()
def test_the_browser_has_a_page_of_its_own(browse_page):
assert ".TH SAUNTERBROWSE 1" in browse_page
assert "saunterbrowse \\- read and listen" in browse_page
def test_every_browser_flag_is_documented(browse_page):
from bandsaunter.browse import build_parser
# --help is argparse's own and needs no prose of its own.
flags = [o for a in build_parser()._actions for o in a.option_strings
if o not in ("-h", "--help")]
missing = [f for f in flags
if f.replace("-", "\\-") not in browse_page
and f not in browse_page]
assert not missing, f"undocumented flags: {missing}"
def test_every_browser_key_is_documented(browse_page):
"""A key that does something the manual does not mention is a key nobody
will press."""
from bandsaunter.browse import FILING
for key in ("Enter", "Space", "PgUp", "Home", "/", "s", "r", "o", "q",
"t", "u", "d", "m"):
assert f".B {key}\n" in browse_page or f'.B "{key}' in browse_page, key
for _key, name, _why in FILING:
assert name in browse_page, name
assert '.B "' + " ".join(k for k, _, _ in FILING) in browse_page
def test_the_browser_page_names_the_players_it_looks_for(browse_page):
from bandsaunter.browse import PLAYERS
for name, _ in PLAYERS:
assert name in browse_page, name
def test_the_two_pages_point_at_each_other(page, browse_page):
assert "saunterbrowse (1)" in page or "saunterbrowse" in page
assert "bandsaunter (1)" in browse_page
def test_the_browser_page_renders_without_complaint(browse_page, tmp_path):
groff = shutil.which("groff")
if groff is None:
pytest.skip("groff is not installed")
src = tmp_path / "saunterbrowse.1"
src.write_text(browse_page)
done = subprocess.run([groff, "-man", "-ww", "-z", str(src)],
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"