From b69d0b7a268c771209820d46037d37dd8dc96d51 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Fri, 21 Aug 2026 23:26:27 -0700 Subject: [PATCH] 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 --- bandsaunter/cli.py | 4 +- bandsaunter/config.py | 1 + bandsaunter/settings.py | 12 +++- tests/test_settings.py | 129 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index ff06d52..96981af 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -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) diff --git a/bandsaunter/config.py b/bandsaunter/config.py index 662cfc5..0175937 100755 --- a/bandsaunter/config.py +++ b/bandsaunter/config.py @@ -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" # ------------------------------------------------------------------ diff --git a/bandsaunter/settings.py b/bandsaunter/settings.py index a7ae511..6e2397c 100644 --- a/bandsaunter/settings.py +++ b/bandsaunter/settings.py @@ -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 diff --git a/tests/test_settings.py b/tests/test_settings.py index cef11d2..d7ed702 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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}"