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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
d6ae6d0c22
commit
f9f0d94000
11 changed files with 1162 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
560
tests/test_manage.py
Normal file
560
tests/test_manage.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue