"""Dealing with the recordings, not just reading them. A night's scan leaves hundreds of files and most of them are worth nothing. The browser can now file one away, delete it, or lock its frequency out of every later scan -- which makes it the first part of this program that writes to the recordings directory, so most of what is checked here is that it writes exactly what it meant to and nothing else. Two things run through all of it. A capture is not one file: the .wav, the sidecar, the IQ, the transcript and the decoded data move or die together, or what is left is a transcript describing a recording nobody has. And the cursor stays on the row it was on, because going down a list is one key per recording and a cursor that jumped after each one would make that impossible. """ import json from pathlib import Path import pytest import yaml from rich.console import Console from bandsaunter.browse import Browser, FILING, Player, scan_directory from bandsaunter.config import ScanConfig from test_browse import library, make_capture, write_wav # noqa: F401 def _browser(directory, config_dir=None, width=100, height=30) -> Browser: console = Console(width=width, height=height, force_terminal=True) return Browser(directory, console=console, player=Player([]), config_dir=config_dir) def frame(b: Browser) -> str: with b.console.capture() as cap: b.console.print(b.render()) return cap.get() def names(directory: Path) -> set[str]: return {p.name for p in Path(directory).iterdir()} @pytest.fixture def full(tmp_path): """One capture with every sidecar a scan can write beside it.""" stem = "0146.520000MHz--2026-08-22_10_00_00-nfm" write_wav(tmp_path / f"{stem}.wav", 2.0) (tmp_path / f"{stem}.json").write_text(json.dumps( {"hit": {"frequency": 146.52e6, "category": "voice"}})) (tmp_path / f"{stem}.cf32").write_bytes(b"\0" * 64) (tmp_path / f"{stem}.sigmf-meta").write_text("{}") (tmp_path / f"{stem}_transcription.txt").write_text("this is W1AW\n") (tmp_path / f"{stem}_data.txt").write_text("beacon 0x41\n") return tmp_path # -- what belongs to a capture ----------------------------------------------- def test_a_capture_owns_every_file_written_beside_it(full): cap = scan_directory(full)[0] assert {p.name.split("-nfm")[1] for p in cap.files()} == { ".wav", ".json", ".cf32", ".sigmf-meta", "_transcription.txt", "_data.txt"} def test_a_capture_does_not_claim_its_neighbour(full): """Two signals in the same second on the same frequency: ...-nfm_2. Matching the stem followed by anything would sweep the second capture up with the first, and file away a recording nobody asked about. """ stem = "0146.520000MHz--2026-08-22_10_00_00-nfm" write_wav(full / f"{stem}_2.wav", 1.0) (full / f"{stem}_2.json").write_text("{}") first = [c for c in scan_directory(full) if c.path.stem == stem][0] assert not any("_2" in p.name for p in first.files()) def test_a_recording_on_its_own_owns_only_itself(library): cap = [c for c in scan_directory(library) if "144.1" in c.path.name][0] assert [p.suffix for p in cap.files()] == [".json", ".wav"] # -- filing it away ---------------------------------------------------------- @pytest.mark.parametrize("key,name,_why", FILING) def test_a_filing_key_moves_the_whole_capture(full, key, name, _why): b = _browser(full) cap = b.current was = {p.name for p in cap.files()} assert b.handle(key) assert names(full / name) == was assert not any(p.suffix == ".wav" for p in full.iterdir()) def test_the_three_keys_are_three_directories(full): assert [name for _, name, _ in FILING] == ["saved", "investigate", "noise"] def test_a_filed_recording_leaves_the_list(library): b = _browser(library) before = len(b.view) b.handle("N") assert len(b.view) == before - 1 assert len(b.captures) == before - 1 def test_the_cursor_stays_where_it_was(library): """One key per recording, going down the list: it must not jump.""" b = _browser(library) b.move(1) third = b.view[2].path b.handle("S") assert b.index == 1 assert b.current.path == third def test_filing_the_last_one_leaves_the_cursor_in_range(library): b = _browser(library) for _ in range(3): b.handle("N") assert b.view == [] and b.index == 0 assert "no recordings" in frame(b) def test_a_scan_never_sees_what_was_filed_away(library): """The subdirectories are below the recordings directory on purpose.""" b = _browser(library) b.handle("N") assert (library / "noise").is_dir() assert len(scan_directory(library)) == 2 def test_what_was_filed_can_be_browsed_by_opening_the_directory(library): b = _browser(library) b.handle("I") assert len(scan_directory(library / "investigate")) == 1 def test_filing_says_where_it_went(full): b = _browser(full) b.handle("S") assert "saved/" in b.message and "6 file" in b.message def test_a_name_already_there_is_not_overwritten(library): """The same frequency and second can be recorded on two different days.""" b = _browser(library) cap = b.current (library / "saved").mkdir() (library / "saved" / cap.path.name).write_text("an older one") b.handle("S") kept = (library / "saved" / cap.path.name).read_text() assert kept == "an older one" assert len(list((library / "saved").glob("*.wav"))) == 2 def test_the_whole_set_is_renamed_together_when_it_collides(full): b = _browser(full) cap = b.current (full / "saved").mkdir() (full / "saved" / cap.path.name).write_text("an older one") b.handle("S") moved = sorted(p.name for p in (full / "saved").iterdir() if "_2" in p.name) assert len(moved) == 6 assert len({p.split("_2")[0] for p in moved}) == 1 def test_a_move_that_fails_halfway_is_put_back(full, monkeypatch): """Half a capture in each of two directories is worse than none moved.""" b = _browser(full) cap = b.current was = {p.name for p in cap.files()} real = Path.rename def flaky(self, target): if self.suffix == ".sigmf-meta": raise OSError(13, "permission denied") return real(self, target) monkeypatch.setattr(Path, "rename", flaky) b.handle("S") assert {p.name for p in cap.files()} == was assert list((full / "saved").iterdir()) == [] assert "could not move" in b.message assert b.current is cap, "the capture was dropped from a move that failed" # -- putting one back -------------------------------------------------------- def test_u_puts_the_last_one_filed_back(full): b = _browser(full) was = {p.name for p in b.current.files()} b.handle("N") b.handle("u") assert names(full) - {"noise"} == was assert list((full / "noise").iterdir()) == [] assert len(b.view) == 1 def test_the_one_put_back_is_the_one_under_the_cursor(library): b = _browser(library) b.move(1) filed = b.current.path b.handle("S") b.handle("u") assert b.current.path == filed, "the cursor did not follow it back" def test_undo_is_one_step_not_a_history(library): b = _browser(library) b.handle("N") b.handle("N") b.handle("u") b.handle("u") assert "nothing to put back" in b.message assert len(list((library / "noise").glob("*.wav"))) == 1 def test_there_is_nothing_to_put_back_to_begin_with(library): b = _browser(library) b.handle("u") assert "nothing to put back" in b.message # -- deleting ---------------------------------------------------------------- def test_delete_asks_first(library): b = _browser(library) cap = b.current b.handle("d") assert b.confirm and cap.path.name in b.confirm assert cap.path.exists(), "deleted without being agreed to" assert b._footer().plain.endswith(" y/n") def test_anything_but_yes_means_no(library): b = _browser(library) cap = b.current b.handle("d") assert b.handle("n") assert not b.confirm and cap.path.exists() assert b.message == "left alone" def test_q_at_the_question_answers_it_rather_than_quitting(library): b = _browser(library) b.handle("d") assert b.handle("q"), "q answered the question and quit as well" assert b.current is not None and b.current.path.exists() def test_yes_deletes_the_recording_and_its_sidecars(full): b = _browser(full) cap = b.current b.handle("d") b.handle("y") assert not cap.path.exists() assert list(full.iterdir()) == [], "a sidecar outlived its recording" assert b.view == [] def test_the_question_counts_the_whole_capture(full): b = _browser(full) b.handle("d") assert b.confirm.endswith("and 5 sidecar files?") def test_the_question_counts_in_the_singular_too(library): b = _browser(library) b.index = next(i for i, c in enumerate(b.view) if "144.1" in c.path.name) b.handle("d") assert b.confirm.endswith("and 1 sidecar file?") def test_a_recording_with_nothing_beside_it_is_asked_about_plainly(tmp_path): write_wav(tmp_path / "0146.520000MHz--2026-08-22_10_00_00-nfm.wav", 1.0) b = _browser(tmp_path) b.handle("d") assert b.confirm.endswith("-nfm.wav?") def test_a_delete_cannot_be_undone_with_u(full): b = _browser(full) b.handle("S") # something to undo b.handle("u") # ... used up b.handle("d") b.handle("y") b.handle("u") assert "nothing to put back" in b.message def test_a_delete_does_not_put_back_the_last_move(library): """u after a delete must not resurrect a different recording.""" b = _browser(library) filed = b.current.path.name b.handle("N") b.handle("d") b.handle("y") b.handle("u") assert "nothing to put back" in b.message assert (library / "noise" / filed).exists() # -- masking a frequency ----------------------------------------------------- def _lockouts(where: Path) -> list[dict]: return yaml.safe_load((where / "config.yaml").read_text())["lockout"] def test_m_writes_the_frequency_into_the_settings(library, tmp_path): cfg_dir = tmp_path / "cfg" b = _browser(library, config_dir=cfg_dir) heard = b.current.frequency b.handle("m") assert _lockouts(cfg_dir) == [{"start": heard, "stop": heard}] def test_the_scanner_reads_back_what_the_browser_wrote(library, tmp_path): """The two write the same list, so one has to be able to read the other.""" cfg_dir = tmp_path / "cfg" b = _browser(library, config_dir=cfg_dir) heard = b.current.frequency b.handle("m") saved = ScanConfig.from_dict( yaml.safe_load((cfg_dir / "config.yaml").read_text())) low, high = saved.lockout[0].interval(saved.lockout_width) assert low < heard < high def test_masking_says_it_takes_effect_next_time(library, tmp_path): b = _browser(library, config_dir=tmp_path / "cfg") b.handle("m") assert "locked out" in b.message and "next scan" in b.message def test_the_same_frequency_is_not_locked_out_twice(library, tmp_path): cfg_dir = tmp_path / "cfg" b = _browser(library, config_dir=cfg_dir) b.handle("m") b.handle("m") assert len(_lockouts(cfg_dir)) == 1 assert "already locked out" in b.message def test_a_frequency_inside_an_existing_lock_out_is_left_alone(library, tmp_path): """A lock-out is a channel, not a point: 146.52 covers 146.5205 too.""" cfg_dir = tmp_path / "cfg" b = _browser(library, config_dir=cfg_dir) heard = b.current.frequency cfg_dir.mkdir() (cfg_dir / "config.yaml").write_text(yaml.safe_dump( {"lockout": [{"start": heard - 1e5, "stop": heard + 1e5}]})) b.handle("m") assert len(_lockouts(cfg_dir)) == 1 assert "already locked out" in b.message def test_masking_leaves_every_other_setting_alone(library, tmp_path): """A settings file is not the browser's to rewrite.""" cfg_dir = tmp_path / "cfg" cfg_dir.mkdir() (cfg_dir / "config.yaml").write_text(yaml.safe_dump( {"output_dir": "/somewhere/else", "record_seconds": 99.0, "gain": "auto"})) b = _browser(library, config_dir=cfg_dir) b.handle("m") after = yaml.safe_load((cfg_dir / "config.yaml").read_text()) assert after["output_dir"] == "/somewhere/else" assert after["record_seconds"] == 99.0 assert len(after["lockout"]) == 1 def test_a_recording_with_no_frequency_cannot_be_masked(tmp_path): write_wav(tmp_path / "something-else.wav", 1.0) b = _browser(tmp_path, config_dir=tmp_path / "cfg") b.handle("m") assert "frequency" in b.message assert not (tmp_path / "cfg").exists() def test_masking_does_not_touch_the_recording(library, tmp_path): """Two keys, on purpose: locking out is about later scans, not this file.""" b = _browser(library, config_dir=tmp_path / "cfg") cap = b.current b.handle("m") assert cap.path.exists() and len(b.view) == 3 def test_without_being_told_it_writes_where_the_scanner_reads(library, isolated_settings): b = _browser(library) b.handle("m") assert (isolated_settings / "config.yaml").exists() # -- these keys are not commands everywhere ---------------------------------- @pytest.mark.parametrize("key", ["S", "I", "N", "d", "m", "u"]) def test_nothing_happens_while_typing_a_search(library, key): b = _browser(library) b.handle("/") b.handle(key) assert b.query == key and not b.confirm assert len(scan_directory(library)) == 3 @pytest.mark.parametrize("key", ["S", "I", "N", "d", "m"]) def test_nothing_happens_on_an_empty_directory(tmp_path, key): b = _browser(tmp_path, config_dir=tmp_path / "cfg") assert b.handle(key) assert not b.confirm # -- what the screen says about them ----------------------------------------- def test_the_keys_are_on_the_help_screen(library): b = _browser(library) b.handle("?") shown = frame(b) for _key, name, _why in FILING: assert f"{name}/" in shown for word in ("delete", "lock this frequency out", "put the last one"): assert word in shown @pytest.mark.parametrize("width", [100, 80]) def test_the_help_screen_still_fits_an_ordinary_window(library, width): """Eighty by twenty-four is still what a new terminal is. A list of keys that has to be scrolled to reach "q" has failed at the one job it has, so every row has to be on the screen at once. """ b = _browser(library, width=width, height=24) b.handle("?") lines = frame(b).rstrip("\n").split("\n") assert len(lines) == 24 # border, a blank from the padding, then the last row of keys. assert "quit" in lines[-3], "the last key is off the bottom of the panel" assert "╰" in lines[-1], "the panel does not close" def test_the_footer_offers_them(library): b = _browser(library) line = frame(b).splitlines()[-1] assert "file" in line and "delete" in line and "mask" in line def test_a_narrow_window_drops_keys_rather_than_cutting_the_line(library): """An ellipsis would hide whichever keys happened to be at the end.""" b = _browser(library, width=46) line = frame(b).splitlines()[-1] assert "…" not in line assert "quit" in line, "the one key nobody can afford not to be told" @pytest.mark.parametrize("width,height", [(120, 40), (100, 30), (80, 24), (64, 16), (50, 10)]) def test_the_frame_still_fits_while_it_is_asking(library, width, height): b = _browser(library, width=width, height=height) b.handle("d") assert len(frame(b).rstrip("\n").split("\n")) <= height # -- in a real terminal ------------------------------------------------------ # # These keys write to disk, which is exactly the thing a test that stubs the # console cannot get wrong in an interesting way. Running the program in a # pty and typing at it checks the whole path: the key reaches the handler, # the handler moves the files, and the settings land where they were told to # rather than in the settings a real scan runs from. def _pty(directory, config_dir): import sys sys.path.insert(0, str(Path(__file__).parent)) from terminal import Terminal return Terminal([sys.executable, "-m", "bandsaunter.browse", str(directory)], cols=100, rows=30, env={"BANDSAUNTER_CONFIG_DIR": str(config_dir)}) def test_typing_the_keys_at_a_real_terminal_moves_real_files(library, tmp_path): pytest.importorskip("pyte", reason="terminal emulator not installed") with _pty(library, tmp_path / "cfg") as term: term.pump(2.5) term.send("N") term.pump(1.2) assert len(list((library / "noise").glob("*.wav"))) == 1 assert "noise/" in term.text() term.send("u") term.pump(1.2) assert list((library / "noise").glob("*.wav")) == [] def test_the_question_is_asked_on_the_screen_and_answered(library, tmp_path): pytest.importorskip("pyte", reason="terminal emulator not installed") with _pty(library, tmp_path / "cfg") as term: term.pump(2.5) term.send("d") term.pump(1.0) assert "y/n" in term.text() assert len(scan_directory(library)) == 3, "deleted before answering" term.send("y") term.pump(1.2) assert len(scan_directory(library)) == 2 def test_masking_writes_where_it_was_told_and_nowhere_else(library, tmp_path): """The settings a real scan runs from are not a test's to write. The harness accepted an environment and ignored it, which is how this was found: a smoke test aimed at a throwaway directory locked a frequency out of the settings on the machine it was run on. """ pytest.importorskip("pyte", reason="terminal emulator not installed") cfg_dir = tmp_path / "cfg" with _pty(library, cfg_dir) as term: term.pump(2.5) term.send("m") term.pump(1.5) assert "locked out" in term.text() assert len(_lockouts(cfg_dir)) == 1 # -- and what was playing ---------------------------------------------------- def test_deleting_what_is_playing_stops_it_first(library): """Sound from a recording that is no longer there is worse than silence.""" from test_browse import FakePlayer b = _browser(library) b.player = FakePlayer() b.handle("enter") assert b.player.active b.handle("d") b.handle("y") assert not b.player.active def test_filing_what_is_playing_stops_it_too(library): from test_browse import FakePlayer b = _browser(library) b.player = FakePlayer() b.handle("enter") b.handle("S") assert not b.player.active def test_a_different_recording_keeps_playing(library): from test_browse import FakePlayer b = _browser(library) b.player = FakePlayer() b.handle("enter") b.move(1) b.handle("N") assert b.player.active, "stopped a recording that was not the one filed"