bandsaunter/tests/test_ui.py
The Dust Council db3e0c79b9 Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:20 -07:00

147 lines
5.6 KiB
Python

"""The live display, and keeping the driver from writing over it."""
import os
import subprocess
import sys
import tempfile
import time
import numpy as np
import pytest
from rich.console import Console
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
from bandsaunter.recorder import HitRecord
from bandsaunter.scanner import Scanner
from bandsaunter.simulator import SimulatedDevice
from bandsaunter.ui import ScanDisplay, _sparkline
def _display(height: int, hits: int, recording: bool) -> ScanDisplay:
console = Console(width=100, height=height,
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()
step = scanner.plan[0]
display.on_step(0, 6, step, np.full(1024, -70.0),
np.linspace(step.low, step.high, 1024))
for i in range(hits):
display.hits.append(HitRecord(
frequency=146e6 + i * 1e5, started_at=time.time(), duration=5.0,
snr_db=20.0, classification="FM broadcast station (stereo)"))
display._rec.active = recording
display._rec.frequency = 146.52e6
display._rec.mode = "nfm"
return display
def _rendered_height(display: ScanDisplay) -> int:
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
record=True)
probe.print(display.render())
return len(probe.export_text().rstrip("\n").split("\n"))
@pytest.mark.parametrize("height", [16, 20, 24, 30, 40, 60])
@pytest.mark.parametrize("hits,recording", [(0, False), (3, False),
(12, False), (12, True)])
def test_the_display_never_outgrows_the_terminal(height, hits, recording):
"""A frame taller than the terminal cannot be redrawn in place.
Every refresh then scrolls another copy into the scrollback, which is why
the header ends up on screen several times over.
"""
display = _display(height, hits, recording)
assert _rendered_height(display) <= height, \
f"{hits} hits, rec={recording}: overflowed a {height}-line terminal"
def test_older_hits_are_dropped_not_the_layout():
"""When space runs short the list shortens; the panels stay."""
display = _display(20, 12, True)
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
record=True)
probe.print(display.render())
text = probe.export_text()
assert "receiver" in text and "sweep" in text
assert "more above" in text, "no sign that hits were trimmed"
def test_a_tall_terminal_shows_every_hit():
display = _display(60, 12, False)
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
record=True)
probe.print(display.render())
assert "more above" not in probe.export_text()
def test_sparkline_keeps_narrow_carriers_visible():
values = np.full(200, -70.0)
values[100] = -20.0
assert "" in _sparkline(values, 60)
# ---------------------------------------------------------------------------
# The driver writes its own messages straight to file descriptor 2
# ---------------------------------------------------------------------------
def test_quiet_driver_suppresses_writes_to_fd_2():
"""librtlsdr prints from C, so Python-level redirection cannot catch it."""
code = (
"import os, sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"with quiet_driver():\n"
" os.write(2, b'CHATTER\\n')\n"
"os.write(2, b'AFTERWARDS\\n')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30)
assert "CHATTER" not in out.stderr
assert "AFTERWARDS" in out.stderr, "stderr was not restored"
def test_quiet_driver_restores_on_an_exception():
code = (
"import os, sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"try:\n"
" with quiet_driver():\n"
" raise ValueError('boom')\n"
"except ValueError:\n"
" pass\n"
"os.write(2, b'RESTORED\\n')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30)
assert "RESTORED" in out.stderr
def test_driver_messages_can_be_turned_back_on_for_debugging():
code = (
"import os, sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"with quiet_driver():\n"
" os.write(2, b'CHATTER\\n')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
env = dict(os.environ, BANDSAUNTER_DRIVER_MESSAGES="1")
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30, env=env)
assert "CHATTER" in out.stderr
def test_python_errors_still_reach_stderr():
"""Silencing the driver must not swallow a traceback."""
code = (
"import sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"with quiet_driver():\n"
" pass\n"
"raise RuntimeError('visible')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30)
assert "visible" in out.stderr