birdcage/tui/tests/test_demo_craft.py
Ryan Malloy 3013eeee4c Add DemoCraftClient for complete offline demo mode
- DemoCraftClient in demo.py: duck-typed CraftClient replacement with
  8 canned satellites, synthetic pass predictions, and time-varying LEO
  arcs that drive real AOS/TCA/LOS pass events to the camera overlay
- Fix sky map EL signal fidelity: snap _el to _target_el at sweep start
  so 2D scans compute correct per-row RSSI (was using stale elevation)
- Branch on demo_mode in app.py _setup_craft_client() to inject
  DemoCraftClient instead of the HTTP CraftClient
- Add test_demo_craft.py: 5 tests exercising search, passes, tracking,
  and WAITING state through the full TUI without mocks
- Update take_screenshots.py to cover all 8 screens (dashboard, control,
  craft search, craft tracking, signal, system, console, camera)
2026-02-16 11:49:39 -07:00

165 lines
5.3 KiB
Python

"""Test DemoCraftClient — full Craft lifecycle in demo mode.
Unlike test_craft_mode.py which uses MagicMock, these tests run with the
real DemoCraftClient to verify the end-to-end demo experience: search,
pass predictions, and tracking with time-varying satellite positions.
No network calls are made.
"""
import asyncio
import pytest
from birdcage_tui.app import BirdcageApp
from birdcage_tui.screens.control import ControlScreen
from birdcage_tui.widgets.craft_panel import CraftPanel, CraftTrackingStatus
async def _switch_to_craft(pilot, app) -> None:
"""Navigate to F2 Control > Craft sub-mode."""
await pilot.press("f2")
await pilot.pause()
control = app.query_one("#control", ControlScreen)
control.switch_mode("craft")
await pilot.pause()
@pytest.mark.asyncio
async def test_demo_search_returns_results():
"""DemoCraftClient search should populate the DataTable."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await _switch_to_craft(pilot, app)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(CraftPanel.SearchRequested("ISS"))
await asyncio.sleep(0.5)
from textual.widgets import DataTable
table = app.query_one("#craft-results-table", DataTable)
assert table.row_count >= 1
first_key = list(table.rows.keys())[0]
row = table.get_row(first_key)
assert "ISS" in row[0]
@pytest.mark.asyncio
async def test_demo_broad_search():
"""Empty query should return the full demo catalog."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await _switch_to_craft(pilot, app)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(CraftPanel.SearchRequested(""))
await asyncio.sleep(0.5)
from textual.widgets import DataTable
table = app.query_one("#craft-results-table", DataTable)
assert table.row_count == 8 # Full demo catalog
@pytest.mark.asyncio
async def test_demo_passes():
"""DemoCraftClient should return synthetic pass predictions."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await _switch_to_craft(pilot, app)
control = app.query_one("#control", ControlScreen)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(CraftPanel.PassesRequested(norad_id=25544))
await asyncio.sleep(0.5)
from birdcage_tui.widgets.craft_panel import CraftPassInfo
info = app.query_one("#craft-pass-info", CraftPassInfo)
# Should contain max elevation of 55.0 degrees (ISS arc)
assert "55.0" in info.passes_text
@pytest.mark.asyncio
async def test_demo_tracking_drives_device():
"""DemoCraftClient tracking should move the demo device."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await _switch_to_craft(pilot, app)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
# Track the Moon — always above horizon in demo
panel.post_message(
CraftPanel.TrackRequested(
target_type="celestial",
target_id="moon",
name="Moon",
min_el=5.0,
)
)
await asyncio.sleep(2.5)
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "TRACKING"
assert status.moves >= 1
# Device should have been commanded toward the Moon's position
assert app.device._target_az > 100.0 # Moon is around AZ 145
assert app.device._target_el > 20.0 # Moon is around EL 32
# Screenshot for visual verification
app.save_screenshot("/tmp/birdcage_demo_craft_tracking.svg")
# Stop tracking
control = app.query_one("#control", ControlScreen)
control._stop_craft_tracking()
await pilot.pause()
@pytest.mark.asyncio
async def test_demo_tracking_waiting_below_horizon():
"""Tracking a below-horizon LEO target shows WAITING."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await _switch_to_craft(pilot, app)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
# SO-50 may be below horizon depending on timing.
# Use Jupiter at min_el=90 to guarantee WAITING.
panel.post_message(
CraftPanel.TrackRequested(
target_type="celestial",
target_id="jupiter",
name="Jupiter",
min_el=90.0, # Impossible — forces WAITING
)
)
await asyncio.sleep(2.0)
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "WAITING"
# Cleanup
control = app.query_one("#control", ControlScreen)
control._stop_craft_tracking()
await pilot.pause()