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:
parent
2aa8739f25
commit
b69d0b7a26
4 changed files with 142 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue