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>
417 lines
16 KiB
Python
417 lines
16 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 == "freq_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", [162_550_000.0, 146_520_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 == [162_550_000.0, 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 = [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 == [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 == "freq_list":
|
|
value = [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 == "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}"
|