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)
This commit is contained in:
parent
7035d814a1
commit
3013eeee4c
@ -1,21 +1,31 @@
|
|||||||
"""Take screenshots of all TUI screens in demo mode for documentation."""
|
"""Take screenshots of all TUI screens in demo mode for documentation.
|
||||||
|
|
||||||
|
Generates SVG snapshots of every screen and sub-mode including overlays.
|
||||||
|
All data comes from DemoDevice and DemoCraftClient — no hardware or
|
||||||
|
network required.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd tui && uv run python scripts/take_screenshots.py
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from birdcage_tui.app import BirdcageApp
|
from birdcage_tui.app import BirdcageApp
|
||||||
|
from birdcage_tui.screens.control import ControlScreen
|
||||||
SCREENS = {
|
from birdcage_tui.widgets.craft_panel import CraftPanel
|
||||||
"f1": "dashboard",
|
|
||||||
"f2": "control",
|
|
||||||
"f3": "signal",
|
|
||||||
"f4": "system",
|
|
||||||
}
|
|
||||||
|
|
||||||
OUT_DIR = "/home/rpm/claude/ham/satellite/winegard-travler/site/public/screenshots"
|
OUT_DIR = "/home/rpm/claude/ham/satellite/winegard-travler/site/public/screenshots"
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def _screenshot_basic_tabs():
|
||||||
for key, name in SCREENS.items():
|
"""F1 Dashboard, F2 Control (manual), F3 Signal, F4 System."""
|
||||||
|
tabs = {
|
||||||
|
"f1": "dashboard",
|
||||||
|
"f2": "control",
|
||||||
|
"f3": "signal",
|
||||||
|
"f4": "system",
|
||||||
|
}
|
||||||
|
for key, name in tabs.items():
|
||||||
app = BirdcageApp()
|
app = BirdcageApp()
|
||||||
app.demo_mode = True
|
app.demo_mode = True
|
||||||
|
|
||||||
@ -23,7 +33,6 @@ async def main():
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await pilot.press(key)
|
await pilot.press(key)
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
# Let workers populate data.
|
|
||||||
await asyncio.sleep(1.5)
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
path = f"{OUT_DIR}/tui-{name}.svg"
|
path = f"{OUT_DIR}/tui-{name}.svg"
|
||||||
@ -31,4 +40,107 @@ async def main():
|
|||||||
print(f"Saved {path}")
|
print(f"Saved {path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _screenshot_craft_search():
|
||||||
|
"""F2 Control > Craft sub-mode with search results."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
control.switch_mode("craft")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Trigger a search — uses DemoCraftClient
|
||||||
|
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||||
|
panel.post_message(CraftPanel.SearchRequested(""))
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
path = f"{OUT_DIR}/tui-craft-search.svg"
|
||||||
|
app.save_screenshot(path)
|
||||||
|
print(f"Saved {path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _screenshot_craft_tracking():
|
||||||
|
"""F2 Control > Craft sub-mode actively tracking the Moon."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("f2")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
control = app.query_one("#control", ControlScreen)
|
||||||
|
control.switch_mode("craft")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Start tracking the Moon (always above horizon in demo)
|
||||||
|
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||||
|
panel.post_message(
|
||||||
|
CraftPanel.TrackRequested(
|
||||||
|
target_type="celestial",
|
||||||
|
target_id="moon",
|
||||||
|
name="Moon",
|
||||||
|
min_el=5.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(2.5)
|
||||||
|
|
||||||
|
path = f"{OUT_DIR}/tui-craft-tracking.svg"
|
||||||
|
app.save_screenshot(path)
|
||||||
|
print(f"Saved {path}")
|
||||||
|
|
||||||
|
control._stop_craft_tracking()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
|
||||||
|
async def _screenshot_console():
|
||||||
|
"""F5 Console overlay."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
await pilot.press("f5")
|
||||||
|
await pilot.pause()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
path = f"{OUT_DIR}/tui-console.svg"
|
||||||
|
app.save_screenshot(path)
|
||||||
|
print(f"Saved {path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _screenshot_camera():
|
||||||
|
"""F6 Camera overlay."""
|
||||||
|
app = BirdcageApp()
|
||||||
|
app.demo_mode = True
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
await pilot.press("f6")
|
||||||
|
await pilot.pause()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
path = f"{OUT_DIR}/tui-camera.svg"
|
||||||
|
app.save_screenshot(path)
|
||||||
|
print(f"Saved {path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
await _screenshot_basic_tabs()
|
||||||
|
await _screenshot_craft_search()
|
||||||
|
await _screenshot_craft_tracking()
|
||||||
|
await _screenshot_console()
|
||||||
|
await _screenshot_camera()
|
||||||
|
print(f"\nAll screenshots saved to {OUT_DIR}/")
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@ -141,9 +141,14 @@ class BirdcageApp(App):
|
|||||||
|
|
||||||
def _setup_craft_client(self) -> None:
|
def _setup_craft_client(self) -> None:
|
||||||
"""Create a Craft API client and hand it to the control screen."""
|
"""Create a Craft API client and hand it to the control screen."""
|
||||||
from birdcage_tui.craft_client import CraftClient
|
if self.demo_mode:
|
||||||
|
from birdcage_tui.demo import DemoCraftClient
|
||||||
|
|
||||||
client = CraftClient(base_url=self.craft_url)
|
client = DemoCraftClient()
|
||||||
|
else:
|
||||||
|
from birdcage_tui.craft_client import CraftClient
|
||||||
|
|
||||||
|
client = CraftClient(base_url=self.craft_url)
|
||||||
try:
|
try:
|
||||||
control = self.query_one("#control")
|
control = self.query_one("#control")
|
||||||
if hasattr(control, "set_craft_client"):
|
if hasattr(control, "set_craft_client"):
|
||||||
|
|||||||
@ -3,14 +3,21 @@
|
|||||||
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
|
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
|
||||||
dish with motor movement, RSSI signal modeling, and canned firmware responses.
|
dish with motor movement, RSSI signal modeling, and canned firmware responses.
|
||||||
No serial hardware required.
|
No serial hardware required.
|
||||||
|
|
||||||
|
Also provides DemoCraftClient — a duck-typed replacement for CraftClient that
|
||||||
|
returns synthetic satellite data with zero HTTP calls. Supports time-varying
|
||||||
|
LEO arcs so the tracking loop drives real pass events to the camera overlay.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import datetime as _dt
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition
|
||||||
|
|
||||||
|
|
||||||
class _DemoMenu(Enum):
|
class _DemoMenu(Enum):
|
||||||
"""Simulated firmware submenu states."""
|
"""Simulated firmware submenu states."""
|
||||||
@ -375,6 +382,10 @@ class DemoDevice:
|
|||||||
timeout: float = 120,
|
timeout: float = 120,
|
||||||
) -> list[dict[str, float]]:
|
) -> list[dict[str, float]]:
|
||||||
"""Simulate a firmware azscanwxp sweep with Gaussian signal peak."""
|
"""Simulate a firmware azscanwxp sweep with Gaussian signal peak."""
|
||||||
|
# Snap EL to target so 2D scans compute correct per-row signal.
|
||||||
|
# Without this, move_motor(1, el) only sets _target_el — _el stays
|
||||||
|
# stale because the position interpolator never runs mid-sweep.
|
||||||
|
self._el = self._target_el
|
||||||
step_deg = step_cdeg / 100.0
|
step_deg = step_cdeg / 100.0
|
||||||
if step_deg <= 0:
|
if step_deg <= 0:
|
||||||
step_deg = 1.0
|
step_deg = 1.0
|
||||||
@ -724,3 +735,216 @@ class DemoDevice:
|
|||||||
if cmd == "reboot":
|
if cmd == "reboot":
|
||||||
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
|
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
|
||||||
return f"Unknown command: {cmd}\nOS>"
|
return f"Unknown command: {cmd}\nOS>"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DemoCraftClient — offline Craft API replacement
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Canned satellite catalog for search results.
|
||||||
|
_DEMO_CATALOG: list[dict] = [
|
||||||
|
{
|
||||||
|
"name": "ISS (ZARYA)",
|
||||||
|
"type": "satellite",
|
||||||
|
"id": "25544",
|
||||||
|
"groups": ["stations"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NOAA 19",
|
||||||
|
"type": "satellite",
|
||||||
|
"id": "33591",
|
||||||
|
"groups": ["weather"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SO-50 (SAUDISAT 1C)",
|
||||||
|
"type": "satellite",
|
||||||
|
"id": "27607",
|
||||||
|
"groups": ["amateur"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-2",
|
||||||
|
"type": "satellite",
|
||||||
|
"id": "50988",
|
||||||
|
"groups": ["amateur"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-91 (FOX-1B)",
|
||||||
|
"type": "satellite",
|
||||||
|
"id": "43017",
|
||||||
|
"groups": ["amateur"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Moon",
|
||||||
|
"type": "celestial",
|
||||||
|
"id": "moon",
|
||||||
|
"groups": ["solar-system"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sun",
|
||||||
|
"type": "celestial",
|
||||||
|
"id": "sun",
|
||||||
|
"groups": ["solar-system"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jupiter",
|
||||||
|
"type": "celestial",
|
||||||
|
"id": "jupiter",
|
||||||
|
"groups": ["solar-system"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# LEO arc parameters: (phase_offset_minutes, period_minutes, max_el)
|
||||||
|
_LEO_ARCS: dict[str, tuple[float, float, float]] = {
|
||||||
|
"25544": (0.0, 10.0, 55.0), # ISS — primary demo target
|
||||||
|
"33591": (3.0, 9.0, 42.0), # NOAA 19
|
||||||
|
"27607": (5.0, 8.5, 38.0), # SO-50
|
||||||
|
"50988": (7.0, 11.0, 48.0), # TEVEL-2
|
||||||
|
"43017": (2.0, 9.5, 35.0), # AO-91
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _leo_position(target_id: str, t: float) -> tuple[float, float]:
|
||||||
|
"""Compute time-varying AZ/EL for a simulated LEO satellite.
|
||||||
|
|
||||||
|
The arc traces: rise east (AZ ~90, EL 0) -> TCA (~180, max_el)
|
||||||
|
-> set west (AZ ~270, EL 0) over one period, then resets.
|
||||||
|
Returns (az, el) where el < 0 means below horizon.
|
||||||
|
"""
|
||||||
|
offset, period, max_el = _LEO_ARCS.get(target_id, (0.0, 10.0, 40.0))
|
||||||
|
period_sec = period * 60.0
|
||||||
|
phase = ((t + offset * 60.0) % period_sec) / period_sec # 0.0 → 1.0
|
||||||
|
|
||||||
|
# Visible window: phase 0.0-0.5 = above horizon, 0.5-1.0 = below
|
||||||
|
if phase > 0.5:
|
||||||
|
return 0.0, -10.0 # Below horizon
|
||||||
|
|
||||||
|
# Map 0→0.5 to AZ 90→270, EL 0→max→0 (sine arc)
|
||||||
|
arc_phase = phase / 0.5 # 0.0 → 1.0 through the visible pass
|
||||||
|
az = 90.0 + 180.0 * arc_phase
|
||||||
|
el = max_el * math.sin(math.pi * arc_phase)
|
||||||
|
return az, el
|
||||||
|
|
||||||
|
|
||||||
|
def _celestial_position(target_id: str, t: float) -> tuple[float, float]:
|
||||||
|
"""Slow-drift positions for celestial bodies."""
|
||||||
|
if target_id == "moon":
|
||||||
|
az = 145.0 + 0.5 * math.sin(t / 600.0) * (t / 60.0 % 10)
|
||||||
|
el = 32.0 + 3.0 * math.sin(t / 900.0)
|
||||||
|
return az, max(el, 5.0)
|
||||||
|
if target_id == "sun":
|
||||||
|
az = 210.0 + 0.3 * (t / 60.0 % 15)
|
||||||
|
el = 45.0 + 5.0 * math.sin(t / 1200.0)
|
||||||
|
return az, max(el, 10.0)
|
||||||
|
# Jupiter — nearly fixed
|
||||||
|
return 255.0, 28.0
|
||||||
|
|
||||||
|
|
||||||
|
class DemoCraftClient:
|
||||||
|
"""Offline replacement for CraftClient returning synthetic orbital data.
|
||||||
|
|
||||||
|
Duck-typed to match CraftClient's interface. No HTTP calls are made.
|
||||||
|
LEO satellites trace realistic arcs using time.monotonic() so the
|
||||||
|
tracking loop sees genuine AOS/TCA/LOS transitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._t0 = time.monotonic()
|
||||||
|
|
||||||
|
def health(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int = 20) -> list[SearchResult]:
|
||||||
|
q_lower = query.lower()
|
||||||
|
results = []
|
||||||
|
for entry in _DEMO_CATALOG:
|
||||||
|
if q_lower in entry["name"].lower():
|
||||||
|
results.append(
|
||||||
|
SearchResult(
|
||||||
|
name=entry["name"],
|
||||||
|
target_type=entry["type"],
|
||||||
|
target_id=entry["id"],
|
||||||
|
score=1.0,
|
||||||
|
groups=entry["groups"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_passes(self, norad_id: int, hours: int = 24) -> list[PassPrediction]:
|
||||||
|
now = _dt.datetime.now(tz=_dt.UTC)
|
||||||
|
name = "Unknown"
|
||||||
|
for entry in _DEMO_CATALOG:
|
||||||
|
if entry["id"] == str(norad_id):
|
||||||
|
name = entry["name"]
|
||||||
|
break
|
||||||
|
|
||||||
|
arc_params = _LEO_ARCS.get(str(norad_id), (0.0, 10.0, 40.0))
|
||||||
|
_, period, max_el = arc_params
|
||||||
|
passes = []
|
||||||
|
for i in range(4):
|
||||||
|
aos = now + _dt.timedelta(minutes=30 * (i + 1))
|
||||||
|
tca = aos + _dt.timedelta(minutes=period / 2)
|
||||||
|
los = aos + _dt.timedelta(minutes=period)
|
||||||
|
duration = int(period * 60)
|
||||||
|
passes.append(
|
||||||
|
PassPrediction(
|
||||||
|
satellite_name=name,
|
||||||
|
norad_id=norad_id,
|
||||||
|
aos_time=aos.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
aos_az=90.0 + random.uniform(-10, 10),
|
||||||
|
tca_time=tca.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
tca_alt=max_el,
|
||||||
|
tca_az=180.0 + random.uniform(-15, 15),
|
||||||
|
los_time=los.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
los_az=270.0 + random.uniform(-10, 10),
|
||||||
|
max_elevation=max_el,
|
||||||
|
duration_seconds=duration,
|
||||||
|
is_visible=i < 2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return passes
|
||||||
|
|
||||||
|
def get_next_pass(self, norad_id: int) -> PassPrediction | None:
|
||||||
|
passes = self.get_passes(norad_id)
|
||||||
|
return passes[0] if passes else None
|
||||||
|
|
||||||
|
def get_visible_targets(self, min_alt: float = 0.0) -> list[TargetPosition]:
|
||||||
|
t = time.monotonic() - self._t0
|
||||||
|
targets = []
|
||||||
|
|
||||||
|
for entry in _DEMO_CATALOG:
|
||||||
|
tid = entry["id"]
|
||||||
|
ttype = entry["type"]
|
||||||
|
|
||||||
|
if ttype == "satellite" and tid in _LEO_ARCS:
|
||||||
|
az, el = _leo_position(tid, t)
|
||||||
|
elif ttype == "celestial":
|
||||||
|
az, el = _celestial_position(tid, t)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if el < min_alt:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Synthetic distance/range-rate for LEO targets
|
||||||
|
if ttype == "satellite":
|
||||||
|
dist = 400.0 + 200.0 * math.cos(math.pi * el / 90.0)
|
||||||
|
rr = -2.0 + 4.0 * math.sin(t / 120.0)
|
||||||
|
else:
|
||||||
|
dist = 384400.0 if tid == "moon" else 0.0
|
||||||
|
rr = 0.0
|
||||||
|
|
||||||
|
targets.append(
|
||||||
|
TargetPosition(
|
||||||
|
name=entry["name"],
|
||||||
|
target_type=ttype,
|
||||||
|
target_id=tid,
|
||||||
|
azimuth=round(az, 2),
|
||||||
|
altitude=round(el, 2),
|
||||||
|
distance_km=round(dist, 1),
|
||||||
|
range_rate=round(rr, 3),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return targets
|
||||||
|
|||||||
164
tui/tests/test_demo_craft.py
Normal file
164
tui/tests/test_demo_craft.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"""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()
|
||||||
Loading…
x
Reference in New Issue
Block a user