Audit what a profile saves, and close the two gaps it found

Every setting was checked by setting all of them to something other than
their default, saving, loading, and comparing: all 54 survived, ranges kept
every field of their own, and the default settings file behaved the same as
a named profile. Those checks are now tests rather than a one-off, along
with a check that no setting is saved but never read, and that a profile
written by an older version still loads.

Two gaps were real.

The plain display could only be asked for per invocation. It was a
hand-written flag rather than an entry in the settings table, so unlike
`quiet` -- which does the same sort of thing -- it could not be saved,
leaving anyone who runs over ssh to pass --plain every time. It is a
setting now, which gives it a menu entry and --no-plain for free.

Gain displayed as "28.5 dB" but refused to parse "28.5 dB" back, because it
was the one numeric setting that called float() instead of the unit-aware
parser every other one uses. The menu shows a value as the example of how
to type it, so what it shows has to be something it accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
The Dust Council 2026-08-21 23:26:27 -07:00
parent 2aa8739f25
commit b69d0b7a26
4 changed files with 142 additions and 4 deletions

View file

@ -78,8 +78,6 @@ examples:
st.add_arguments(s)
g = s.add_argument_group("presentation")
g.add_argument("--plain", action="store_true",
help="line-per-hit output instead of the live display")
g.add_argument("--simulate", action="store_true",
help="use a synthetic receiver instead of real hardware")
g.add_argument("--dry-run", action="store_true",
@ -336,7 +334,7 @@ def cmd_scan(args) -> int:
return 2
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
rc = (_run_plain(scanner, cfg) if (args.plain or cfg.quiet or
rc = (_run_plain(scanner, cfg) if (cfg.plain or cfg.quiet or
not sys.stdout.isatty())
else _run_live(scanner, cfg))
_print_summary(scanner)

View file

@ -126,6 +126,7 @@ class ScanConfig:
max_cycles: int = 0 # 0 = run forever
max_runtime_seconds: float = 0.0 # 0 = no limit
quiet: bool = False
plain: bool = False # line per hit instead of the live display
log_file: str = "scan_log.jsonl"
# ------------------------------------------------------------------

View file

@ -391,6 +391,13 @@ SETTINGS: tuple[Setting, ...] = (
S("quiet", "Quiet output", "Run control", "bool",
"print errors only",
"", flags=("--quiet",), off_flags=("--no-quiet",)),
S("plain", "Plain display", "Run control", "bool",
"print one line per hit instead of the live display",
"The live display redraws a spectrum and a table in place, which wants "
"a real terminal. Plain output prints a line per recording instead, "
"which is what you want over ssh, in a log, or piped to another "
"program. It is chosen automatically when output is not a terminal.",
flags=("--plain",), off_flags=("--no-plain",)),
)
GROUPS: tuple[str, ...] = tuple(dict.fromkeys(s.group for s in SETTINGS))
@ -507,7 +514,10 @@ def parse_value(setting: Setting, text):
elif kind == "gain":
if raw.lower() in ("auto", "agc", ""):
return "auto"
value = float(raw)
# Through _numeric, so "28.5 dB" is accepted as readily as "28.5" --
# the menu displays the value with its unit, and what it displays
# has to be something the user can type straight back.
value = _numeric(setting, raw)
if value < 0:
raise SettingError("gain cannot be negative")
return value

View file

@ -286,3 +286,132 @@ def test_no_config_ignores_the_saved_file(tmp_path, monkeypatch):
cfg, source = cli._build_config(args)
assert cfg.hang_seconds == ScanConfig().hang_seconds
assert source is None
# ---------------------------------------------------------------------------
# Persistence: what you set is what comes back
# ---------------------------------------------------------------------------
def a_different_value(s, current):
"""A legal value for this setting that is not the one it has now."""
if s.kind == "bool":
return not current
if s.kind in ("float", "int"):
lo = s.minimum if s.minimum is not None else 0.0
hi = s.maximum
v = (current or 0) + 7 + (0.5 if s.kind == "float" else 0)
if hi is not None and v > hi:
v = (lo + hi) / 2.0
if v < lo:
v = lo + 1
return int(v) if s.kind == "int" else float(v)
if s.kind == "choice":
return next(c for c in s.choices if c != current)
if s.kind == "opt_float":
return 4.5 if current is None else None
if s.kind == "accept_list":
return ["cw", "carrier"]
if s.kind == "gain":
return 28.5
if s.kind == "direct":
return 2
if s.kind == "path":
return "/tmp/somewhere-else"
if s.kind == "freq_list":
return [162_550_000.0, 146_520_000.0]
if s.kind == "text":
return f"probe-{s.key}"
raise AssertionError(f"no test value for kind {s.kind!r} ({s.key})")
def test_every_setting_survives_being_saved_to_a_profile(tmp_path):
"""Set all of them to something new, save, load, and expect it all back."""
from bandsaunter.config import load_config, save_config
cfg = ScanConfig()
expected = {}
for s in st.SETTINGS:
expected[s.key] = a_different_value(s, getattr(cfg, s.key))
setattr(cfg, s.key, expected[s.key])
save_config(cfg, "audit", tmp_path)
back = load_config("audit", tmp_path)
lost = {k: (v, getattr(back, k)) for k, v in expected.items()
if getattr(back, k) != v}
assert not lost, f"settings that did not survive the round trip: {lost}"
def test_the_default_settings_file_keeps_everything_too(tmp_path):
"""The file every run starts from, not just named profiles."""
from bandsaunter.config import load_default, save_default
cfg = ScanConfig()
expected = {}
for s in st.SETTINGS:
expected[s.key] = a_different_value(s, getattr(cfg, s.key))
setattr(cfg, s.key, expected[s.key])
save_default(cfg, tmp_path)
back, _ = load_default(tmp_path)
lost = [k for k, v in expected.items() if getattr(back, k) != v]
assert not lost, f"lost from the default settings: {lost}"
def test_ranges_keep_every_one_of_their_own_fields(tmp_path):
"""A range carries its mode and bandwidth, and a scan is wrong without them."""
import dataclasses
from bandsaunter.config import load_config, save_config
from bandsaunter.ranges import ScanRange
cfg = ScanConfig(ranges=[
ScanRange(7_000_000, 7_300_000, step=500, mode="lsb", bandwidth=2_800,
label="40 m phone", preset_key="40m-phone", enabled=False,
threshold_db=6.0)])
save_config(cfg, "ranges", tmp_path)
back = load_config("ranges", tmp_path)
assert len(back.ranges) == 1
for f in dataclasses.fields(ScanRange):
assert getattr(back.ranges[0], f.name) == getattr(cfg.ranges[0], f.name), f.name
def test_a_profile_from_an_older_version_still_loads(tmp_path):
"""Missing keys take their defaults; keys we no longer know are dropped."""
import yaml
from bandsaunter.config import load_config
(tmp_path / "old.yaml").write_text(yaml.safe_dump(
{"hang_seconds": 9.0, "ranges": ["144M-148M"],
"setting_we_removed": 3}))
cfg = load_config("old", tmp_path)
assert cfg.hang_seconds == 9.0
assert cfg.record_seconds == ScanConfig().record_seconds
assert cfg.ranges and cfg.ranges[0].start == 144_000_000
assert cfg._unknown_keys == ["setting_we_removed"]
@pytest.mark.parametrize("setting", st.SETTINGS, ids=lambda s: s.key)
def test_what_the_menu_shows_can_be_typed_straight_back(setting):
"""The displayed form is the documentation for the format, so it must parse.
Copying "28.5 dB" back into the gain prompt used to be refused.
"""
cfg = ScanConfig()
for value in (getattr(cfg, setting.key),
a_different_value(setting, getattr(cfg, setting.key))):
shown = st.format_value(setting, value)
assert st.parse_value(setting, shown) == value, \
f"{setting.key} displays as {shown!r}"
def test_no_setting_is_saved_but_never_read():
"""A setting nothing consults is a promise the app does not keep."""
import re
from pathlib import Path
src = "\n".join(p.read_text() for p in
sorted(Path(st.__file__).parent.glob("*.py")))
unread = [s.key for s in st.SETTINGS
if not re.search(rf"\bcfg\.{s.key}\b|\bconfig\.{s.key}\b", src)]
assert not unread, f"settings nothing ever reads: {unread}"