bandsaunter/tests/test_manpage.py
The Dust Council f9f0d94000 Deal with the recordings, not just read them
A night's scan leaves hundreds of files, most worth nothing and a few of
them the reason it was left running.  Sorting that out meant leaving the
browser and going at the directory with mv and rm.

Five keys, meant to be pressed once each going down the list:

  S I N   file it into saved/, investigate/ or noise/
  u       put the last one filed back
  d       delete it and its sidecars, for good -- asks first
  m       lock the frequency out, so no later scan stops on it

Each of these acts on the whole capture -- the .wav, the JSON sidecar, the
IQ, the transcript and the decoded data -- because a recording in one
directory and its transcript in another is a pair nothing will ever put
back together.  A move that cannot be finished puts back whatever already
moved.  The cursor stays on the row it was on, which is now the next
recording, since a cursor that jumped would make one-key-per-recording
impossible.

m writes to the lock-out list in the settings file, the same one the
scanner's own l key maintains, so a birdie found while reading last night's
recordings is gone from tonight's.  It says "the next scan": one already
running read its settings when it started.

The subdirectories sit under the recordings directory, so a scan writing
there never looks in them, and saunterbrowse ~/bandsaunter/saved reads one
back.

Also here, because this is the first part of the browser that writes:

 - The help screen is back inside eighty by twenty-four.  It had grown past
   the bottom of an ordinary window, which puts "q quit" off the screen.
 - The footer drops keys in a deliberate order when the window is narrow,
   rather than ellipsising whichever happened to be at the end.
 - Moving or deleting what is playing stops the player first.
 - The pty harness accepted an env and ignored it, so a test aimed at a
   throwaway settings directory wrote to the real one.  It honours it now,
   and conftest redirects the settings directory for every test besides.

1014 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-29 13:21:32 -07:00

137 lines
5.4 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