- 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)
165 lines
5.3 KiB
Python
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()
|