"""Resizing the window, and the debris it used to leave behind. Live rendering moves the cursor back over the frame it drew last time and overwrites it. That is only correct while the frame is still where it was put, and resizing the window reflows everything on the screen, so what is left is half of the old frame in pieces -- box corners in the middle of a line, a border twice the width of the window, a header printed eight times. Two kinds of test here. The first are arithmetic: every frame has to be exactly as tall as the terminal, or never taller, because that is the property the overwriting depends on and it can be checked without a terminal at all. The second run the programs in a real pty and resize them. """ import os import sys import tempfile from pathlib import Path import numpy as np import pytest from rich.console import Console sys.path.insert(0, str(Path(__file__).parent)) from terminal import Terminal, stray_rows # noqa: E402 from bandsaunter.browse import Browser, Player # noqa: E402 from bandsaunter.config import ScanConfig # noqa: E402 from bandsaunter.ranges import parse_range_list # noqa: E402 from bandsaunter.recorder import HitRecord # noqa: E402 from bandsaunter.scanner import Scanner # noqa: E402 from bandsaunter.simulator import SimulatedDevice # noqa: E402 from bandsaunter.ui import ScanDisplay # noqa: E402 pyte = pytest.importorskip("pyte", reason="terminal emulator not installed") SIZES = [(120, 40), (100, 30), (100, 24), (90, 20), (80, 24), (70, 18), (64, 16), (60, 12), (50, 10), (40, 8), (200, 60)] # --------------------------------------------------------------------------- # The arithmetic the overwriting depends on # --------------------------------------------------------------------------- def _scan_display(width: int, height: int, busy: bool = True) -> ScanDisplay: console = Console(width=width, height=height, force_terminal=True, file=open(os.devnull, "w")) cfg = ScanConfig(ranges=parse_range_list("144M-148M"), output_dir=tempfile.mkdtemp()) scanner = Scanner(cfg, device=SimulatedDevice().open()) scanner.prepare() display = ScanDisplay(scanner, console=console) display.attach() if busy: step = scanner.plan[3] display.on_step(3, 6, step, np.full(1024, -70.0), np.linspace(step.low, step.high, 1024)) display._rec.active = True display._rec.frequency = 146.520555e6 display._rec.mode = "nfm" display._rec.present = True display._rec.snr = 50.4 display._rec.elapsed = 3.0 display.on_status("decoded 146.52 MHz: a status line long enough to " "run off the end of a narrow window") for i in range(8): hit = HitRecord(frequency=146e6 + i * 1e5, started_at=0, duration=5.0, snr_db=20.0, classification="Narrowband FM voice " "(CTCSS 100.0 Hz)") display.hits.appendleft(hit) return display def _rendered_height(renderable, width: int, height: int) -> int: probe = Console(width=width, height=height, force_terminal=True, record=True, file=open(os.devnull, "w")) probe.print(renderable) return len(probe.export_text().rstrip("\n").split("\n")) @pytest.mark.parametrize("width,height", SIZES) @pytest.mark.parametrize("busy", [False, True]) def test_the_scan_frame_never_outgrows_the_terminal(width, height, busy): """A frame taller than the window cannot be redrawn where it was drawn.""" display = _scan_display(width, height, busy) drawn = _rendered_height(display.render(), width, height) assert drawn <= height, f"{width}x{height}: frame is {drawn} lines" @pytest.mark.parametrize("width,height", SIZES) def test_the_frame_is_exactly_as_tall_as_the_layout_says(width, height): """The number the whole thing depends on, checked against reality. The layout counts each panel as a fixed number of lines when it decides what fits, and rich moves the cursor back over exactly that many. A model one line out overflows by one line on every refresh, and the top of the frame marches down the screen -- which is what a column of "receiver" headers was. Two ways to be wrong: a panel counted at the wrong height, or a long band name or status message wrapping inside one. """ display = _scan_display(width, height) probe = Console(width=width, height=400, force_terminal=True, record=True, file=open(os.devnull, "w")) probe.print(display.render()) lines = probe.export_text().rstrip("\n").split("\n") assert len(lines) == display._layout_height(), \ "the frame is not the height the layout budgeted for" @pytest.mark.parametrize("width,height", SIZES) @pytest.mark.parametrize("view", ["list", "reader", "help"]) def test_every_browser_screen_fills_the_terminal_exactly(width, height, view, tmp_path): """Nothing erases the alternate screen between frames. The cursor is sent home and the new frame written over the old one, so a frame shorter than the screen leaves the tail of the last one visible underneath it. """ console = Console(width=width, height=height, force_terminal=True, file=open(os.devnull, "w")) browser = Browser(tmp_path, console=console, player=Player([])) browser.reading = view == "reader" browser.show_help = view == "help" drawn = _rendered_height(browser.render(), width, height) assert drawn == height, f"{width}x{height} {view}: {drawn} lines" # --------------------------------------------------------------------------- # Noticing the change # --------------------------------------------------------------------------- class _Resizable(Console): """A console whose size can be changed the way a window can.""" def __init__(self, width, height): super().__init__(width=width, height=height, force_terminal=True, file=open(os.devnull, "w")) def become(self, width, height): self.width = width self.height = height def test_a_size_change_is_noticed_once_and_only_once(): console = _Resizable(100, 30) display = _scan_display(100, 30, busy=False) display.console = console display._size = console.size assert not display.resized(), "reported a resize that never happened" console.become(70, 20) assert display.resized(), "did not notice the window changing" assert not display.resized(), "reported the same resize twice" console.become(70, 40) assert display.resized(), "a change in height is a change" def test_the_browser_notices_a_size_change_too(tmp_path): console = _Resizable(100, 30) browser = Browser(tmp_path, console=console, player=Player([])) assert not browser.resized() console.become(64, 18) assert browser.resized() assert not browser.resized() # --------------------------------------------------------------------------- # In a real terminal # --------------------------------------------------------------------------- def _scan_argv(directory: Path) -> list[str]: return [sys.executable, "-m", "bandsaunter", "scan", "--simulate", "--no-config", "-r", "144M-148M", "-o", str(directory), "--duration", "90"] @pytest.mark.parametrize("after", [(70, 20), (120, 40), (60, 12), (100, 30)]) def test_resizing_a_running_scan_leaves_nothing_behind(tmp_path, after): cols, rows = after with Terminal(_scan_argv(tmp_path), cols=100, rows=30) as term: term.pump(4.0) term.resize(cols, rows) term.pump(3.0) lines = term.display() assert any("sweep" in line for line in lines), \ "the display did not redraw at all" stray = stray_rows(lines, cols) assert not stray, "left behind:\n" + "\n".join(stray) def test_a_scan_survives_being_resized_over_and_over(tmp_path): """A window being dragged sends a great many of these in a row.""" with Terminal(_scan_argv(tmp_path), cols=110, rows=34) as term: term.pump(3.0) for cols, rows in ((90, 28), (70, 20), (64, 16), (100, 30), (120, 40), (80, 24)): term.resize(cols, rows) term.pump(0.6) term.pump(2.0) lines = term.display() stray = stray_rows(lines, term.cols) assert not stray, "left behind:\n" + "\n".join(stray) def test_the_header_is_never_drawn_more_than_once(tmp_path): """It used to march down the screen, one copy per refresh.""" with Terminal(_scan_argv(tmp_path), cols=100, rows=30) as term: term.pump(4.0) term.resize(78, 22) term.pump(3.0) headers = [ln for ln in term.display() if " receiver " in ln] assert len(headers) <= 1, f"{len(headers)} copies of the header" def _browse_argv(directory: Path) -> list[str]: return [sys.executable, "-m", "bandsaunter.browse", str(directory)] @pytest.fixture def library(tmp_path): """A directory with something in it for the browser to show.""" import json import wave stem = "0146.520000MHz--2026-08-22_10_00_00-nfm" with wave.open(str(tmp_path / f"{stem}.wav"), "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(16000) w.writeframes(b"\0\0" * 16000) (tmp_path / f"{stem}_transcription.txt").write_text( "Net control, this is W1AW. " * 30) (tmp_path / f"{stem}.json").write_text(json.dumps({"hit": { "frequency": 146.52e6, "started_at": 1755856800.0, "category": "voice", "classification": "Narrowband FM voice", "confidence": 0.88, "mode": "nfm", "duration": 1.0}})) return tmp_path @pytest.mark.parametrize("after", [(70, 20), (130, 44), (60, 10)]) def test_resizing_the_browser_leaves_nothing_behind(library, after): cols, rows = after with Terminal(_browse_argv(library), cols=100, rows=30) as term: term.pump(2.5) term.resize(cols, rows) term.pump(2.0) lines = term.display() stray = stray_rows(lines, cols) assert not stray, "left behind:\n" + "\n".join(stray) def test_the_reader_covers_the_list_it_was_opened_from(library): """Opening a full-screen view must not leave the list showing under it.""" with Terminal(_browse_argv(library), cols=100, rows=30) as term: term.pump(2.5) term.send("t") term.pump(1.5) text = term.text() assert "transcript" in text assert "recordings in" not in text, \ "the list is still visible under the reader" def test_the_help_screen_covers_the_list_too(library): with Terminal(_browse_argv(library), cols=100, rows=30) as term: term.pump(2.5) term.send("?") term.pump(1.5) text = term.text() assert "move through the recordings" in text assert "recordings in" not in text