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
193 lines
7.0 KiB
Python
193 lines
7.0 KiB
Python
"""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
|