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