A consumer weather station is two things. The display on the kitchen wall is one of them; the other is a plastic box on a fence post that says what it can see every sixteen seconds, in the clear, to anyone who happens to be listening. This reads the box. A section of its own, like the aircraft one, and for the same reason: it does not fit through the scanner. A sensor message is a burst of a carrier switched on and off, a fifth of a second long, and the scan path is a squelch and a recorder -- it would record the bursts as clicks in a WAV file and decode nothing. `bandsaunter weather` listens, `bandsaunter readings` reads a log back, `bandsaunter sensors` says what is out there. Item 6 in the main menu is the same thing without a command line. Five families: the Tower 592TXR, the 5-in-1, the 6045M lightning detector, the 609TXC and the 606TX. Temperature, humidity, wind speed and direction, rainfall, strike counts, how far off the storm is, and battery state from all of them. Every one is implemented from its published description and checked against frames built from the same description, which proves the framing, the parity, the checksums and the arithmetic and is not the same as having held one of each. The naming is the point. A sensor broadcasts an identity, and that identity is a number that came out of a hat in a factory; it tells one sensor from another and is no use at all for telling which is which. So press n while listening: the display comes down, the sensors are listed, you name one, and it goes back up, with the receiver running throughout. That is the moment it is possible -- the sensor is on the screen saying 3.1 degrees, and the person watching is the one who knows that the cold one is the shed. An hour later it is a list of hexadecimal again. Names are written the instant they are given rather than at exit, to a neighbouring file renamed over the old one, and one given before a sensor has ever been heard waits under its identity and moves across when the first message says which model it is. Four things keep the neighbours' doorbells off the display. The checks the message carries; a second copy, for the two models that carry only one byte of check between them; a plausibility range, because a checksum can be satisfied by a message the hardware could not send; and where in the burst the message sits. That last one is the one that is easy to miss: a seven-byte message read out of the front of a real eight-byte one is made of that message's own payload bytes, whose parity is already correct, so the parity bits contribute nothing and one byte of sum is all that is left -- and corroboration cannot help, the three copies being identical. What gives that window away every time is that it ends a whole byte before the burst does. The Atlas is nine bytes like the lightning detector and lays its payload out differently, so every decoder insists on a message type it knows. Anything else that frames correctly is reported with its identity and no weather, because wrong weather under somebody's sensor name is a worse answer than none. ism.py now delegates to this rather than keeping a second implementation of the tower sensor, which fixes the channel letters -- A is 3, B is 2, C is 0, and there is no D -- and the battery bit, which is set while the battery is good. The two thinly-checked models are not reported from a scan at all: a scan hears one burst, and they need two. The option menus are now handed the module that owns the options rather than importing the aircraft one, so one set of screens drives both sections and will drive a third. 169 new tests, checked against nineteen deliberately broken builds; two of the tests were too weak to notice their own mutation and were rewritten. Full suite 2252 passed. Built as 2026-09-07_01. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
168 lines
6.7 KiB
Python
168 lines
6.7 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", "weather", "readings", "sensors"):
|
|
assert f".B {word}\n" in page, f"command {word} undocumented"
|
|
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
|
|
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES",
|
|
"AIRCRAFT OPTIONS", "WEATHER SENSORS", "WEATHER OPTIONS"):
|
|
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"
|
|
|
|
|
|
def test_the_manual_lists_every_weather_option(page):
|
|
"""The same, for the other section, from the other table."""
|
|
from bandsaunter import weather as wx
|
|
|
|
for option in wx.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"
|
|
|
|
|
|
def test_the_manual_says_where_the_sensor_names_are_kept(page):
|
|
"""They are the only thing this program stores that somebody typed."""
|
|
assert "sensors.yaml" in page
|
|
assert "weather.yaml" in page
|
|
assert "weather_" in page
|