bandsaunter/tests/test_manpage.py
The Dust Council cc317914e1 Add saunterbrowse, for reading back what a scan collected
A long scan leaves hundreds of recordings, each with a JSON sidecar of
measurements and, where a recogniser heard speech, a transcript.  Reading
that meant opening files one at a time and guessing which were worth
playing.

saunterbrowse is a second executable in the same package.  Arrow keys move
through the recordings; the transcript of whichever is highlighted fills
the top of the screen, because that is the part anyone actually wants to
read.  Enter plays it, handing the file to whichever player is installed
-- the recordings are ordinary WAVs, every desktop already has something
that plays them, and a browser that cannot start would be worse than one
that cannot play.  t opens the whole transcript full screen when it is
longer than the panel, and says so rather than cutting the end off
silently.  / filters on the frequency, the name, the identification, or
anything that was said, which is the point of it: "was the repeater
mentioned" is a question about content.

Sidecars are read only for the rows on screen, so a directory of ten
thousand recordings opens instantly.  Where there is no transcript the
panel says which of the reasons applies -- Morse (decoded, and shown),
data, a bare carrier, or speech never offered to a recogniser -- because
those want different things done about them.  It only ever reads.

Two things were only found by driving it through a real terminal.
sys.stdin.read(1) goes through a buffered text wrapper, which in cbreak
mode waits for more bytes than one keypress provides: the program drew its
first frame and then hung, while tests against a stand-in stream object
passed.  It reads the file descriptor now, and the tests drive a pty.  And
stopping playback signalled only the direct child, so a player that is a
wrapper script kept the sound going with nothing on screen to stop it; the
whole process group is signalled instead, which is what start_new_session
was there for.

man saunterbrowse ships beside man bandsaunter, and the two point at each
other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-22 15:06:33 -07:00

133 lines
5.1 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."""
for key in ("Enter", "Space", "PgUp", "Home", "/", "s", "r", "o", "q",
"t"):
assert key in browse_page, key
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