From f9f0d94000b9ff30220a862ff9bbbb78511d3486 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Sat, 29 Aug 2026 13:21:32 -0700 Subject: [PATCH] Deal with the recordings, not just read them A night's scan leaves hundreds of files, most worth nothing and a few of them the reason it was left running. Sorting that out meant leaving the browser and going at the directory with mv and rm. Five keys, meant to be pressed once each going down the list: S I N file it into saved/, investigate/ or noise/ u put the last one filed back d delete it and its sidecars, for good -- asks first m lock the frequency out, so no later scan stops on it Each of these acts on the whole capture -- the .wav, the JSON sidecar, the IQ, the transcript and the decoded data -- because a recording in one directory and its transcript in another is a pair nothing will ever put back together. A move that cannot be finished puts back whatever already moved. The cursor stays on the row it was on, which is now the next recording, since a cursor that jumped would make one-key-per-recording impossible. m writes to the lock-out list in the settings file, the same one the scanner's own l key maintains, so a birdie found while reading last night's recordings is gone from tonight's. It says "the next scan": one already running read its settings when it started. The subdirectories sit under the recordings directory, so a scan writing there never looks in them, and saunterbrowse ~/bandsaunter/saved reads one back. Also here, because this is the first part of the browser that writes: - The help screen is back inside eighty by twenty-four. It had grown past the bottom of an ordinary window, which puts "q quit" off the screen. - The footer drops keys in a deliberate order when the window is narrow, rather than ellipsising whichever happened to be at the end. - Moving or deleting what is playing stops the player first. - The pty harness accepted an env and ignored it, so a test aimed at a throwaway settings directory wrote to the real one. It honours it now, and conftest redirects the settings directory for every test besides. 1014 tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg --- README.md | 44 ++- bandsaunter/__init__.py | 4 +- bandsaunter/browse.py | 353 ++++++++++++++++++++-- bandsaunter/tui.py | 7 +- packaging/bandsaunter.1 | 2 +- packaging/make-browse-man.py | 96 +++++- packaging/saunterbrowse.1 | 91 +++++- tests/conftest.py | 33 ++- tests/terminal.py | 8 +- tests/test_manage.py | 560 +++++++++++++++++++++++++++++++++++ tests/test_manpage.py | 8 +- 11 files changed, 1162 insertions(+), 44 deletions(-) create mode 100644 tests/test_manage.py diff --git a/README.md b/README.md index 001c7d3..4d64dfd 100644 --- a/README.md +++ b/README.md @@ -1086,6 +1086,10 @@ span is taken exactly as written, since a noisy stretch of spectrum has a definite width rather than a point with a guess around it. Ranges accept the same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`. +`saunterbrowse` writes to the same list: pressing `m` over a recording locks +out the frequency it was heard on, which is usually when you find out that a +frequency is not worth listening to. + `--lockout-control` adds each trunking control channel to the list as it is found. Two runs never write anything back: `--no-config` has no settings file to write to, since the point of the flag is to leave the saved settings alone; @@ -1129,7 +1133,7 @@ or symbol rate where there is one, and the bands the frequency falls in. │ › 146.88 MHz 13:01:44 nfm 42.8s Alright, moving on. It is… │ │ 146.88 MHz 13:00:44 nfm 35.2s Check out communication o… │ ╰───────────────────────────────────────────────────────────────────────╯ - ↑↓ move ⏎ play space stop / search t read s sort ? keys q + ↑↓ move ⏎ play space stop / search t read S I N file d delete m mask q quit ``` | Key | What it does | @@ -1144,8 +1148,46 @@ or symbol rate where there is one, and the bands the frequency falls in. | `s` | sort by time, frequency or length | | `r` | re-read the directory, picking up what a running scan has written | | `o` | print the file's path and quit | +| `S` `I` `N` | file it into `saved/`, `investigate/` or `noise/` | +| `u` | put the last one filed back | +| `d` | delete it and its sidecars, for good — asks first | +| `m` | lock this frequency out, so no later scan stops on it | | `q` | quit | +### Dealing with what you find + +A night's scan leaves hundreds of files, most worth nothing and a few of them +the reason you left it running. Sorting that out is one key per recording, +going down the list: + +``` +S saved/ keep this one +I investigate/ come back to this one +N noise/ not a signal worth keeping +d delete it outright — asks first +m never record this frequency again +``` + +Each of `S I N` moves the whole capture — the `.wav`, the JSON sidecar, the +raw IQ if it was kept, the transcript and the decoded data — because a +recording in one directory and its transcript in another is a pair nothing +will ever put back together. If a move cannot be finished, whatever already +moved is put back. + +The subdirectories are ordinary directories inside the recordings directory, +so a scan writing there never looks in them, and `saunterbrowse +~/bandsaunter/saved` reads one back. `u` puts the last one filed back — one +step, so that a mistyped key costs nothing. `d` asks first, because nothing +puts that back. + +`m` is the other half of the same job. A birdie or a pager transmitter that +fills the directory night after night is a scanning problem, not a recording +one, so this writes the frequency into the lock-out list in your settings — +the same list [the scanner's own `l` key](#lock-outs) writes to. It +takes effect on the next scan; one already running read its settings when it +started. Locking a frequency out does not delete what has already been +recorded on it, so pressing `m` and then `d` is the usual thing to do. + ### Detected callsigns Under the transcript, every callsign heard in it is listed with the name and diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 49dab82..1a31259 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -8,8 +8,8 @@ and transcribing speech. # Versions are the release date and a revision within that day, so # 2026-08-21_02 is the second build made on the 21st. The revision is padded # to two digits so versions sort as text. -VERSION_DATE = "2026-08-28" -VERSION_REVISION = 3 +VERSION_DATE = "2026-08-29" +VERSION_REVISION = 1 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/browse.py b/bandsaunter/browse.py index c369191..e2a6f2c 100644 --- a/bandsaunter/browse.py +++ b/bandsaunter/browse.py @@ -11,6 +11,7 @@ the thing you actually want to read -- gets the top of the screen. from __future__ import annotations import argparse +import glob import json import os import re @@ -39,10 +40,12 @@ from rich.text import Text from . import __version__ from .bandplan import band_names, fmt_hz, shorten_band from .callsign import CallsignBook, HEADING, find_callsigns -from .config import load_default +from .config import DEFAULT_CONFIG_DIR, load_default, remember_lockouts from .kml import DEFAULT_KML_NAME, KmlLog +from .ranges import Lockout -__all__ = ["main", "Capture", "scan_directory", "Browser", "Player"] +__all__ = ["main", "Capture", "scan_directory", "Browser", "Player", + "FILING"] # --------------------------------------------------------------------------- @@ -233,6 +236,36 @@ class Capture: except OSError: return 0 + def files(self) -> list[Path]: + """Every file that belongs to this recording, the .wav included. + + A capture is not one file. Beside it a scan writes the JSON sidecar, + sometimes the raw IQ and its SigMF description, the transcript, and + the decoded data. They are one thing, and moving or deleting the + recording without them leaves sidecars describing a recording nobody + has any more. + + Matched on the stem followed by a dot, never on the stem followed by + anything: two signals can land in the same second on the same + frequency, in which case the second is filed as ``...-nfm_2``, and a + looser pattern would sweep that up with its neighbour. + """ + stem = self.path.stem + found = {self.path} + try: + found.update(p for p in + self.path.parent.glob(glob.escape(stem) + ".*") + if p.is_file()) + except OSError: + pass + # The two that do not follow the pattern, because a transcript is + # named after the recording rather than sharing its extension. + for tail in ("_transcription.txt", "_data.txt"): + side = self.path.with_name(stem + tail) + if side.is_file(): + found.add(side) + return sorted(found) + def _from_name(path: Path) -> Capture: """Read what the filename alone says, without touching any sidecar.""" @@ -463,6 +496,19 @@ class Keyboard: SORTS = ("time", "frequency", "duration") +# The three subdirectories a recording can be filed into, and the key that +# does it. Upper case on purpose: j and k are under the same fingers, and a +# key that moves a file is not one to hit while scrolling. They are ordinary +# directories under the recordings directory, so a scan never looks in them +# again and the browser opens one by being pointed at it. +FILING: tuple[tuple[str, str, str], ...] = ( + ("S", "saved", "keep this one"), + ("I", "investigate", "come back to this one"), + ("N", "noise", "not a signal worth keeping"), +) + +_FILING_KEYS = {key: name for key, name, _ in FILING} + def _dur(seconds: float) -> str: seconds = max(0.0, float(seconds)) @@ -485,11 +531,16 @@ class Browser: def __init__(self, directory: Path, console: Console | None = None, player: Player | None = None, - book: CallsignBook | None = None): + book: CallsignBook | None = None, + config_dir: Path | None = None): self.directory = Path(directory) self.console = console or Console() self.player = player if player is not None else Player() self.book = book if book is not None else CallsignBook() + # Where the lock-out key writes. Passed in rather than reached for, + # so that nothing can write to the settings a real scan runs from + # without having been told to. + self.config_dir = Path(config_dir) if config_dir else None self.captures: list[Capture] = [] self.view: list[Capture] = [] self.index = 0 @@ -497,10 +548,14 @@ class Browser: self.sort = "time" self.query = "" self.searching = False + self.confirm = "" # an action waiting to be agreed to self.message = "" self.show_help = False self.reading = False # full-screen transcript self.read_top = 0 + self._undo: list[tuple[Path, Path]] = [] # the last move, to reverse + self._undo_path: Path | None = None # where its .wav came from + self._undo_label = "" self._size = self.console.size self.reload() @@ -562,6 +617,172 @@ class Browser: return self.index = max(0, min(len(self.view) - 1, self.index + delta)) + # -- managing what is on disk ----------------------------------------- + # + # Everything here works on the whole capture -- the recording and every + # sidecar written beside it -- and leaves the cursor on the row it was + # on, which is now the next recording. Going through a night's scan is + # one key per recording, and a cursor that jumped after each one would + # make that impossible. + + def _forget(self, cap: Capture) -> None: + """Drop a capture from the lists without re-reading the directory.""" + for group in (self.captures, self.view): + try: + group.remove(cap) + except ValueError: + pass + self.index = max(0, min(self.index, len(self.view) - 1)) + self.top = max(0, min(self.top, max(0, len(self.view) - 1))) + + @staticmethod + def _plan_move(cap: Capture, target: Path) -> list[tuple[Path, Path]]: + """Where each of a capture's files should go, avoiding collisions. + + The whole set is renamed together or not at all: a recording called + one thing and a transcript called another is a pair nothing will ever + put back together. + """ + files = cap.files() + stem = cap.path.stem + tails = [f.name[len(stem):] for f in files] + candidate, n = stem, 2 + while any((target / (candidate + tail)).exists() for tail in tails): + candidate, n = f"{stem}_{n}", n + 1 + return [(f, target / (candidate + tail)) + for f, tail in zip(files, tails)] + + def _release(self, cap: Capture) -> None: + """Stop playing this recording, if it is the one being played. + + Moving or deleting a file out from under a player leaves sound coming + out of a recording that is no longer there, and a progress bar + counting up against a name nothing on disk answers to. + """ + if (self.player.playing is not None + and self.player.playing.path == cap.path): + self.player.stop() + + def _file_into(self, name: str) -> None: + """Move the highlighted capture into a subdirectory of its own.""" + cap = self.current + if cap is None: + return + self._release(cap) + target = self.directory / name + try: + target.mkdir(parents=True, exist_ok=True) + except OSError as exc: + self.message = f"cannot make {name}/: {exc}" + return + + done: list[tuple[Path, Path]] = [] + for src, dst in self._plan_move(cap, target): + try: + src.rename(dst) + except OSError as exc: + # Put back whatever has already moved. Half a capture in + # each of two directories is worse than none moved at all. + for was, now in reversed(done): + try: + now.rename(was) + except OSError: + pass + self.message = f"could not move {src.name}: {exc}" + return + done.append((src, dst)) + + self._undo, self._undo_path = done, cap.path + self._undo_label = f"{cap.path.name} back from {name}/" + self._forget(cap) + plural = "" if len(done) == 1 else "s" + self.message = (f"{cap.path.name} → {name}/ " + f"({len(done)} file{plural}) u to undo") + + def _undo_move(self) -> None: + """Reverse the last filing. Not a delete: that one is gone.""" + if not self._undo: + self.message = "nothing to put back" + return + moves, self._undo = self._undo, [] + home, self._undo_path = self._undo_path, None + failed = 0 + for was, now in reversed(moves): + try: + now.rename(was) + except OSError: + failed += 1 + # A full re-read, because the capture has to go back into the list in + # the place the current sort order puts it, not the place it was. + self.reload() + if home is not None: + for i, cap in enumerate(self.view): + if cap.path == home: + self.index = i + break + self.message = (f"{failed} file(s) could not be put back" if failed + else f"put {self._undo_label}") + self._undo_label = "" + + def _delete(self) -> None: + """Delete the highlighted capture and everything written beside it.""" + cap = self.current + if cap is None: + return + self._release(cap) + gone = 0 + for path in cap.files(): + try: + path.unlink() + gone += 1 + except OSError as exc: + self.message = f"could not delete {path.name}: {exc}" + if gone: + self.reload() + return + # There is nothing to undo a delete with, and leaving the last move + # under u would put the wrong thing back. + self._undo, self._undo_path, self._undo_label = [], None, "" + self._forget(cap) + others = gone - 1 + self.message = f"deleted {cap.path.name}" + ( + f" and {others} sidecar{'' if others == 1 else 's'}" + if others else "") + + def _mask(self) -> None: + """Lock this frequency out, so no later scan stops on it again. + + The same list the scanner's own lock-out key writes to, and the same + file, so a birdie masked here while reading last night's recordings + is gone from tonight's. + """ + cap = self.current + if cap is None: + return + if not cap.frequency: + self.message = ("nothing in this recording's name says what " + "frequency it was on") + return + directory = self.config_dir or DEFAULT_CONFIG_DIR + cfg, _ = load_default(directory) + width = max(1.0, cfg.lockout_width) + for existing in cfg.lockout: + low, high = Lockout.coerce(existing).interval(width) + if low <= cap.frequency <= high: + self.message = (f"{fmt_hz(cap.frequency)} is already locked " + "out") + return + entry = Lockout(cap.frequency) + cfg.lockout.append(entry) + written = remember_lockouts(cfg, directory) + if written is None: + self.message = f"could not write the lock-out to {directory}" + return + # "The next scan", not "scans": one already running read its settings + # when it started and will not see this. + self.message = (f"locked out {entry.describe()} ±{fmt_hz(width / 2)} " + f"in {written} — the next scan will not stop there") + # -- rendering -------------------------------------------------------- def _callsign_lines(self, pad: int = 3) -> list[str]: """The DETECTED CALLSIGNS block, wrapped to the panel's width. @@ -845,18 +1066,65 @@ class Browser: return Panel(t, title=title, title_align="left", border_style="blue", padding=(0, 1)) + @staticmethod + def _line(markup: str) -> Text: + """One line, and never more, whatever it was asked to hold. + + The footer sits in a region exactly one row tall. A line that wrapped + would push the frame past the bottom of the window, and the whole + display depends on the frame being the height it says it is. + """ + text = Text.from_markup(markup) + text.no_wrap = True + text.overflow = "ellipsis" + return text + + def _keyline(self) -> Text: + """The keys, as many of them as the window is wide enough to hold. + + Dropped in a deliberate order rather than truncated: an ellipsis in + the middle of the line hides whichever keys happen to be at the end, + and "q quit" is the one nobody can afford not to be told. + """ + items = [("↑↓", "move"), ("⏎", "play"), + ("space", "stop"), ("/", "search"), ("t", "read"), + ("S I N", "file"), ("d", "delete"), ("m", "mask"), + ("s", f"sort by {self.sort}"), ("r", "reload"), + ("?", "keys"), ("q", "quit")] + # Everything given up here is still on the page ? opens, which is why + # ? is among the last to go. + expendable = ["r", "s", "space", "m", "d", "S I N", "t", "/", + "⏎", "↑↓", "?"] + width = self.console.size.width or 80 + shown = items + while expendable and sum(len(k) + len(w) + 4 + for k, w in shown) - 3 > width: + gone = expendable.pop(0) + shown = [item for item in shown if item[0] != gone] + text = Text(no_wrap=True, overflow="ellipsis") + for i, (key, what) in enumerate(shown): + if i: + text.append(" ") + text.append(key, style="bright_black") + text.append(" " + what) + return text + def _footer(self) -> Text: - # Both of these carry text that did not come from this program -- what + # All of these carry text that did not come from this program -- what # the user typed, a filename, a player's error -- and rich reads a # square bracket as markup. Typing "[/" at the search prompt used to # end the session with a MarkupError. + if self.confirm: + return self._line( + f"[bold yellow]{escape(self.confirm)}[/bold yellow] " + "[bold]y[/bold][bright_black]/[/bright_black][bold]n[/bold]") if self.searching: - return Text.from_markup( + return self._line( f"[bold]search:[/bold] {escape(self.query)}[blink]_[/blink]" " [bright_black]enter to accept, esc to clear" "[/bright_black]") if self.message: - return Text.from_markup(f"[yellow]{escape(self.message)}[/yellow]") + return self._line(f"[yellow]{escape(self.message)}[/yellow]") if self.player.active and self.player.playing is not None: total = self.player.playing.duration done = self.player.elapsed @@ -864,25 +1132,27 @@ class Browser: filled = int(width * min(1.0, done / total)) if total else 0 bar = ("[red]" + "━" * filled + "[/red]" + "[grey37]" + "━" * (width - filled) + "[/grey37]") - return Text.from_markup( + return self._line( f"[bold red]♪[/bold red] {bar} {_dur(done)}/{_dur(total)}" " [bright_black]space stop[/bright_black]") - return Text.from_markup( - "[bright_black]↑↓[/bright_black] move " - "[bright_black]⏎[/bright_black] play " - "[bright_black]space[/bright_black] stop " - "[bright_black]/[/bright_black] search " - "[bright_black]t[/bright_black] read " - "[bright_black]s[/bright_black] sort by " - f"{self.sort} " - "[bright_black]r[/bright_black] reload " - "[bright_black]?[/bright_black] keys " - "[bright_black]q[/bright_black] quit") + return self._keyline() def _help(self) -> Panel: + """Every key, on one screen, in a window the size a terminal opens at. + + Eighty by twenty-four is still what a new terminal is, and a list of + keys that has to be scrolled to reach "q" is a list that failed at the + one job it has. So it is kept to twenty rows that fit in sixty + columns: the three filing keys share a line, because the directories + they name say what each one does, and the player goes in the border + rather than costing a row. + """ t = Table(box=None, show_header=False, pad_edge=False) t.add_column(style="bold cyan", width=14) t.add_column() + filing = " ".join(key for key, _, _ in FILING) + names = ", ".join(f"{name}/" for _, name, _ in FILING[:-1]) + names += f" or {FILING[-1][1]}/" for key, what in ( ("↑ ↓ / k j", "move through the recordings"), ("PgUp PgDn", "a screenful at a time"), @@ -890,24 +1160,28 @@ class Browser: ("Enter", "play the highlighted recording"), ("space", "stop playing"), ("t", "read the whole transcript, full screen"), - ("/", "filter by frequency, name, identification or " - "anything that was said"), - ("", ""), - ("callsigns", "found in the transcript and looked up " - "automatically; --no-lookup keeps it offline"), + ("/", "filter by frequency, name, class or anything said"), ("Esc", "clear the filter"), ("s", "sort by time, frequency or length"), ("r", "re-read the directory"), ("o", "print the file's path and quit"), + ("", ""), + (filing, f"file it into {names}"), + ("u", "put the last one filed back"), + ("d", "delete it and its sidecars for good; asks first"), + ("m", "lock this frequency out of every later scan"), + ("", ""), + ("callsigns", "found in transcripts and looked up for you"), ("? h", "this list"), ("q", "quit")): t.add_row(key, what) player = (" ".join(self.player.command) if self.player.command else "none found -- install pw-play, paplay, aplay, " "sox or ffmpeg") - t.add_row("", "") - t.add_row("player", player) return Panel(t, title="keys", title_align="left", + subtitle=f"[bright_black]player: {escape(player)}" + "[/bright_black]", + subtitle_align="right", border_style="cyan", padding=(1, 2)) def _reader(self) -> Panel: @@ -981,6 +1255,8 @@ class Browser: if not key: return True self.message = "" + if self.confirm: + return self._handle_confirm(key) if self.searching: return self._handle_search(key) if self.show_help and key not in ("?", "h", "q"): @@ -1043,6 +1319,33 @@ class Browser: if cap is not None: self.message = str(cap.path) return False + elif key in _FILING_KEYS: + self._file_into(_FILING_KEYS[key]) + elif key == "u": + self._undo_move() + elif key == "m": + self._mask() + elif key == "d": + cap = self.current + if cap is None: + return True + # Asked rather than done. Filing is reversible and is not worth + # a question every time; this one is not reversible at all, and + # d is next to s and f on the same row of the keyboard. + others = len(cap.files()) - 1 + self.confirm = "delete " + cap.path.name + ( + f" and {others} sidecar file{'' if others == 1 else 's'}?" + if others else "?") + return True + + def _handle_confirm(self, key: str) -> bool: + """One question, one answer. Anything but yes means no.""" + question, self.confirm = self.confirm, "" + if key not in ("y", "Y"): + self.message = "left alone" + return True + if question.startswith("delete "): + self._delete() return True def _handle_reader(self, key: str) -> bool: diff --git a/bandsaunter/tui.py b/bandsaunter/tui.py index 9466e11..cbcdaf8 100644 --- a/bandsaunter/tui.py +++ b/bandsaunter/tui.py @@ -599,7 +599,12 @@ q stop the scan p pause and resume s skip the signal being recorded and carry on sweeping l lock out this frequency for the rest of the run -+/- raise or lower the squelch threshold on the fly"""), ++/- raise or lower the squelch threshold on the fly + +saunterbrowse writes to the same lock-out list afterwards: pressing m over a +recording locks out the frequency it was heard on, which is usually when you +find out a frequency is not worth listening to. It can also file recordings +into saved/, investigate/ or noise/, and delete the ones worth nothing."""), } diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index c6d249c..fba8a1c 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-man.py -- do not edit by hand. -.TH BANDSAUNTER 1 "2026-08-28" "bandsaunter 2026-08-28_03" "User Commands" +.TH BANDSAUNTER 1 "2026-08-29" "bandsaunter 2026-08-29_01" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS diff --git a/packaging/make-browse-man.py b/packaging/make-browse-man.py index b69fe65..bde4662 100755 --- a/packaging/make-browse-man.py +++ b/packaging/make-browse-man.py @@ -13,7 +13,15 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import bandsaunter # noqa: E402 -from bandsaunter.browse import PLAYERS, SORTS # noqa: E402 +from bandsaunter.browse import FILING, PLAYERS, SORTS # noqa: E402 + + +def _english(items) -> str: + """a, b and c -- the way a sentence lists things.""" + items = list(items) + if len(items) < 2: + return "".join(items) + return ", ".join(items[:-1]) + " and " + items[-1] PAGE = r'''.\" Generated by packaging/make-browse-man.py -- do not edit by hand. @@ -52,8 +60,10 @@ With no it opens the one the scanner writes to, taken from your saved settings, so it normally needs no arguments at all. .PP -It only ever reads. Nothing in the recordings directory is renamed, moved or -deleted. +Recordings can also be dealt with as they are read. A key files one into +{filing_dirs}, another deletes it outright, and another locks its frequency +out of every later scan. Nothing else is written: without one of those keys +the browser only reads. .SH KEYS .TP .B "Up Down k j" @@ -95,6 +105,27 @@ it, and this picks up what has arrived since. Print the highlighted recording's path and quit, for piping into something else. .TP +.B "{filing_keys}" +File the highlighted recording into {filing_list} respectively \[em] the +recording and every sidecar written beside it, together. See +.B MANAGING THE RECORDINGS +below. +.TP +.B u +Put the last recording that was filed back where it came from. One level +only, and a delete cannot be undone this way. +.TP +.B d +Delete the highlighted recording and its sidecars for good. It asks first: +this is the one key here that cannot be taken back. +.TP +.B m +Lock the highlighted recording's frequency out, so that no later scan stops +on it again. The frequency is written into your saved settings, the same list +.BR bandsaunter (1) +maintains, and takes effect on the next scan \[em] a scan already running read +its settings when it started. +.TP .B "? h" The list of keys, and which audio player was found. .TP @@ -225,6 +256,45 @@ KML is the format Google Earth uses. .BR marble (1) and OsmAnd open it too, and it is XML, so a scan interrupted halfway through leaves a file that still opens. +.SH MANAGING THE RECORDINGS +A night's scan leaves hundreds of files, most of which are worth nothing and +a few of which are the reason you left it running. Deciding which is which is +what this program is for, and the keys that act on a recording are meant to +be pressed once each, going down the list. +{filing_prose} +.PP +Each of these moves the whole capture \[em] the +.IR .wav , +the JSON sidecar, the raw IQ where it was kept, the transcript and the decoded +data \[em] because a recording in one directory and its transcript in another +is a pair nothing will ever put back together. If the move cannot be finished, +whatever has already moved is put back: half a capture in each of two places +is worse than none moved at all. +.PP +The subdirectories are ordinary directories inside the recordings directory, +so a scan writing there never looks into them and never lists what is in +them. To read what is in one, point the browser at it: +.IP +.EX +saunterbrowse ~/bandsaunter/{first_dir} +.EE +.PP +.B u +puts the last one filed back. One step, not a history: it exists so that a +mistyped key costs nothing, not so that an evening's sorting can be unwound. +.PP +.B d +deletes instead, and asks first, because nothing puts that back. +.PP +.B m +is the other half of the same job. A birdie, a pager transmitter or a data +link that fills the recordings directory night after night is not a recording +problem, it is a scanning problem, and this writes the frequency into the +lock-out list in your settings file. The width comes from the +.B lockout_width +setting, so a lock-out is a channel rather than a single point. Locking out a +frequency does not delete what has already been recorded on it \[em] the two +keys are separate on purpose, and pressing both is the usual thing to do. .SH DECODED DATA Where a capture carried data rather than speech, what was decoded takes the place of the transcript at the top of the screen: the kind of packet, and then @@ -270,6 +340,15 @@ What was said, where a recogniser heard speech. .TP .IR ... _data.txt What was decoded, where the capture carried data. +.TP +.IR {filing_files} +Where the filing keys move recordings to. Created on first use; a scan never +looks in them. +.TP +.I ~/.config/bandsaunter/config.yaml +The settings the +.B m +key writes a locked-out frequency into. .SH ENVIRONMENT .TP .B BANDSAUNTER_OUTPUT @@ -328,11 +407,20 @@ to pick up recordings a running scan has written since. def main() -> int: + dirs = [name for _, name, _ in FILING] text = PAGE.format( date=date.today().isoformat(), version=bandsaunter.__version__, sorts=", ".join(SORTS), - players=", ".join(name for name, _ in PLAYERS)) + players=", ".join(name for name, _ in PLAYERS), + filing_keys=" ".join(key for key, _, _ in FILING), + filing_dirs=_english(f"{name}/" for name in dirs), + filing_list=_english(f"{name}/" for name in dirs), + filing_files=", ".join(f"{name} /" for name in dirs), + first_dir=dirs[0], + filing_prose="\n".join( + f".TP\n.B {key}\nInto\n.IR {name} /\n\\[em] {why}." + for key, name, why in FILING)) text = text.replace("\n\n", "\n") # troff dislikes blank lines target = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent / "saunterbrowse.1") diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 index 9d2dc27..ba8874b 100644 --- a/packaging/saunterbrowse.1 +++ b/packaging/saunterbrowse.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-browse-man.py -- do not edit by hand. -.TH SAUNTERBROWSE 1 "2026-08-28" "bandsaunter 2026-08-28_03" "User Commands" +.TH SAUNTERBROWSE 1 "2026-08-29" "bandsaunter 2026-08-29_01" "User Commands" .SH NAME saunterbrowse \- read and listen to what a bandsaunter scan collected .SH SYNOPSIS @@ -34,8 +34,10 @@ With no it opens the one the scanner writes to, taken from your saved settings, so it normally needs no arguments at all. .PP -It only ever reads. Nothing in the recordings directory is renamed, moved or -deleted. +Recordings can also be dealt with as they are read. A key files one into +saved/, investigate/ and noise/, another deletes it outright, and another locks its frequency +out of every later scan. Nothing else is written: without one of those keys +the browser only reads. .SH KEYS .TP .B "Up Down k j" @@ -77,6 +79,27 @@ it, and this picks up what has arrived since. Print the highlighted recording's path and quit, for piping into something else. .TP +.B "S I N" +File the highlighted recording into saved/, investigate/ and noise/ respectively \[em] the +recording and every sidecar written beside it, together. See +.B MANAGING THE RECORDINGS +below. +.TP +.B u +Put the last recording that was filed back where it came from. One level +only, and a delete cannot be undone this way. +.TP +.B d +Delete the highlighted recording and its sidecars for good. It asks first: +this is the one key here that cannot be taken back. +.TP +.B m +Lock the highlighted recording's frequency out, so that no later scan stops +on it again. The frequency is written into your saved settings, the same list +.BR bandsaunter (1) +maintains, and takes effect on the next scan \[em] a scan already running read +its settings when it started. +.TP .B "? h" The list of keys, and which audio player was found. .TP @@ -207,6 +230,59 @@ KML is the format Google Earth uses. .BR marble (1) and OsmAnd open it too, and it is XML, so a scan interrupted halfway through leaves a file that still opens. +.SH MANAGING THE RECORDINGS +A night's scan leaves hundreds of files, most of which are worth nothing and +a few of which are the reason you left it running. Deciding which is which is +what this program is for, and the keys that act on a recording are meant to +be pressed once each, going down the list. +.TP +.B S +Into +.IR saved / +\[em] keep this one. +.TP +.B I +Into +.IR investigate / +\[em] come back to this one. +.TP +.B N +Into +.IR noise / +\[em] not a signal worth keeping. +.PP +Each of these moves the whole capture \[em] the +.IR .wav , +the JSON sidecar, the raw IQ where it was kept, the transcript and the decoded +data \[em] because a recording in one directory and its transcript in another +is a pair nothing will ever put back together. If the move cannot be finished, +whatever has already moved is put back: half a capture in each of two places +is worse than none moved at all. +.PP +The subdirectories are ordinary directories inside the recordings directory, +so a scan writing there never looks into them and never lists what is in +them. To read what is in one, point the browser at it: +.IP +.EX +saunterbrowse ~/bandsaunter/saved +.EE +.PP +.B u +puts the last one filed back. One step, not a history: it exists so that a +mistyped key costs nothing, not so that an evening's sorting can be unwound. +.PP +.B d +deletes instead, and asks first, because nothing puts that back. +.PP +.B m +is the other half of the same job. A birdie, a pager transmitter or a data +link that fills the recordings directory night after night is not a recording +problem, it is a scanning problem, and this writes the frequency into the +lock-out list in your settings file. The width comes from the +.B lockout_width +setting, so a lock-out is a channel rather than a single point. Locking out a +frequency does not delete what has already been recorded on it \[em] the two +keys are separate on purpose, and pressing both is the usual thing to do. .SH DECODED DATA Where a capture carried data rather than speech, what was decoded takes the place of the transcript at the top of the screen: the kind of packet, and then @@ -252,6 +328,15 @@ What was said, where a recogniser heard speech. .TP .IR ... _data.txt What was decoded, where the capture carried data. +.TP +.IR saved /, investigate /, noise / +Where the filing keys move recordings to. Created on first use; a scan never +looks in them. +.TP +.I ~/.config/bandsaunter/config.yaml +The settings the +.B m +key writes a locked-out frequency into. .SH ENVIRONMENT .TP .B BANDSAUNTER_OUTPUT diff --git a/tests/conftest.py b/tests/conftest.py index e10e17c..b3bde23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,39 @@ """Fixtures every test gets. -The one that matters is the cache: a lookup writes to ``~/.cache`` by -default, and a test run that touches the real one leaves entries behind and -reads back entries an earlier version wrote. Redirecting it per test makes -each run start from nothing. +Both of them are about not touching the machine the tests run on. + +The cache: a lookup writes to ``~/.cache`` by default, and a test run that +touches the real one leaves entries behind and reads back entries an earlier +version wrote. Redirecting it per test makes each run start from nothing. + +The settings: locking a frequency out writes it into ``config.yaml``, and a +test that reached the real one would silently change what the next real scan +does. The constant is replaced in every module that holds a copy, so that +forgetting to pass a directory somewhere cannot end in someone's own settings. """ import pytest +import bandsaunter.browse +import bandsaunter.cli +import bandsaunter.config +import bandsaunter.tui + @pytest.fixture(autouse=True) def isolated_cache(tmp_path_factory, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path_factory.mktemp("cache"))) + + +@pytest.fixture(autouse=True) +def isolated_settings(tmp_path_factory, monkeypatch): + where = tmp_path_factory.mktemp("config") + for module in (bandsaunter.config, bandsaunter.browse, bandsaunter.cli, + bandsaunter.tui): + if hasattr(module, "DEFAULT_CONFIG_DIR"): + monkeypatch.setattr(module, "DEFAULT_CONFIG_DIR", where) + if hasattr(module, "DEFAULT_CONFIG_PATH"): + monkeypatch.setattr(module, "DEFAULT_CONFIG_PATH", + where / "config.yaml") + monkeypatch.setenv("BANDSAUNTER_CONFIG_DIR", str(where)) + return where diff --git a/tests/terminal.py b/tests/terminal.py index d12b65c..733e920 100644 --- a/tests/terminal.py +++ b/tests/terminal.py @@ -22,6 +22,12 @@ class Terminal: """One program running in a pty, with a screen that can be read back.""" def __init__(self, argv, cols=100, rows=30, env=None): + """``env`` is added to the child's environment, not substituted for it. + + It was accepted and silently ignored until a test that pointed the + program at a throwaway settings directory wrote to the real one + instead. + """ import pyte self.cols, self.rows = cols, rows @@ -35,7 +41,7 @@ class Terminal: self.pid, self.fd = pty.fork() if self.pid == 0: # pragma: no cover -- the child child = dict(os.environ, TERM="xterm-256color", - PYTHONUNBUFFERED="1") + PYTHONUNBUFFERED="1", **(env or {})) # rich reads COLUMNS and LINES in preference to asking the # terminal, so leaving them set would make every resize invisible # and every one of these tests pass for the wrong reason. diff --git a/tests/test_manage.py b/tests/test_manage.py new file mode 100644 index 0000000..0c69e0d --- /dev/null +++ b/tests/test_manage.py @@ -0,0 +1,560 @@ +"""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" diff --git a/tests/test_manpage.py b/tests/test_manpage.py index 6057989..4970291 100644 --- a/tests/test_manpage.py +++ b/tests/test_manpage.py @@ -105,9 +105,13 @@ def test_every_browser_flag_is_documented(browse_page): def test_every_browser_key_is_documented(browse_page): """A key that does something the manual does not mention is a key nobody will press.""" + from bandsaunter.browse import FILING for key in ("Enter", "Space", "PgUp", "Home", "/", "s", "r", "o", "q", - "t"): - assert key in browse_page, key + "t", "u", "d", "m"): + assert f".B {key}\n" in browse_page or f'.B "{key}' in browse_page, key + for _key, name, _why in FILING: + assert name in browse_page, name + assert '.B "' + " ".join(k for k, _, _ in FILING) in browse_page def test_the_browser_page_names_the_players_it_looks_for(browse_page):