Move bridge.py, demo.py, craft_client.py from tui/src/birdcage_tui/ to src/birdcage/ so both TUI and MCP server can share the device layer without a circular dependency on textual.
260 lines
7.9 KiB
Python
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.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)
|