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
133 lines
5.1 KiB
Python
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
|