The listing gave the time and not the date, which is fine for an evening's scanning and useless for a directory that has been filling for a fortnight: two recordings a week apart looked like neighbours. Every moment in the browser is now written YY-mm-dd hh:mm:ss am/pm -- date first so a column of them reads down in order, twelve-hour so it reads the way it would be said. Midnight is 12 am and noon is 12 pm, which is the pair a twelve-hour clock gets wrong when it is done by subtraction, so both are tested. The sort is named "date/time" rather than "time" and orders by the whole moment, with the filename breaking a tie so that the list does not shuffle itself between one reload and the next. --sort time still works; it is the sort of thing that lives in a shell alias. The date costs twelve columns, so the frequency column is now only as wide as the widest frequency in the list rather than always wide enough for 1090.000001 MHz. It is fixed for the whole list, not the screenful, because a column that resizes as the list scrolls under it makes the whole listing appear to twitch. --list is left in ISO and twenty-four hours: it is there to be piped into grep and sort, and those want the format that sorts as text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1026 lines
36 KiB
Python
1026 lines
36 KiB
Python
"""saunterbrowse: reading and listening to what a scan collected.
|
|
|
|
The browser never writes to the recordings directory, so every test here is
|
|
about what it *shows* and what it does with a keypress. Playback is checked
|
|
against a recorded command rather than a real one: whether audio came out of
|
|
the speakers is not something a test can see, but which file was handed to
|
|
which player is.
|
|
"""
|
|
import json
|
|
import re
|
|
import time
|
|
import wave
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from bandsaunter.browse import (Browser, Player, PLAYERS, Keyboard, main,
|
|
scan_directory)
|
|
from bandsaunter.callsign import CallsignBook
|
|
|
|
|
|
# -- fixtures ----------------------------------------------------------------
|
|
|
|
def write_wav(path: Path, seconds: float = 1.0, rate: int = 16_000) -> None:
|
|
n = int(seconds * rate)
|
|
tone = (np.sin(2 * np.pi * 440 * np.arange(n) / rate) * 8000)
|
|
with wave.open(str(path), "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(rate)
|
|
w.writeframes(tone.astype(np.int16).tobytes())
|
|
|
|
|
|
def make_capture(directory: Path, mhz: float, when: str, mode: str,
|
|
seconds: float = 1.0, transcript: str = "",
|
|
meta: dict | None = None) -> Path:
|
|
"""One recording and its sidecars, named the way a scan names them."""
|
|
stem = f"{mhz:011.6f}MHz--{when}-{mode}"
|
|
wav = directory / f"{stem}.wav"
|
|
write_wav(wav, seconds)
|
|
if meta is not None:
|
|
body = {"frequency_hz": mhz * 1e6, "hit": meta}
|
|
(directory / f"{stem}.json").write_text(json.dumps(body))
|
|
if transcript:
|
|
(directory / f"{stem}_transcription.txt").write_text(transcript + "\n")
|
|
return wav
|
|
|
|
|
|
@pytest.fixture
|
|
def library(tmp_path):
|
|
"""A small recordings directory covering the cases that render differently."""
|
|
# The transcript names a callsign, as a real one from a net would.
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 4.0,
|
|
transcript="Net control, this is W1AW, standing by.",
|
|
meta={"category": "voice", "snr_db": 21.5,
|
|
"classification": "Narrowband FM voice",
|
|
"confidence": 0.88, "ctcss_hz": 100.0,
|
|
"band_labels": ["2 m Amateur"]})
|
|
make_capture(tmp_path, 856.5625, "2026-08-22_11_00_00", "fsk", 2.0,
|
|
meta={"category": "trunk", "snr_db": 38.0, "baud": 3600.0,
|
|
"classification": "Motorola SMARTNET / SmartZone "
|
|
"(Type I/II) trunking control channel",
|
|
"confidence": 0.95})
|
|
make_capture(tmp_path, 144.1, "2026-08-22_09_00_00", "cw", 3.0,
|
|
meta={"category": "cw", "morse_text": "VVV DE W1AW",
|
|
"morse_wpm": 18.0, "classification": "CW / Morse"})
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def no_network(monkeypatch):
|
|
"""No test in this file may contact the licence database.
|
|
|
|
One did, silently, and passed -- it was only visible because the assertion
|
|
it failed printed a real operator's address. A stub that is forgotten
|
|
should fail loudly rather than work.
|
|
"""
|
|
def refuse(self, call):
|
|
raise AssertionError(f"a test tried to look up {call} for real")
|
|
|
|
monkeypatch.setattr(CallsignBook, "_request", refuse)
|
|
|
|
|
|
def browser(directory, width=100, height=30, player=None) -> Browser:
|
|
console = Console(width=width, height=height, force_terminal=True)
|
|
return Browser(directory, console=console,
|
|
player=player if player is not None else Player([]))
|
|
|
|
|
|
def frame(b: Browser) -> str:
|
|
with b.console.capture() as cap:
|
|
b.console.print(b.render())
|
|
return cap.get()
|
|
|
|
|
|
class FakePlayer(Player):
|
|
"""Records what it was asked to play instead of playing it."""
|
|
|
|
def __init__(self):
|
|
super().__init__(["/bin/true"])
|
|
self.played: list[Path] = []
|
|
self.stops = 0
|
|
self._active = False
|
|
|
|
def play(self, cap):
|
|
self.played.append(cap.path)
|
|
self.playing = cap
|
|
self._active = True
|
|
self.error = ""
|
|
|
|
def stop(self):
|
|
if self._active:
|
|
self.stops += 1
|
|
self._active = False
|
|
self.playing = None
|
|
|
|
@property
|
|
def active(self):
|
|
return self._active
|
|
|
|
|
|
# -- reading the directory ---------------------------------------------------
|
|
|
|
def test_every_recording_is_found(library):
|
|
assert len(scan_directory(library)) == 3
|
|
|
|
|
|
def test_the_newest_recording_comes_first(library):
|
|
caps = scan_directory(library)
|
|
assert [c.mode for c in caps] == ["fsk", "nfm", "cw"]
|
|
|
|
|
|
def test_the_filename_alone_gives_frequency_time_and_mode(library):
|
|
cap = [c for c in scan_directory(library) if c.mode == "nfm"][0]
|
|
assert cap.frequency == pytest.approx(146_520_000.0)
|
|
assert cap.when == datetime(2026, 8, 22, 10, 0, 0)
|
|
|
|
|
|
def test_length_comes_from_the_wav_not_the_sidecar(tmp_path):
|
|
"""A combined file grows after its sidecar was written, and a sidecar can
|
|
be missing entirely."""
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 2.5,
|
|
meta={"duration": 999.0})
|
|
cap = scan_directory(tmp_path)[0]
|
|
assert cap.duration == pytest.approx(2.5, abs=0.05)
|
|
|
|
|
|
def test_a_recording_with_no_sidecar_is_still_listed(tmp_path):
|
|
write_wav(tmp_path / "0146.520000MHz--2026-08-22_10_00_00-nfm.wav")
|
|
cap = scan_directory(tmp_path)[0]
|
|
assert cap.category == "unknown"
|
|
assert cap.classification == "unclassified"
|
|
|
|
|
|
def test_a_file_the_browser_cannot_name_is_shown_rather_than_hidden(tmp_path):
|
|
write_wav(tmp_path / "something-else.wav")
|
|
caps = scan_directory(tmp_path)
|
|
assert len(caps) == 1 and caps[0].frequency == 0.0
|
|
|
|
|
|
def test_a_combined_per_frequency_file_is_recognised(tmp_path):
|
|
write_wav(tmp_path / "0146.880000MHz.wav")
|
|
cap = scan_directory(tmp_path)[0]
|
|
assert cap.combined
|
|
assert cap.frequency == pytest.approx(146_880_000.0)
|
|
assert cap.category == "combined"
|
|
|
|
|
|
def test_sidecars_are_only_read_when_something_asks(library):
|
|
"""A directory of ten thousand recordings has to open instantly."""
|
|
caps = scan_directory(library)
|
|
assert all(c._meta is None for c in caps)
|
|
_ = caps[0].category
|
|
assert caps[0]._meta is not None
|
|
|
|
|
|
def test_a_missing_directory_is_not_an_exception(tmp_path):
|
|
assert scan_directory(tmp_path / "nowhere") == []
|
|
|
|
|
|
# -- the transcript ----------------------------------------------------------
|
|
|
|
def test_the_transcript_is_at_the_top_of_the_screen(library):
|
|
b = browser(library)
|
|
b.index = [i for i, c in enumerate(b.view) if c.mode == "nfm"][0]
|
|
out = frame(b)
|
|
head = out.split("recordings in")[0]
|
|
assert "Net control, this is W1AW" in head
|
|
assert "transcript" in head
|
|
|
|
|
|
def test_the_txt_beside_the_recording_beats_the_sidecar(tmp_path):
|
|
"""The sidecar records what was recognised at the time; the file is what a
|
|
later re-run wrote."""
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript="the newer text",
|
|
meta={"transcript": "the older text", "category": "voice"})
|
|
assert scan_directory(tmp_path)[0].transcript == "the newer text"
|
|
|
|
|
|
def test_the_sidecar_transcript_is_used_when_there_is_no_txt(tmp_path):
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
meta={"transcript": "only in the sidecar", "category": "voice"})
|
|
assert scan_directory(tmp_path)[0].transcript == "only in the sidecar"
|
|
|
|
|
|
@pytest.mark.parametrize("meta,expected", [
|
|
({"category": "digital"}, "data, not speech"),
|
|
({"category": "trunk"}, "data, not speech"),
|
|
({"category": "carrier"}, "silence by definition"),
|
|
({"category": "cw"}, "Morse"),
|
|
({"category": "voice"}, "no transcript beside it"),
|
|
])
|
|
def test_an_empty_transcript_says_which_reason_applies(tmp_path, meta,
|
|
expected):
|
|
""""No transcript" is the same shape for Morse, for data and for a
|
|
recogniser that was never installed, and those want different things
|
|
done about them."""
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", meta=meta)
|
|
b = browser(tmp_path)
|
|
assert expected in frame(b)
|
|
|
|
|
|
def test_decoded_morse_is_shown_where_the_transcript_would_be(library):
|
|
b = browser(library)
|
|
b.index = [i for i, c in enumerate(b.view) if c.mode == "cw"][0]
|
|
assert "VVV DE W1AW" in frame(b)
|
|
|
|
|
|
def test_a_transcript_too_long_for_the_panel_says_so(tmp_path):
|
|
"""Cutting the end off a transmission silently is how a reader ends up
|
|
believing they have read all of it."""
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript=" ".join(f"word{i}" for i in range(400)),
|
|
meta={"category": "voice"})
|
|
out = frame(browser(tmp_path, height=24))
|
|
assert "more line(s)" in out and "press t" in out
|
|
|
|
|
|
def test_the_reader_shows_what_the_panel_could_not(tmp_path):
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript=" ".join(f"word{i}" for i in range(400)),
|
|
meta={"category": "voice"})
|
|
b = browser(tmp_path, height=24)
|
|
panel_text = frame(b)
|
|
b.handle("t")
|
|
assert b.reading
|
|
reader = frame(b)
|
|
assert "word0" in reader
|
|
assert "word200" in reader and "word200" not in panel_text
|
|
|
|
|
|
def test_the_reader_scrolls_and_comes_back(tmp_path):
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript=" ".join(f"word{i}" for i in range(400)),
|
|
meta={"category": "voice"})
|
|
b = browser(tmp_path, height=20)
|
|
b.handle("t")
|
|
first = frame(b)
|
|
b.handle("pgdn")
|
|
assert frame(b) != first
|
|
b.handle("home")
|
|
assert frame(b) == first
|
|
b.handle("escape")
|
|
assert not b.reading
|
|
|
|
|
|
def test_the_reader_refuses_when_there_is_nothing_to_read(library):
|
|
b = browser(library)
|
|
b.index = [i for i, c in enumerate(b.view) if c.mode == "fsk"][0]
|
|
b.handle("t")
|
|
assert not b.reading
|
|
assert "nothing to read" in b.message
|
|
|
|
|
|
def test_the_reader_opens_on_a_decoded_capture_too(tmp_path):
|
|
"""The decoded panel says "press t to read it all", so t has to work."""
|
|
make_capture(tmp_path, 929.6125, "2026-08-22_11_00_00", "fsk", 4.0,
|
|
meta={"category": "digital", "data_protocol": "POCSAG 1200",
|
|
"data_messages": ["[1234568D] ENGINE 4 RESPOND"]})
|
|
b = browser(tmp_path)
|
|
assert not b.current.transcript and b.current.decoded
|
|
b.handle("t")
|
|
assert b.reading, b.message
|
|
assert "ENGINE 4 RESPOND" in frame(b)
|
|
|
|
|
|
# -- the frame ---------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("width,height",
|
|
[(60, 16), (80, 24), (100, 30), (200, 60)])
|
|
def test_the_frame_never_overflows_the_terminal(library, width, height):
|
|
"""A frame taller than the terminal cannot be redrawn in place: every
|
|
refresh scrolls another copy of it into the scrollback."""
|
|
b = browser(library, width=width, height=height)
|
|
lines = frame(b).rstrip("\n").split("\n")
|
|
assert len(lines) <= height, f"{len(lines)} lines in {height}"
|
|
bare = [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines]
|
|
assert max(len(line) for line in bare) <= width, "a line ran off the side"
|
|
|
|
|
|
def test_one_row_per_recording_however_long_the_transcript(tmp_path):
|
|
"""A wrapped row would push the ones below it off the bottom, and the
|
|
cursor arithmetic counts rows."""
|
|
for i in range(6):
|
|
make_capture(tmp_path, 146.0 + i, f"2026-08-22_10_0{i}_00", "nfm",
|
|
transcript=" ".join(f"word{n}" for n in range(200)),
|
|
meta={"category": "voice"})
|
|
b = browser(tmp_path, height=30)
|
|
body = frame(b).split("recordings in")[1]
|
|
assert body.count("word0") == 6, "a row wrapped instead of truncating"
|
|
|
|
|
|
def test_the_header_names_the_frequency_mode_and_category(library):
|
|
b = browser(library)
|
|
b.index = [i for i, c in enumerate(b.view) if c.mode == "nfm"][0]
|
|
head = frame(b).split("transcript")[0]
|
|
assert "146.52 MHz" in head and "NFM" in head and "voice" in head
|
|
|
|
|
|
def test_a_symbol_rate_is_only_shown_where_it_means_something(library):
|
|
"""The estimator returns a figure for every capture, and "120 baud"
|
|
beside a conversation is noise."""
|
|
b = browser(library)
|
|
b.index = [i for i, c in enumerate(b.view) if c.mode == "fsk"][0]
|
|
assert "3600 baud" in frame(b)
|
|
b.index = [i for i, c in enumerate(b.view) if c.mode == "nfm"][0]
|
|
assert "baud" not in frame(b)
|
|
|
|
|
|
def test_an_empty_directory_renders_rather_than_crashing(tmp_path):
|
|
b = browser(tmp_path)
|
|
assert "nothing here yet" in frame(b)
|
|
|
|
|
|
# -- moving around -----------------------------------------------------------
|
|
|
|
def test_the_arrow_keys_move_the_cursor(library):
|
|
b = browser(library)
|
|
assert b.index == 0
|
|
b.handle("down")
|
|
assert b.index == 1
|
|
b.handle("up")
|
|
assert b.index == 0
|
|
|
|
|
|
def test_the_cursor_stops_at_both_ends(library):
|
|
b = browser(library)
|
|
for _ in range(20):
|
|
b.handle("up")
|
|
assert b.index == 0
|
|
for _ in range(20):
|
|
b.handle("down")
|
|
assert b.index == len(b.view) - 1
|
|
|
|
|
|
def test_home_and_end_go_to_the_ends(library):
|
|
b = browser(library)
|
|
b.handle("end")
|
|
assert b.index == len(b.view) - 1
|
|
b.handle("home")
|
|
assert b.index == 0
|
|
|
|
|
|
def test_the_cursor_stays_on_screen_in_a_long_list(tmp_path):
|
|
for i in range(60):
|
|
make_capture(tmp_path, 146.0, f"2026-08-22_10_{i // 60:02d}_{i % 60:02d}",
|
|
"nfm", meta={"category": "voice"})
|
|
b = browser(tmp_path, height=24)
|
|
b.handle("end")
|
|
frame(b)
|
|
assert b.top <= b.index < b.top + b._rows()
|
|
|
|
|
|
def test_sorting_cycles_and_reorders(library):
|
|
b = browser(library)
|
|
assert b.sort == "date/time"
|
|
b.handle("s")
|
|
assert b.sort == "frequency"
|
|
assert [c.frequency for c in b.view] == sorted(c.frequency
|
|
for c in b.view)
|
|
b.handle("s")
|
|
assert b.sort == "duration"
|
|
assert b.view[0].duration >= b.view[-1].duration
|
|
|
|
|
|
def test_the_listing_gives_the_date_as_well_as_the_time(library):
|
|
"""Without the date, two recordings a week apart look like neighbours."""
|
|
text = frame(browser(library))
|
|
assert re.search(r"\d\d-\d\d-\d\d \d\d:\d\d:\d\d [ap]m", text)
|
|
|
|
|
|
def test_the_clock_is_twelve_hour_with_midnight_and_noon_named(tmp_path):
|
|
"""The two hours a twelve-hour clock gets wrong if it is done by
|
|
subtraction: midnight is 12 am and noon is 12 pm, not 0 am and 0 pm."""
|
|
make_capture(tmp_path, 146.52, "2026-08-30_00_00_30", "nfm")
|
|
make_capture(tmp_path, 146.52, "2026-08-30_12_00_30", "nfm")
|
|
make_capture(tmp_path, 146.52, "2026-08-30_13_05_00", "nfm")
|
|
text = frame(browser(tmp_path))
|
|
assert "26-08-30 12:00:30 am" in text
|
|
assert "26-08-30 12:00:30 pm" in text
|
|
assert "26-08-30 01:05:00 pm" in text
|
|
|
|
|
|
def test_sorting_by_date_time_is_chronological_across_every_boundary(tmp_path):
|
|
"""Year, month, day, then hour, minute, second. The order below is the
|
|
one a clock would put them in; the browser has to agree with it whichever
|
|
way the filenames happen to sort as text."""
|
|
moments = ["2025-12-31_23_59_59", "2026-01-01_00_00_01",
|
|
"2026-01-01_00_00_02", "2026-01-31_09_00_00",
|
|
"2026-02-01_08_00_00", "2026-08-30_11_59_59",
|
|
"2026-08-30_12_00_00", "2026-08-30_13_00_00"]
|
|
for i, when in enumerate(moments):
|
|
make_capture(tmp_path, 146.52 + i * 0.01, when, "nfm")
|
|
b = browser(tmp_path)
|
|
assert [c.when.strftime("%Y-%m-%d_%H_%M_%S") for c in b.view] == \
|
|
list(reversed(moments))
|
|
|
|
|
|
def test_two_signals_in_the_same_second_keep_a_settled_order(tmp_path):
|
|
"""A tie is broken by the filename so that the list does not shuffle
|
|
itself between one reload and the next."""
|
|
make_capture(tmp_path, 146.52, "2026-08-30_10_00_00", "nfm")
|
|
make_capture(tmp_path, 145.00, "2026-08-30_10_00_00", "nfm")
|
|
first = [c.path.name for c in browser(tmp_path).view]
|
|
assert first == sorted(first)
|
|
|
|
|
|
def test_the_sort_key_is_called_date_time(library):
|
|
b = browser(library, width=140)
|
|
assert b.sort == "date/time"
|
|
assert "date/time" in frame(b)
|
|
|
|
|
|
def test_the_old_name_for_the_time_sort_still_works(library, monkeypatch):
|
|
"""--sort time is the sort of thing that lives in a shell alias."""
|
|
from bandsaunter import browse
|
|
|
|
seen = {}
|
|
|
|
def fake_loop(self):
|
|
seen["sort"] = self.sort
|
|
return 0
|
|
|
|
monkeypatch.setattr(browse.Browser, "run", fake_loop, raising=False)
|
|
monkeypatch.setattr(browse.Console, "is_terminal", property(lambda s: True))
|
|
assert main(["--sort", "time", str(library)]) == 0
|
|
assert seen["sort"] == "date/time"
|
|
|
|
|
|
def test_the_frequency_column_does_not_change_width_while_scrolling(tmp_path):
|
|
"""One wide frequency in the list fixes the column for the whole list, so
|
|
that scrolling past it does not make everything else jump sideways."""
|
|
make_capture(tmp_path, 1090.000001, "2026-08-30_10_00_00", "nfm")
|
|
for i in range(40):
|
|
make_capture(tmp_path, 146.52, f"2026-08-30_11_{i // 60:02d}_{i % 60:02d}",
|
|
"nfm")
|
|
b = browser(tmp_path, height=24)
|
|
frame(b)
|
|
was = b.freq_width
|
|
b.handle("end")
|
|
frame(b)
|
|
assert b.freq_width == was
|
|
|
|
|
|
def test_quitting_stops_the_loop(library):
|
|
assert browser(library).handle("q") is False
|
|
|
|
|
|
# -- searching ---------------------------------------------------------------
|
|
|
|
def test_search_matches_what_was_said(library):
|
|
"""The point of it: "did anyone mention the repeater" is a question about
|
|
content, not about filenames."""
|
|
b = browser(library)
|
|
b.handle("/")
|
|
for ch in "standing by":
|
|
b.handle(ch)
|
|
b.handle("enter")
|
|
assert len(b.view) == 1 and b.view[0].mode == "nfm"
|
|
|
|
|
|
def test_search_matches_the_identification(library):
|
|
b = browser(library)
|
|
b.query = "smartnet"
|
|
b.apply()
|
|
assert len(b.view) == 1 and b.view[0].mode == "fsk"
|
|
|
|
|
|
def test_search_matches_the_frequency_in_the_name(library):
|
|
b = browser(library)
|
|
b.query = "0856"
|
|
b.apply()
|
|
assert len(b.view) == 1
|
|
|
|
|
|
def test_backspace_edits_the_search(library):
|
|
b = browser(library)
|
|
b.handle("/")
|
|
for ch in "smartnetX":
|
|
b.handle(ch)
|
|
assert b.view == []
|
|
b.handle("backspace")
|
|
assert len(b.view) == 1
|
|
|
|
|
|
def test_escape_clears_the_search_before_it_quits(library):
|
|
"""Escape with a filter in force means "show me everything again", not
|
|
"throw the program away"."""
|
|
b = browser(library)
|
|
b.query = "smartnet"
|
|
b.apply()
|
|
assert b.handle("escape") is True
|
|
assert b.query == "" and len(b.view) == 3
|
|
assert b.handle("escape") is False
|
|
|
|
|
|
def test_a_search_that_matches_nothing_says_so(library):
|
|
b = browser(library)
|
|
b.query = "no such thing"
|
|
b.apply()
|
|
assert "nothing matches" in frame(b)
|
|
|
|
|
|
def test_keys_typed_while_searching_are_not_commands(library):
|
|
"""'q' has to be a letter in the search box, not an instruction to quit."""
|
|
b = browser(library)
|
|
b.handle("/")
|
|
assert b.handle("q") is True
|
|
assert b.query == "q"
|
|
|
|
|
|
# -- playing -----------------------------------------------------------------
|
|
|
|
def test_enter_plays_the_highlighted_recording(library):
|
|
p = FakePlayer()
|
|
b = browser(library, player=p)
|
|
b.handle("down")
|
|
b.handle("enter")
|
|
assert p.played == [b.current.path]
|
|
|
|
|
|
def test_space_stops_what_is_playing(library):
|
|
p = FakePlayer()
|
|
b = browser(library, player=p)
|
|
b.handle("enter")
|
|
b.handle("space")
|
|
assert p.stops == 1 and not p.active
|
|
|
|
|
|
def test_playing_a_second_recording_stops_the_first(tmp_path):
|
|
"""Two players talking over each other is worse than either alone."""
|
|
calls = []
|
|
|
|
class Recorder(Player):
|
|
def play(self, cap):
|
|
calls.append(("play", cap.path.name))
|
|
self.playing = cap
|
|
|
|
def stop(self):
|
|
calls.append(("stop", None))
|
|
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm")
|
|
make_capture(tmp_path, 147.52, "2026-08-22_10_01_00", "nfm")
|
|
b = browser(tmp_path, player=Recorder([]))
|
|
b.handle("enter")
|
|
b.handle("down")
|
|
b.handle("enter")
|
|
assert [c[0] for c in calls] == ["play", "play"]
|
|
|
|
|
|
def test_the_real_player_stops_the_previous_file_first(library, tmp_path):
|
|
"""The stop is in Player.play, so every caller gets it."""
|
|
p = Player(["/bin/sleep", "5"])
|
|
caps = scan_directory(library)
|
|
p.play(caps[0])
|
|
first = p.proc
|
|
assert first is not None
|
|
p.play(caps[1])
|
|
assert first.poll() is not None, "the first player was left running"
|
|
p.stop()
|
|
|
|
|
|
def test_stopping_kills_the_player_and_anything_it_started(library, tmp_path):
|
|
"""Several of these players are wrapper scripts that fork the real one.
|
|
Signalling only the script leaves the sound playing with nothing on
|
|
screen to stop it."""
|
|
import subprocess as sp
|
|
marker = tmp_path / "child.pid"
|
|
script = tmp_path / "wrapper.sh"
|
|
script.write_text("#!/bin/sh\nsleep 30 &\necho $! > %s\nwait\n" % marker)
|
|
script.chmod(0o755)
|
|
|
|
p = Player([str(script)])
|
|
p.play(scan_directory(library)[0])
|
|
for _ in range(50):
|
|
if marker.exists():
|
|
break
|
|
time.sleep(0.05)
|
|
child = int(marker.read_text().strip())
|
|
assert sp.run(["kill", "-0", str(child)]).returncode == 0, "never started"
|
|
p.stop()
|
|
for _ in range(50):
|
|
if sp.run(["kill", "-0", str(child)],
|
|
capture_output=True).returncode != 0:
|
|
break
|
|
time.sleep(0.05)
|
|
assert sp.run(["kill", "-0", str(child)],
|
|
capture_output=True).returncode != 0, "still playing"
|
|
|
|
|
|
def test_no_player_installed_is_reported_not_crashed(library):
|
|
b = browser(library, player=Player([])) # [] means "none installed"
|
|
b.handle("enter")
|
|
assert "no audio player" in b.message
|
|
assert all(name in b.message for name, _ in PLAYERS)
|
|
|
|
|
|
def test_the_file_is_the_last_argument_to_the_player(library):
|
|
p = Player(["/bin/true", "--flag"])
|
|
cap = scan_directory(library)[0]
|
|
p.play(cap)
|
|
assert p.command == ["/bin/true", "--flag"]
|
|
assert p.playing is cap
|
|
p.stop()
|
|
|
|
|
|
def test_playback_progress_is_shown(library):
|
|
p = FakePlayer()
|
|
b = browser(library, player=p)
|
|
b.handle("enter")
|
|
p.started_at = 0.0 # started long ago: the bar is full
|
|
assert "♪" in frame(b)
|
|
|
|
|
|
# -- the keyboard ------------------------------------------------------------
|
|
|
|
# Driven through a real pty, not a stand-in object. A fake stream with a
|
|
# read() method passed every one of these while the program hung on the first
|
|
# keypress: sys.stdin.read(1) goes through a buffered text wrapper, which in
|
|
# cbreak mode waits for more bytes than a single key provides.
|
|
|
|
|
|
@pytest.fixture
|
|
def terminal():
|
|
"""A pty whose slave end is what Keyboard will read."""
|
|
import os
|
|
import pty
|
|
master, slave = pty.openpty()
|
|
stream = os.fdopen(slave, "r")
|
|
yield master, stream
|
|
for fd in (master,):
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
stream.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@pytest.mark.parametrize("raw,name", [
|
|
("\x1b[A", "up"), ("\x1b[B", "down"), ("\x1b[C", "right"),
|
|
("\x1b[D", "left"), ("\x1b[5~", "pgup"), ("\x1b[6~", "pgdn"),
|
|
("\x1b[H", "home"), ("\x1b[F", "end"),
|
|
("\x1bOA", "up"), ("\x1bOB", "down"),
|
|
("\r", "enter"), ("\n", "enter"), (" ", "space"),
|
|
("\x7f", "backspace"), ("q", "q"), ("/", "/"), ("t", "t"),
|
|
])
|
|
def test_a_key_arrives_as_one_key(terminal, raw, name):
|
|
"""An arrow is "\\x1b[A" -- three bytes. Read one at a time it becomes
|
|
three commands, and the cursor jumps somewhere unasked."""
|
|
import os
|
|
master, stream = terminal
|
|
with Keyboard(stream) as kb:
|
|
assert kb.enabled
|
|
os.write(master, raw.encode())
|
|
assert kb.get(0.5) == name
|
|
assert kb.get(0.05) == "", "one keypress produced more than one key"
|
|
|
|
|
|
def test_a_bare_escape_is_not_mistaken_for_an_arrow(terminal):
|
|
import os
|
|
master, stream = terminal
|
|
with Keyboard(stream) as kb:
|
|
os.write(master, b"\x1b")
|
|
assert kb.get(0.5) == "escape"
|
|
|
|
|
|
def test_keys_arriving_together_are_delivered_one_at_a_time(terminal):
|
|
"""Held down, or pasted, several keys land in a single read."""
|
|
import os
|
|
master, stream = terminal
|
|
with Keyboard(stream) as kb:
|
|
os.write(master, b"\x1b[B\x1b[Bq")
|
|
assert [kb.get(0.5) for _ in range(3)] == ["down", "down", "q"]
|
|
|
|
|
|
def test_nothing_pressed_returns_nothing(terminal):
|
|
_, stream = terminal
|
|
with Keyboard(stream) as kb:
|
|
assert kb.get(0.05) == ""
|
|
|
|
|
|
def test_the_terminal_is_restored_afterwards(terminal):
|
|
"""Left in cbreak, the shell that follows has no line editing and no echo."""
|
|
import termios
|
|
_, stream = terminal
|
|
before = termios.tcgetattr(stream.fileno())
|
|
with Keyboard(stream) as kb:
|
|
assert kb.enabled
|
|
assert termios.tcgetattr(stream.fileno()) != before
|
|
assert termios.tcgetattr(stream.fileno()) == before
|
|
|
|
|
|
def test_a_stream_that_is_not_a_terminal_is_not_an_error(tmp_path):
|
|
path = tmp_path / "notatty"
|
|
path.write_text("q")
|
|
with open(path) as fh, Keyboard(fh) as kb:
|
|
assert not kb.enabled
|
|
assert kb.get(0.0) == ""
|
|
|
|
|
|
# -- the command line --------------------------------------------------------
|
|
|
|
def test_list_prints_one_line_per_recording(library, capsys):
|
|
assert main(["--list", str(library)]) == 0
|
|
lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.strip()]
|
|
assert len(lines) == 3
|
|
assert any("Net control" in ln for ln in lines)
|
|
|
|
|
|
def test_list_honours_the_filter(library, capsys):
|
|
assert main(["--list", "--filter", "smartnet", str(library)]) == 0
|
|
lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.strip()]
|
|
assert len(lines) == 1
|
|
|
|
|
|
def test_a_missing_directory_is_explained(tmp_path, capsys):
|
|
assert main([str(tmp_path / "nowhere")]) == 2
|
|
assert "no such directory" in capsys.readouterr().out
|
|
|
|
|
|
def test_an_empty_directory_is_explained(tmp_path, capsys):
|
|
assert main([str(tmp_path)]) == 1
|
|
assert "no recordings" in capsys.readouterr().out
|
|
|
|
|
|
def test_it_refuses_to_draw_where_there_is_no_terminal(library, capsys):
|
|
"""Piped or redirected, the full-screen frame would be gibberish."""
|
|
assert main([str(library)]) == 2
|
|
out = capsys.readouterr().out
|
|
assert "needs a terminal" in out and "--list" in out
|
|
|
|
|
|
def test_the_output_directory_is_found_without_being_told(monkeypatch,
|
|
tmp_path):
|
|
monkeypatch.setenv("BANDSAUNTER_OUTPUT", str(tmp_path))
|
|
from bandsaunter.browse import default_directory
|
|
assert default_directory() == tmp_path
|
|
|
|
|
|
# -- detected callsigns ------------------------------------------------------
|
|
|
|
class StubBook(CallsignBook):
|
|
"""Answers lookups from a dict, so no test touches the network."""
|
|
|
|
def __init__(self, answers=None, tmp=None, **kw):
|
|
self.answers = answers or {}
|
|
self.requested = []
|
|
super().__init__(cache=(tmp or Path("/nonexistent")) / "c.json", **kw)
|
|
|
|
def _request(self, call):
|
|
self.requested.append(call)
|
|
if call not in self.answers:
|
|
raise OSError("offline")
|
|
return self.answers[call]
|
|
|
|
|
|
KU0W = {
|
|
"status": "VALID", "type": "PERSON", "name": "ROD R GOWDY",
|
|
"current": {"callsign": "KU0W", "operClass": "EXTRA"},
|
|
"previous": {"callsign": ""}, "trustee": {"callsign": ""},
|
|
"address": {"line2": "TUCSON, AZ 85742"},
|
|
"location": {"gridsquare": "DM42lj"},
|
|
"otherInfo": {"expiryDate": "08/09/2034"},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def net(tmp_path):
|
|
"""A directory whose transcript names a callsign, and a stubbed lookup."""
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 4.0,
|
|
transcript="Net control, this is KU 0W, standing by.",
|
|
meta={"category": "voice", "classification": "NFM voice"})
|
|
return tmp_path
|
|
|
|
|
|
def net_browser(directory, tmp, answers=None, **kw):
|
|
b = browser(directory, **kw)
|
|
b.book = StubBook(answers if answers is not None else {"KU0W": KU0W},
|
|
tmp=tmp)
|
|
return b
|
|
|
|
|
|
def test_callsigns_are_listed_under_the_transcript(net, tmp_path):
|
|
b = net_browser(net, tmp_path)
|
|
b.book.get("KU0W")
|
|
b.book.wait(5)
|
|
out = frame(b)
|
|
head, _, rest = out.partition("recordings in")
|
|
assert "DETECTED CALLSIGNS:" in head
|
|
assert "KU0W" in head
|
|
assert "Rod R Gowdy" in head and "Tucson, AZ" in head
|
|
# Under the words, not above them.
|
|
assert head.index("standing by") < head.index("DETECTED CALLSIGNS:")
|
|
|
|
|
|
def test_a_callsign_broken_by_the_recogniser_is_still_found(net, tmp_path):
|
|
"""The transcript says "KU 0W"; the licence is under KU0W."""
|
|
cap = scan_directory(net)[0]
|
|
assert cap.callsigns == ["KU0W"]
|
|
|
|
|
|
def test_the_lookup_is_shown_as_pending_before_it_lands(net, tmp_path):
|
|
class Slow(StubBook):
|
|
def _request(self, call):
|
|
time.sleep(1.0)
|
|
return super()._request(call)
|
|
|
|
b = browser(net)
|
|
b.book = Slow({"KU0W": KU0W}, tmp=tmp_path)
|
|
assert "looking up" in frame(b)
|
|
|
|
|
|
def test_a_transcript_with_no_callsign_has_no_block(tmp_path):
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript="the meeting is at seven on the fourth Saturday",
|
|
meta={"category": "voice"})
|
|
assert "DETECTED CALLSIGNS" not in frame(browser(tmp_path))
|
|
|
|
|
|
def test_a_callsign_that_cannot_be_looked_up_still_appears(net, tmp_path):
|
|
"""Offline, the prefix still says which country and district it is."""
|
|
b = net_browser(net, tmp_path, answers={})
|
|
b.book.get("KU0W")
|
|
b.book.wait(5)
|
|
out = frame(b)
|
|
assert "KU0W" in out and "United States" in out
|
|
|
|
|
|
def test_the_callsigns_survive_a_transcript_too_long_for_the_panel(tmp_path):
|
|
"""Whatever else is squeezed, the callsigns stay: they are the part of the
|
|
panel that cannot be recovered by listening to the recording."""
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript="KU0W " + " ".join(f"word{i}" for i in range(400)),
|
|
meta={"category": "voice"})
|
|
b = net_browser(tmp_path, tmp_path, height=22)
|
|
b.book.get("KU0W")
|
|
b.book.wait(5)
|
|
out = frame(b)
|
|
assert "DETECTED CALLSIGNS:" in out and "Rod R Gowdy" in out
|
|
assert "more line(s)" in out
|
|
|
|
|
|
def test_the_reader_shows_them_at_the_end_of_the_words(tmp_path):
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm",
|
|
transcript="KU0W " + " ".join(f"word{i}" for i in range(400)),
|
|
meta={"category": "voice"})
|
|
b = net_browser(tmp_path, tmp_path, height=22)
|
|
b.book.get("KU0W")
|
|
b.book.wait(5)
|
|
b.handle("t")
|
|
assert "DETECTED CALLSIGNS" not in frame(b), "shown beside the middle"
|
|
b.handle("end")
|
|
assert "DETECTED CALLSIGNS:" in frame(b)
|
|
|
|
|
|
def test_the_frame_still_fits_with_callsigns(net, tmp_path):
|
|
for height in (16, 20, 24, 40):
|
|
b = net_browser(net, tmp_path, height=height)
|
|
b.book.get("KU0W")
|
|
b.book.wait(5)
|
|
lines = frame(b).rstrip("\n").split("\n")
|
|
assert len(lines) <= height, f"{len(lines)} lines in {height}"
|
|
|
|
|
|
def test_callsigns_are_found_once_per_recording(net):
|
|
cap = scan_directory(net)[0]
|
|
assert cap._calls is None
|
|
first = cap.callsigns
|
|
assert cap._calls is not None
|
|
assert cap.callsigns is first
|
|
|
|
|
|
# -- the callsign command line -----------------------------------------------
|
|
|
|
def test_callsigns_flag_reports_where_each_was_heard(net, capsys, monkeypatch):
|
|
monkeypatch.setattr("bandsaunter.browse.CallsignBook",
|
|
lambda **kw: StubBook({"KU0W": KU0W}, tmp=net, **kw))
|
|
assert main(["--callsigns", str(net)]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "KU0W" in out and "Rod R Gowdy" in out
|
|
assert "heard on" in out and "146.52 MHz" in out
|
|
|
|
|
|
def test_callsigns_flag_says_so_when_there_are_none(tmp_path, capsys):
|
|
make_capture(tmp_path, 856.5625, "2026-08-22_11_00_00", "fsk", 2.0,
|
|
meta={"category": "trunk"})
|
|
assert main(["--callsigns", str(tmp_path)]) == 1
|
|
assert "no callsigns" in capsys.readouterr().out
|
|
|
|
|
|
def test_no_lookup_contacts_nothing(net, capsys, monkeypatch):
|
|
"""The flag has to mean it: nothing leaves the machine."""
|
|
made = []
|
|
|
|
class Watching(StubBook):
|
|
def _request(self, call):
|
|
made.append(call)
|
|
return super()._request(call)
|
|
|
|
monkeypatch.setattr("bandsaunter.browse.CallsignBook",
|
|
lambda **kw: Watching({"KU0W": KU0W}, tmp=net, **kw))
|
|
assert main(["--callsigns", "--no-lookup", str(net)]) == 0
|
|
out = capsys.readouterr().out
|
|
assert made == [], "a request was made with --no-lookup"
|
|
assert "KU0W" in out
|
|
assert "United States" in out, "the prefix should still be described"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_the_details_line_names_the_band(library):
|
|
b = browser(library)
|
|
b.index = [i for i, c in enumerate(b.view)
|
|
if abs(c.frequency - 146.52e6) < 1e5][0]
|
|
assert "2 m Amateur" in frame(b)
|
|
|
|
|
|
def test_a_capture_keeps_the_band_its_sidecar_recorded(library):
|
|
"""The plan can be edited later; the recording was filed under this one."""
|
|
b = browser(library)
|
|
cap = [c for c in b.captures if abs(c.frequency - 146.52e6) < 1e5][0]
|
|
assert cap.band == "2 m Amateur"
|
|
|
|
|
|
def test_a_capture_with_no_band_in_its_sidecar_gets_one_from_the_dial(library):
|
|
b = browser(library)
|
|
cap = [c for c in b.captures if abs(c.frequency - 144.1e6) < 1e5][0]
|
|
assert cap.band and "2 m" in cap.band
|
|
|
|
|
|
def test_a_band_can_be_searched_for(library):
|
|
""""70 cm" is how an operator thinks of a range, not 420-450 MHz."""
|
|
b = browser(library)
|
|
b.query = "2 m"
|
|
b.apply()
|
|
assert b.view, "searching by band found nothing"
|
|
assert all("2 m" in c.band for c in b.view)
|
|
|
|
|
|
def test_the_listing_carries_the_band_beside_the_frequency(library, capsys):
|
|
from bandsaunter.browse import main
|
|
assert main([str(library), "--list"]) == 0
|
|
out = capsys.readouterr().out
|
|
row = [ln for ln in out.splitlines() if "146.52 MHz" in ln][0]
|
|
assert row.index("146.52 MHz") < row.index("2 m Amateur")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stub_lookup(monkeypatch):
|
|
def respond(self, call):
|
|
return {"status": "VALID", "name": "ARRL HQ OPERATORS CLUB",
|
|
"address": {"line2": "NEWINGTON, CT 06111"},
|
|
"location": {"latitude": "41.714775",
|
|
"longitude": "-72.727260",
|
|
"gridsquare": "FN31pr"}}
|
|
monkeypatch.setattr(CallsignBook, "_request", respond)
|
|
|
|
|
|
def test_the_browser_writes_a_map_of_who_was_heard(library, monkeypatch,
|
|
capsys):
|
|
import xml.etree.ElementTree as ET
|
|
from bandsaunter.browse import main
|
|
_stub_lookup(monkeypatch)
|
|
assert main([str(library), "--kml"]) == 0
|
|
written = library / "callsigns.kml"
|
|
assert written.exists()
|
|
root = ET.parse(written).getroot()
|
|
ns = "{http://www.opengis.net/kml/2.2}"
|
|
names = [m.find(ns + "name").text for m in root.iter(ns + "Placemark")]
|
|
assert any("W1AW" in n for n in names)
|
|
# and where it is licensed, which is the point of a map
|
|
point = next(root.iter(ns + "Placemark")).find(
|
|
f"{ns}Point/{ns}coordinates")
|
|
assert point is not None and point.text.startswith("-72.7")
|
|
assert "with a position" in capsys.readouterr().out
|
|
|
|
|
|
def test_the_map_can_be_written_somewhere_else(library, tmp_path, monkeypatch):
|
|
from bandsaunter.browse import main
|
|
_stub_lookup(monkeypatch)
|
|
where = tmp_path / "elsewhere" / "heard.kml"
|
|
assert main([str(library), "--kml", str(where)]) == 0
|
|
assert where.exists()
|
|
assert not (library / "callsigns.kml").exists()
|
|
|
|
|
|
def test_no_callsigns_means_no_map(tmp_path, monkeypatch):
|
|
from bandsaunter.browse import main
|
|
_stub_lookup(monkeypatch)
|
|
make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 4.0,
|
|
transcript="Nobody said a callsign in this one at all.",
|
|
meta={"category": "voice"})
|
|
assert main([str(tmp_path), "--kml"]) == 1
|
|
assert not list(tmp_path.glob("*.kml"))
|