birdcage/tui/tests/test_craft_mode.py
Ryan Malloy 6c1e9da773 Add Craft mode — direct satellite tracking via space.warehack.ing API
New F2 Control sub-mode that searches the Craft orbital catalog (22k+
objects), displays pass predictions, and drives the dish in real-time
using server-computed AZ/EL positions from /api/sky/up.

Tracking loop polls at ~1Hz, filters for the tracked target by
target_type + target_id (str, not int — handles satellites, planets,
stars, comets), and issues motor commands through the existing serial
bridge. Verified end-to-end with Carryout G2 hardware tracking NOAA 17.

New files:
- craft_client.py — stdlib HTTP client (urllib only, no deps)
- widgets/craft_panel.py — search table, pass info, tracking status
- tests/test_craft_mode.py — 5 unit tests with mocked API
- tests/test_craft_integration.py — 3 hardware integration tests
2026-02-16 02:07:42 -07:00

260 lines
7.9 KiB
Python

"""Test Craft mode — API-driven satellite tracking via the F2 Control screen.
Verifies search, pass predictions, tracking loop, and stop lifecycle using
mocked CraftClient methods. No real network calls.
NOTE: Uses post_message() for reliable button activation (same pattern as
test_track_mode.py).
"""
import asyncio
from unittest.mock import MagicMock
import pytest
from birdcage_tui.app import BirdcageApp
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition
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()
def _mock_search_results() -> list[SearchResult]:
return [
SearchResult(
name="ISS (ZARYA)",
target_type="satellite",
target_id="25544",
score=1.0,
groups=["stations"],
),
SearchResult(
name="NOAA 19",
target_type="satellite",
target_id="33591",
score=0.8,
groups=["weather"],
),
]
def _mock_passes() -> list[PassPrediction]:
return [
PassPrediction(
satellite_name="ISS (ZARYA)",
norad_id=25544,
aos_time="2026-02-16T08:20:00Z",
aos_az=220.0,
tca_time="2026-02-16T08:25:00Z",
tca_alt=45.3,
tca_az=180.0,
los_time="2026-02-16T08:30:00Z",
los_az=140.0,
max_elevation=45.3,
duration_seconds=600,
is_visible=True,
),
]
def _mock_visible_target() -> list[TargetPosition]:
return [
TargetPosition(
name="ISS (ZARYA)",
target_type="satellite",
target_id="25544",
azimuth=245.30,
altitude=34.20,
distance_km=420.0,
range_rate=-2.1,
is_above_horizon=True,
),
]
def _mock_below_horizon() -> list[TargetPosition]:
"""Return an empty list — target is not in the sky."""
return []
@pytest.mark.asyncio
async def test_search_populates_table():
"""Searching should populate the DataTable with results."""
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)
# Mock the client
mock_client = MagicMock()
mock_client.search.return_value = _mock_search_results()
control.set_craft_client(mock_client)
# Post search request
panel.post_message(CraftPanel.SearchRequested("ISS"))
await asyncio.sleep(0.5)
# Verify table was populated
from textual.widgets import DataTable
table = app.query_one("#craft-results-table", DataTable)
assert table.row_count == 2
mock_client.search.assert_called_once_with("ISS")
@pytest.mark.asyncio
async def test_tracking_drives_device():
"""Tracking should poll positions and 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)
control = app.query_one("#control", ControlScreen)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
mock_client = MagicMock()
mock_client.get_visible_targets.return_value = _mock_visible_target()
control.set_craft_client(mock_client)
# Start tracking
panel.post_message(
CraftPanel.TrackRequested(
target_type="satellite",
target_id="25544",
name="ISS (ZARYA)",
min_el=18.0,
)
)
# Let the tracking loop run a couple iterations
await asyncio.sleep(2.5)
# Device target should have been updated
assert app.device._target_az == pytest.approx(245.30, abs=0.1)
assert app.device._target_el == pytest.approx(34.20, abs=0.1)
# Status should show TRACKING
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "TRACKING"
assert status.moves >= 1
# Cleanup
control._stop_craft_tracking()
await pilot.pause()
@pytest.mark.asyncio
async def test_tracking_stops_cleanly():
"""Stopping tracking should return to IDLE state."""
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)
mock_client = MagicMock()
mock_client.get_visible_targets.return_value = _mock_visible_target()
control.set_craft_client(mock_client)
# Start tracking
panel.post_message(
CraftPanel.TrackRequested(
target_type="satellite",
target_id="25544",
name="ISS (ZARYA)",
min_el=18.0,
)
)
await asyncio.sleep(1.5)
# Stop
panel.post_message(CraftPanel.StopTrackingRequested())
await asyncio.sleep(0.5)
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "IDLE"
assert not control._craft_tracking
@pytest.mark.asyncio
async def test_below_horizon_shows_waiting():
"""Target not in sky/up results should show WAITING status."""
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)
mock_client = MagicMock()
mock_client.get_visible_targets.return_value = _mock_below_horizon()
control.set_craft_client(mock_client)
# Start tracking
panel.post_message(
CraftPanel.TrackRequested(
target_type="satellite",
target_id="25544",
name="ISS (ZARYA)",
min_el=18.0,
)
)
await asyncio.sleep(1.5)
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "WAITING"
# Cleanup
control._stop_craft_tracking()
await pilot.pause()
@pytest.mark.asyncio
async def test_passes_display():
"""Pass predictions should update the CraftPassInfo widget."""
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)
mock_client = MagicMock()
mock_client.get_passes.return_value = _mock_passes()
control.set_craft_client(mock_client)
# Request passes
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)
assert "45.3" in info.passes_text
mock_client.get_passes.assert_called_once_with(25544)