Sweeps any set of frequency ranges, records what it finds, and works out what kind of signal it was. - Frequency ranges entered by hand or picked from a 135-entry US band plan, including whole-band and all-CW sweeps that resolve the demodulator per segment. - Detection calibrated against the peak-hold detector's own noise statistics, so the threshold means real margin over static rather than over the floor. - A content gate: captures are kept only if they carry voice, decodable CW, or an identified digital keying scheme. Speech is recognised by a pitch track that drifts, which static cannot imitate. - Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR, NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK. - Gapless streaming capture, with the signal path fast enough to keep up in real time, so recordings play back at the right speed. - Optional one-file-per-frequency recording with spoken timestamps, and speech-to-text transcription. - Menus and command line generated from one settings table, so neither can offer something the other cannot; settings persist in ~/.config. 367 tests, run against synthetic signals, a built-in receiver simulator, and real hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.2 KiB
Python
206 lines
7.2 KiB
Python
"""The in-application menus, driven by scripted answers."""
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from bandsaunter import settings as st, tui
|
|
from bandsaunter.config import ScanConfig
|
|
|
|
|
|
@pytest.fixture
|
|
def console():
|
|
return Console(width=100, file=open("/dev/null", "w"), force_terminal=False)
|
|
|
|
|
|
def drive(monkeypatch, answers):
|
|
"""Feed the menus a fixed list of answers, then stop."""
|
|
script = list(answers)
|
|
|
|
def fake_ask(console, prompt, default=""):
|
|
if not script:
|
|
raise _Done()
|
|
return script.pop(0)
|
|
|
|
monkeypatch.setattr(tui, "_ask", fake_ask)
|
|
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
|
|
return script
|
|
|
|
|
|
class _Done(Exception):
|
|
"""Raised to break out when the script runs out."""
|
|
|
|
|
|
def run(monkeypatch, console, func, answers, *args):
|
|
drive(monkeypatch, answers)
|
|
try:
|
|
return func(console, *args)
|
|
except _Done:
|
|
return None
|
|
|
|
|
|
def test_settings_menu_edits_a_value(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
run(monkeypatch, console, tui.settings_menu, ["1", "2", "6", "", ""], cfg)
|
|
assert cfg.hang_seconds == 6.0
|
|
|
|
|
|
def test_settings_menu_finds_a_setting_by_search(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
run(monkeypatch, console, tui.settings_menu, ["voice score", "0.3", ""], cfg)
|
|
assert cfg.min_voice_score == 0.3
|
|
|
|
|
|
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
before = cfg.hang_seconds
|
|
run(monkeypatch, console, tui.settings_menu,
|
|
["1", "2", "banana", "", "", ""], cfg)
|
|
assert cfg.hang_seconds == before
|
|
|
|
|
|
def test_a_value_failing_validation_is_rolled_back(monkeypatch, console):
|
|
"""min_record longer than record would mean nothing is ever kept."""
|
|
cfg = ScanConfig()
|
|
cfg.record_seconds = 5.0
|
|
run(monkeypatch, console, tui.settings_menu,
|
|
["1", "4", "9", "", "", ""], cfg)
|
|
assert cfg.min_record_seconds != 9.0
|
|
assert not [e for e in cfg.validate() if "ranges" not in e]
|
|
|
|
|
|
def test_every_group_can_be_opened_and_left(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
for i in range(1, len(st.GROUPS) + 1):
|
|
run(monkeypatch, console, tui.settings_menu, [str(i), "", ""], cfg)
|
|
|
|
|
|
def test_every_setting_renders_its_help(console):
|
|
"""Built-in help must exist and render for every single setting."""
|
|
cfg = ScanConfig()
|
|
for s in st.SETTINGS:
|
|
tui.setting_help(console, s, cfg)
|
|
|
|
|
|
def test_reset_a_group_restores_defaults(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
cfg.hang_seconds = 99.0
|
|
cfg.record_seconds = 99.0
|
|
run(monkeypatch, console, tui.settings_menu, ["1", "d", "", ""], cfg)
|
|
assert cfg.hang_seconds == ScanConfig().hang_seconds
|
|
assert cfg.record_seconds == ScanConfig().record_seconds
|
|
|
|
|
|
def test_help_screen_shows_every_topic(monkeypatch, console):
|
|
for topic in tui._TOPICS:
|
|
run(monkeypatch, console, tui.help_screen, [topic, ""])
|
|
|
|
|
|
def test_help_screen_looks_up_settings(monkeypatch, console):
|
|
run(monkeypatch, console, tui.help_screen, ["hang", ""])
|
|
|
|
|
|
def test_ranges_can_be_added_from_the_band_plan(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
run(monkeypatch, console, tui.choose_presets, ["gmrs", "1", ""], cfg)
|
|
assert cfg.ranges, "no range was added"
|
|
|
|
|
|
def test_ranges_can_be_typed_in(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
run(monkeypatch, console, tui.add_manual_ranges,
|
|
["144M", "148M", "nfm", ""], cfg)
|
|
assert len(cfg.ranges) == 1
|
|
assert cfg.ranges[0].start == 144e6 and cfg.ranges[0].stop == 148e6
|
|
assert cfg.ranges[0].mode == "nfm"
|
|
|
|
|
|
def test_a_bad_frequency_is_refused(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
run(monkeypatch, console, tui.add_manual_ranges, ["banana", ""], cfg)
|
|
assert not cfg.ranges
|
|
|
|
|
|
def test_main_menu_can_start_a_scan(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
from bandsaunter.ranges import parse_range_list
|
|
cfg.ranges = parse_range_list("144M-148M")
|
|
out = run(monkeypatch, console, tui.run_tui, ["s"], cfg)
|
|
assert out is cfg
|
|
|
|
|
|
def test_main_menu_refuses_to_start_without_ranges(monkeypatch, console):
|
|
cfg = ScanConfig()
|
|
out = run(monkeypatch, console, tui.run_tui, ["s", "q"], cfg)
|
|
assert out is None, "started a scan with nothing to scan"
|
|
|
|
|
|
def test_main_menu_quits(monkeypatch, console):
|
|
assert run(monkeypatch, console, tui.run_tui, ["q"], ScanConfig()) is None
|
|
|
|
|
|
def test_closed_input_unwinds_instead_of_looping(monkeypatch, console):
|
|
"""With no input left, the menus must give up rather than spin forever."""
|
|
def eof(*a, **k):
|
|
raise EOFError
|
|
monkeypatch.setattr(tui.Prompt, "ask", eof)
|
|
assert tui.run_tui(console, ScanConfig()) is None
|
|
with pytest.raises(tui.TUIAbort):
|
|
tui.settings_menu(console, ScanConfig())
|
|
|
|
|
|
def test_interrupt_unwinds_too(monkeypatch, console):
|
|
def interrupt(*a, **k):
|
|
raise KeyboardInterrupt
|
|
monkeypatch.setattr(tui.Prompt, "ask", interrupt)
|
|
assert tui.run_tui(console, ScanConfig()) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# First run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_first_run_is_detected_and_then_not(tmp_path):
|
|
from bandsaunter.config import ScanConfig, is_first_run, save_default
|
|
assert is_first_run(tmp_path)
|
|
save_default(ScanConfig(), tmp_path)
|
|
assert not is_first_run(tmp_path)
|
|
|
|
|
|
def test_first_run_asks_where_to_save_and_remembers(monkeypatch, console,
|
|
tmp_path):
|
|
from bandsaunter import tui
|
|
from bandsaunter.config import load_default
|
|
wanted = tmp_path / "my recordings"
|
|
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": str(wanted))
|
|
monkeypatch.setattr(tui, "save_default",
|
|
lambda cfg: __import__("bandsaunter.config",
|
|
fromlist=["x"]).save_default(
|
|
cfg, tmp_path))
|
|
cfg = ScanConfig()
|
|
assert tui.first_run_setup(console, cfg)
|
|
assert cfg.output_dir == str(wanted)
|
|
assert wanted.is_dir(), "the directory should be created"
|
|
back, _ = load_default(tmp_path)
|
|
assert back.output_dir == str(wanted)
|
|
|
|
|
|
def test_first_run_accepts_the_suggested_default(monkeypatch, console,
|
|
tmp_path):
|
|
from bandsaunter import tui
|
|
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": "")
|
|
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
|
|
monkeypatch.setattr(tui, "DEFAULT_OUTPUT_DIR", str(tmp_path / "default"))
|
|
cfg = ScanConfig()
|
|
tui.first_run_setup(console, cfg)
|
|
assert cfg.output_dir == str(tmp_path / "default")
|
|
|
|
|
|
def test_first_run_rejects_a_directory_it_cannot_write(monkeypatch, console,
|
|
tmp_path):
|
|
"""Better to say so now than to fail on the first recording."""
|
|
from bandsaunter import tui
|
|
answers = iter(["/proc/nonsense/cannot-create", str(tmp_path / "ok")])
|
|
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": next(answers))
|
|
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
|
|
cfg = ScanConfig()
|
|
tui.first_run_setup(console, cfg)
|
|
assert cfg.output_dir == str(tmp_path / "ok")
|