Add radar scope, splash screen, Star Wars easter egg, and harden TUI

New features:
- P1 green phosphor radar scope widget on Track screen with LUT-optimized rendering
- Splash screen with pre-baked ANSI half-block art from 16colo.rs/Mistigris
- Star Wars ASCII animation via telnet (ctrl+w) with IPv6 happy eyeballs and offline fallback
- Dark Side / New Hope toast notifications on theme toggle
- Kitty terminal detection with cat emoji on splash

Robustness (from Apollo code review):
- Circuit breaker in track loop after 10 consecutive errors
- Input validation for frequency, symbol rate, step size across scan/spectrum/track
- Consolidated sys.path manipulation into __init__.py
- Radar scope pre-computes dist/angle LUT per pixel on resize

Cleanup:
- Removed unused imports across lband, monitor, scan, signal_gauge
- Moved Pillow/textual-image to optional dev deps (splash uses pre-baked ANSI)
- Added 41-test pytest suite covering telnet IAC parsing, radar geometry, splash assets
This commit is contained in:
Ryan Malloy 2026-02-14 09:51:58 -07:00
parent 8da486719a
commit 6dcb6b693a
33 changed files with 2076 additions and 86 deletions

0
tui/tests/__init__.py Normal file
View file

View file

@ -0,0 +1,192 @@
"""Tests for the radar scope widget — LUT geometry and pixel intensity."""
from skywalker_tui.widgets.radar_scope import RadarScope
class TestRadarGeometry:
"""LUT pre-computation and geometry tests."""
def _make_scope(self, width=40, height=20):
scope = RadarScope()
scope._recompute_geometry(width, height)
return scope
def test_lut_dimensions(self):
scope = self._make_scope(40, 20)
# pixel rows = terminal rows * 2
assert len(scope._dist_lut) == 40
assert len(scope._angle_lut) == 40
assert len(scope._dx_lut) == 40
assert len(scope._dy_lut) == 40
# each row has `width` columns
assert len(scope._dist_lut[0]) == 40
assert len(scope._angle_lut[0]) == 40
def test_center_pixel_distance_near_zero(self):
scope = self._make_scope(40, 20)
# center = (20, 20) in pixel coords
cx_int = int(scope._cx)
cy_int = int(scope._cy)
dist = scope._dist_lut[cy_int][cx_int]
assert dist < 1.0, f"Center pixel distance should be ~0, got {dist}"
def test_corner_pixel_outside_circle(self):
scope = self._make_scope(40, 20)
dist = scope._dist_lut[0][0]
assert dist > scope._radius, "Corner should be outside the radar circle"
def test_radius_fits_within_bounds(self):
scope = self._make_scope(60, 30)
# radius should be ≤ half the smaller dimension
assert scope._radius <= 30 # half of width
assert scope._radius <= 29 # half of pixel height (60) - 1
def test_recompute_updates_on_resize(self):
scope = self._make_scope(40, 20)
r1 = scope._radius
scope._recompute_geometry(80, 40)
r2 = scope._radius
assert r2 > r1, "Larger terminal should give larger radius"
class TestPixelIntensity:
"""Tests for _pixel_intensity with pre-computed LUT values."""
def _make_scope(self, width=40, height=20):
scope = RadarScope()
scope._recompute_geometry(width, height)
return scope
def test_outside_circle_returns_zero(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius + 5,
sample_idx=0, dx=20.0, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 0
def test_center_on_crosshair(self):
"""At exact center, crosshair check (abs(dx) < 0.7) fires before center dot."""
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=0.5, sample_idx=0, dx=0.0, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 2 # crosshair overlaps center
def test_center_off_crosshair(self):
"""Near center but off crosshair axes → center dot (1)."""
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=0.8, sample_idx=45, dx=0.8, dy=0.8,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 1 # center dot, off crosshair axes
def test_lock_ring_at_edge(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=True,
)
assert result == 7 # peak phosphor for lock ring
def test_no_lock_ring_when_unlocked(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
# Should be boundary ring (2), not lock ring (7)
assert result != 7
def test_crosshair_on_vertical(self):
scope = self._make_scope()
samples = [0.0] * 360
# Point on the vertical crosshair (dx near 0, inside circle)
result = scope._pixel_intensity(
dist=scope._radius * 0.5,
sample_idx=90, dx=0.0, dy=scope._radius * 0.5,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 2 # crosshair intensity
def test_strong_signal_blip_near_sweep(self):
scope = self._make_scope()
samples = [0.0] * 360
# strength 0.47 → blip at 0.47 * r * 0.85 = 0.40*r
# Clear of range rings at 0.25r, 0.50r, 0.75r (nearest gap: 0.15r ≈ 2.85px)
samples[180] = 0.47
signal_dist = 0.47 * scope._radius * 0.85
# Verify we're not on a range ring
r = scope._radius
for ring_r in (0.25, 0.5, 0.75):
assert abs(signal_dist - r * ring_r) > 0.7, (
f"Signal blip at {signal_dist:.2f} overlaps range ring at {r * ring_r:.2f}"
)
result = scope._pixel_intensity(
dist=signal_dist,
sample_idx=180, dx=5.0, dy=5.0,
radius=scope._radius, samples=samples,
angle_idx=180, locked=False,
)
# Newest sample at sweep position should be visible
assert result >= 3
def test_old_signal_decays(self):
scope = self._make_scope()
samples = [0.0] * 360
samples[0] = 0.8
signal_dist = 0.8 * scope._radius * 0.85
# Sample at index 0, sweep beam at index 300 → age = 300
result = scope._pixel_intensity(
dist=signal_dist,
sample_idx=0, dx=5.0, dy=5.0,
radius=scope._radius, samples=samples,
angle_idx=300, locked=False,
)
# Old sample should be dim or invisible
assert result <= 2
class TestRadarPush:
"""Tests for the push() method and normalization."""
def test_push_normalizes_to_range(self):
scope = RadarScope(max_samples=10)
scope.push(8.0, max_snr=16.0)
assert scope._samples[-1] == 0.5
def test_push_clamps_above_max(self):
scope = RadarScope(max_samples=10)
scope.push(20.0, max_snr=16.0)
assert scope._samples[-1] == 1.0
def test_push_clamps_below_zero(self):
scope = RadarScope(max_samples=10)
scope.push(-5.0, max_snr=16.0)
assert scope._samples[-1] == 0.0
def test_angle_index_wraps(self):
scope = RadarScope(max_samples=4)
for _ in range(5):
scope.push(1.0)
assert scope._angle_idx == 1 # 5 % 4 = 1
def test_set_locked(self):
scope = RadarScope()
assert scope._locked is False
scope.set_locked(True)
assert scope._locked is True

48
tui/tests/test_splash.py Normal file
View file

@ -0,0 +1,48 @@
"""Tests for the splash screen art catalog and selection."""
from skywalker_tui.screens.splash import SplashScreen, ASSETS_DIR, ART_CATALOG
class TestSplashArtCatalog:
"""Verify bundled pre-baked .ans art assets exist and catalog is consistent."""
def test_assets_dir_exists(self):
assert ASSETS_DIR.is_dir(), f"Assets dir missing: {ASSETS_DIR}"
def test_all_catalog_entries_have_ans_files(self):
missing = []
for stem, artist, title in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if not path.exists():
missing.append(f"{stem}.ans")
assert not missing, f"Missing pre-baked .ans files: {missing}"
def test_catalog_has_entries(self):
assert len(ART_CATALOG) >= 1
def test_ans_files_not_empty(self):
for stem, _, _ in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if path.exists():
assert path.stat().st_size > 0, f"{stem}.ans is empty"
def test_ans_files_contain_ansi_escapes(self):
"""Pre-baked files should contain ANSI color escape sequences."""
for stem, _, _ in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if path.exists():
content = path.read_text()
assert "\033[" in content, f"{stem}.ans has no ANSI escapes"
assert "\u2580" in content, f"{stem}.ans has no half-block chars"
class TestSplashScreenInit:
"""Test SplashScreen construction (no app needed)."""
def test_selects_art_on_init(self):
screen = SplashScreen()
assert screen._ans_path is not None or len(ART_CATALOG) == 0
if screen._ans_path:
assert screen._ans_path.exists()
assert screen._artist != ""
assert screen._title != ""

View file

@ -0,0 +1,108 @@
"""Tests for the stateful telnet IAC sequence stripper."""
from skywalker_tui.screens.starwars import _TelnetStripper
class TestTelnetStripper:
"""Edge cases for IAC parsing across chunk boundaries."""
def test_plain_text_passthrough(self):
s = _TelnetStripper()
assert s.feed(b"hello world") == b"hello world"
def test_strips_will_command(self):
# IAC WILL ECHO = FF FB 01
s = _TelnetStripper()
assert s.feed(b"\xff\xfb\x01hello") == b"hello"
def test_strips_wont_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfc\x03data") == b"data"
def test_strips_do_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfd\x01data") == b"data"
def test_strips_dont_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfe\x01data") == b"data"
def test_escaped_0xff(self):
"""Doubled 0xFF = literal 0xFF byte in content."""
s = _TelnetStripper()
assert s.feed(b"\xff\xff") == b"\xff"
def test_sub_negotiation(self):
# IAC SB 0x01 ... IAC SE = FF FA 01 xx xx FF F0
s = _TelnetStripper()
data = b"before\xff\xfa\x01\x00\x00\xff\xf0after"
assert s.feed(data) == b"beforeafter"
def test_split_iac_across_chunks(self):
"""IAC command split: FF in chunk 1, FB 01 in chunk 2."""
s = _TelnetStripper()
out1 = s.feed(b"hello\xff")
out2 = s.feed(b"\xfb\x01world")
assert out1 == b"hello"
assert out2 == b"world"
def test_split_will_at_boundary(self):
"""IAC WILL split: FF FB in chunk 1, option byte in chunk 2."""
s = _TelnetStripper()
out1 = s.feed(b"aaa\xff\xfb")
out2 = s.feed(b"\x03bbb")
assert out1 == b"aaa"
assert out2 == b"bbb"
def test_split_sub_negotiation(self):
"""Sub-negotiation without closing IAC SE — buffers until next chunk."""
s = _TelnetStripper()
out1 = s.feed(b"x\xff\xfa\x01\x00")
out2 = s.feed(b"\xff\xf0y")
assert out1 == b"x"
assert out2 == b"y"
def test_multiple_commands_in_one_chunk(self):
s = _TelnetStripper()
data = b"\xff\xfb\x01\xff\xfc\x03text\xff\xfd\x01"
assert s.feed(data) == b"text"
def test_empty_input(self):
s = _TelnetStripper()
assert s.feed(b"") == b""
def test_just_iac_byte(self):
"""Single 0xFF byte — should buffer, waiting for next byte."""
s = _TelnetStripper()
out1 = s.feed(b"\xff")
assert out1 == b""
out2 = s.feed(b"\xfb\x01done")
assert out2 == b"done"
def test_unknown_iac_command(self):
"""Unknown command byte after IAC — stripped as 2-byte sequence."""
s = _TelnetStripper()
assert s.feed(b"\xff\xf1text") == b"text"
class TestFrameParsing:
"""Tests for the ESC[H frame boundary detection logic."""
def test_frame_split_on_cursor_home(self):
"""ESC[H splits data into frames."""
data = b"frame1\x1b[Hframe2\x1b[Hframe3"
frames = data.split(b"\x1b[H")
assert frames == [b"frame1", b"frame2", b"frame3"]
def test_esc_j_in_frame(self):
"""ESC[J (clear to end) can be stripped from frame data."""
data = b"\x1b[Jsome text here"
clean = data.replace(b"\x1b[J", b"")
assert clean == b"some text here"
def test_empty_frames_between_homes(self):
"""Consecutive ESC[H produces empty frames (filtered in code)."""
data = b"\x1b[H\x1b[Htext"
frames = data.split(b"\x1b[H")
non_empty = [f for f in frames if f.strip()]
assert non_empty == [b"text"]