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>
462 lines
18 KiB
Python
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)
|