"""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)