"""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)