bandsaunter/tests/test_settings.py
The Dust Council 44c98b11e3 Remember runtime lock-outs, and let a lock-out be a span
Pressing `l` during a scan locked a frequency out for that run only, so the
same birdie had to be locked out again on every later one. It now writes
back to the settings file the run started from -- only that one key, since
a scan's config also holds whatever was passed on the command line for this
run and saving all of it would quietly make those permanent. The file is
read, its lock-outs replaced, the rest left as it was. `save_lockouts`
turns it off for anyone who would rather their config were never touched.

Lock-outs were also single frequencies only. They are now a list of
frequencies and spans -- "162.55M, 450M-455M, 88M to 108M" -- which is what
a pager band or a noisy stretch of spectrum actually is. A point is still
widened by the lock-out width; a span is taken exactly as written, because
whoever typed it already said how wide it is.

The scanner matched lock-outs by rounding a frequency into a bucket of the
lock-out width, which cannot express a span and was never exact at the
edges. It now holds intervals and tests them directly.

Settings files that predate this hold a bare number per lock-out, and still
mean the same thing: Lockout.coerce takes numbers, strings, pairs and dicts,
so old profiles load untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:44:24 -07:00

462 lines
18 KiB
Python

"""The settings table, and the two front ends generated from it."""
import argparse
import pytest
from bandsaunter import settings as st
from bandsaunter.cli import build_parser
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import Lockout
# ---------------------------------------------------------------------------
# Parity: every setting reachable from both the command line and the menu.
# ---------------------------------------------------------------------------
def test_every_config_field_has_a_setting():
"""A field with no entry would be invisible in the in-app menu."""
missing, extra = st.coverage()
assert not missing, f"config fields with no setting: {missing}"
assert not extra, f"settings with no config field: {extra}"
def test_every_setting_has_a_command_line_flag():
"""Anything settable in the app must also be settable as an argument."""
without = [s.key for s in st.SETTINGS if not s.flags and not s.off_flags]
assert not without, f"settings with no flag: {without}"
def test_every_flag_reaches_the_parser():
parser = build_parser()
args = parser.parse_args(["scan"])
for s in st.SETTINGS:
assert hasattr(args, s.dest), f"{s.key} never reached argparse"
assert getattr(args, s.dest) is None, \
f"{s.key} defaults to a value, so it would override saved settings"
def test_flags_are_unique():
seen = {}
for s in st.SETTINGS:
for flag in s.flags + s.off_flags:
assert flag not in seen, f"{flag} used by {seen.get(flag)} and {s.key}"
seen[flag] = s.key
def test_every_setting_is_documented():
for s in st.SETTINGS:
assert s.help, s.key
assert not s.help.endswith("."), f"{s.key} help should not end in a full stop"
assert s.label, s.key
assert s.group in st.GROUPS, s.key
if s.kind in ("choice", "accept_list"):
assert s.choices, f"{s.key} is a choice with no choices"
# ---------------------------------------------------------------------------
# Values
# ---------------------------------------------------------------------------
def test_defaults_round_trip_through_parse_and_format():
"""Whatever the menu shows must be something the menu accepts back."""
cfg = ScanConfig()
for s in st.SETTINGS:
value = getattr(cfg, s.key)
shown = st.format_value(s, value)
if s.key in ("record_seconds", "max_record_seconds",
"max_runtime_seconds", "max_cycles") and not value:
continue # shown as "no limit", typed back as 0
if s.kind == "lockout_list" and not value:
continue # shown as "(none)"
back = st.parse_value(s, shown)
assert back == value, f"{s.key}: {value!r} -> {shown!r} -> {back!r}"
@pytest.mark.parametrize("key,text,expected", [
("hang_seconds", "5", 5.0),
("record_seconds", "0", 0.0),
("gain", "auto", "auto"),
("gain", "28.0", 28.0),
("direct_sampling", "auto", "auto"),
("direct_sampling", "2", 2),
("detector", "PEAK", "peak"),
("save_iq", "yes", True),
("save_iq", "n", False),
("accept", "voice, cw", ["voice", "cw"]),
("accept", "voice cw digital", ["voice", "cw", "digital"]),
("lockout", "162.55M, 146.52M", [Lockout(162_550_000.0),
Lockout(146_520_000.0)]),
("lockout", "162.55M, 450M-455M", [Lockout(162_550_000.0),
Lockout(450_000_000.0, 455_000_000.0)]),
("detector_bias_db", "", None),
("detector_bias_db", "6", 6.0),
("sample_rate", "2048000", 2_048_000),
("threshold_db", "12", 12.0),
])
def test_parse(key, text, expected):
assert st.parse_value(st.by_key(key), text) == expected
@pytest.mark.parametrize("key,text", [
("hang_seconds", "-1"), # below the minimum
("hang_seconds", "banana"),
("detector", "sideways"),
("accept", "voice,banana"),
("min_voice_score", "1.5"), # above the maximum
("usable_fraction", "0.01"),
("save_iq", "maybe"),
("lockout", "not-a-frequency"),
("output_dir", ""),
])
def test_bad_values_are_refused_with_a_reason(key, text):
with pytest.raises(st.SettingError) as exc:
st.parse_value(st.by_key(key), text)
assert str(exc.value), "an error with no explanation is no use"
def test_search_finds_settings_by_words_from_anywhere():
assert [s.key for s in st.search("voice score")] == ["min_voice_score"]
assert st.search("hang")[0].key == "hang_seconds"
assert st.search("--hang")[0].key == "hang_seconds"
assert st.search("")== []
assert st.search("zzzznothing") == []
def test_name_matches_rank_above_description_matches():
hits = [s.key for s in st.search("record")]
assert hits.index("record_seconds") < hits.index("audio_rate")
# ---------------------------------------------------------------------------
# Applying the command line
# ---------------------------------------------------------------------------
def test_apply_args_sets_only_what_was_given():
parser = build_parser()
args = parser.parse_args(["scan", "--hang", "6", "--record", "0"])
cfg = ScanConfig()
changed = st.apply_args(cfg, args)
assert cfg.hang_seconds == 6.0
assert cfg.record_seconds == 0.0
assert set(changed) == {"hang_seconds", "record_seconds"}
assert cfg.threshold_db == ScanConfig().threshold_db
def test_boolean_flags_work_both_ways():
parser = build_parser()
cfg = ScanConfig()
st.apply_args(cfg, parser.parse_args(["scan", "--no-audio", "--iq"]))
assert cfg.save_audio is False and cfg.save_iq is True
cfg = ScanConfig()
st.apply_args(cfg, parser.parse_args(["scan", "--keep-everything"]))
assert cfg.require_signal is False
def test_repeated_lockout_flags_accumulate():
parser = build_parser()
args = parser.parse_args(["scan", "--lockout", "162.55M",
"--lockout", "146.52M"])
cfg = ScanConfig()
st.apply_args(cfg, args)
assert cfg.lockout == [Lockout(162_550_000.0), Lockout(146_520_000.0)]
def test_help_text_renders_for_every_entry():
"""--help must format without an error from any entry in the table."""
top = build_parser().format_help()
assert "scan" in top and "config" in top
scan = build_parser().parse_known_args(["scan"])
assert scan is not None
# The scan sub-parser has to render too, since that is where the table goes.
sub = [a for a in build_parser()._actions
if isinstance(a, argparse._SubParsersAction)][0]
text = sub.choices["scan"].format_help()
for setting in st.SETTINGS:
for flag in setting.flags + setting.off_flags:
assert flag in text, f"{flag} missing from --help"
# ---------------------------------------------------------------------------
# The saved settings file
# ---------------------------------------------------------------------------
def test_saved_settings_round_trip(tmp_path):
from bandsaunter.config import load_default, save_default
from bandsaunter.ranges import parse_range_list
cfg = ScanConfig(ranges=parse_range_list("144M-148M, gmrs"))
cfg.hang_seconds = 6.0
cfg.record_seconds = 0.0
cfg.accept = ["voice", "cw"]
cfg.lockout = [Lockout(162_550_000.0)]
cfg.gain = 28.0
save_default(cfg, tmp_path)
back, path = load_default(tmp_path)
assert path == tmp_path / "config.yaml"
assert back.hang_seconds == 6.0
assert back.record_seconds == 0.0
assert back.accept == ["voice", "cw"]
assert back.lockout == [Lockout(162_550_000.0)]
assert back.gain == 28.0
assert [r.label for r in back.ranges] == [r.label for r in cfg.ranges]
def test_every_setting_survives_a_save_and_load(tmp_path):
"""No setting may be lost or mangled by the config file."""
from bandsaunter.config import load_default, save_default
cfg = ScanConfig()
marks = {}
for s in st.SETTINGS:
current = getattr(cfg, s.key)
if s.kind == "bool":
value = not current
elif s.kind == "choice":
value = [c for c in s.choices if c != current][0]
elif s.kind == "accept_list":
value = ["cw"]
elif s.kind == "lockout_list":
value = [Lockout(146_520_000.0)]
elif s.kind in ("text", "path"):
value = "somewhere"
elif s.kind == "gain":
value = 22.9
elif s.kind == "direct":
value = 2
elif s.kind == "opt_float":
value = 7.0
elif s.kind == "int":
value = int(max(s.minimum or 1, 3))
else:
lo = s.minimum if s.minimum is not None else 1.0
hi = s.maximum if s.maximum is not None else lo + 5.0
value = min(hi, lo + 0.5)
setattr(cfg, s.key, value)
marks[s.key] = value
save_default(cfg, tmp_path)
back, _ = load_default(tmp_path)
for key, value in marks.items():
assert getattr(back, key) == value, key
def test_a_corrupt_config_file_does_not_crash(tmp_path):
from bandsaunter.config import load_default
(tmp_path / "config.yaml").write_text("this: [is: not: valid: yaml")
cfg, path = load_default(tmp_path)
assert cfg.hang_seconds == ScanConfig().hang_seconds
assert path is None
def test_unknown_keys_in_a_config_file_are_ignored(tmp_path):
"""An old file from a newer or older version must still load."""
from bandsaunter.config import load_default
(tmp_path / "config.yaml").write_text(
"hang_seconds: 9\nsomething_removed: 5\n")
cfg, _ = load_default(tmp_path)
assert cfg.hang_seconds == 9.0
# ---------------------------------------------------------------------------
# Layering: saved settings, then a profile, then flags
# ---------------------------------------------------------------------------
def test_flags_override_saved_settings(tmp_path, monkeypatch):
from bandsaunter import cli
from bandsaunter.config import save_default
saved = ScanConfig()
saved.hang_seconds = 6.0
saved.threshold_db = 12.0
save_default(saved, tmp_path)
monkeypatch.setattr("bandsaunter.cli.load_default",
lambda: __import__("bandsaunter.config", fromlist=["x"])
.load_default(tmp_path))
args = cli.build_parser().parse_args(["scan", "-b", "2m", "--hang", "1.5"])
cfg, source = cli._build_config(args)
assert cfg.hang_seconds == 1.5, "the flag must win"
assert cfg.threshold_db == 12.0, "the saved value must survive"
assert source is not None
def test_no_config_ignores_the_saved_file(tmp_path, monkeypatch):
from bandsaunter import cli
from bandsaunter.config import save_default
saved = ScanConfig()
saved.hang_seconds = 6.0
save_default(saved, tmp_path)
monkeypatch.setattr("bandsaunter.cli.load_default",
lambda: __import__("bandsaunter.config", fromlist=["x"])
.load_default(tmp_path))
args = cli.build_parser().parse_args(["scan", "-b", "2m", "--no-config"])
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 == "lockout_list":
return [Lockout(162_550_000.0), Lockout(450_000_000.0, 455_000_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}"
# ---------------------------------------------------------------------------
# Lock-outs: spans, and remembering them
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text,expected", [
("162.55M", [(162_550_000.0, 162_550_000.0)]),
("162.55M, 146.52M", [(162_550_000.0, 162_550_000.0),
(146_520_000.0, 146_520_000.0)]),
("450M-455M", [(450_000_000.0, 455_000_000.0)]),
("450-455M", [(450_000_000.0, 455_000_000.0)]), # unit carries over
("88M to 108M", [(88_000_000.0, 108_000_000.0)]),
("450M..455M", [(450_000_000.0, 455_000_000.0)]),
("162.55M, 450M-455M; 88M-108M", [(162_550_000.0, 162_550_000.0),
(450_000_000.0, 455_000_000.0),
(88_000_000.0, 108_000_000.0)]),
("455M-450M", [(450_000_000.0, 455_000_000.0)]), # written backwards
])
def test_lockouts_accept_lists_and_spans(text, expected):
got = st.parse_value(st.by_key("lockout"), text)
assert [(lk.start, lk.stop) for lk in got] == expected
def test_a_span_is_taken_as_written_and_a_point_is_widened():
point, span = st.parse_value(st.by_key("lockout"), "146.52M, 450M-455M")
assert point.interval(12_500) == (146_513_750.0, 146_526_250.0)
assert span.interval(12_500) == (450_000_000.0, 455_000_000.0)
def test_older_settings_files_keep_their_bare_numbers(tmp_path):
"""Lock-outs used to be plain frequencies, and still mean the same thing."""
import yaml
from bandsaunter.config import load_config
(tmp_path / "old.yaml").write_text(
yaml.safe_dump({"lockout": [162_550_000.0, 146_520_000.0]}))
cfg = load_config("old", tmp_path)
assert [lk.start for lk in cfg.lockout] == [162_550_000.0, 146_520_000.0]
assert not any(lk.is_span for lk in cfg.lockout)