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
|
||||
|
||||
from birdcage_tui.app import BirdcageApp
|
||||
|
||||
SCREENS = {
|
||||
"f1": "dashboard",
|
||||
"f2": "control",
|
||||
"f3": "signal",
|
||||
"f4": "system",
|
||||
}
|
||||
from birdcage_tui.screens.control import ControlScreen
|
||||
from birdcage_tui.widgets.craft_panel import CraftPanel
|
||||
|
||||
OUT_DIR = "/home/rpm/claude/ham/satellite/winegard-travler/site/public/screenshots"
|
||||
|
||||
|
||||
async def main():
|
||||
for key, name in SCREENS.items():
|
||||
async def _screenshot_basic_tabs():
|
||||
"""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.demo_mode = True
|
||||
|
||||
@ -23,7 +33,6 @@ async def main():
|
||||
await pilot.pause()
|
||||
await pilot.press(key)
|
||||
await pilot.pause()
|
||||
# Let workers populate data.
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
path = f"{OUT_DIR}/tui-{name}.svg"
|
||||
@ -31,4 +40,107 @@ async def main():
|
||||
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())
|
||||
|
||||
@ -141,9 +141,14 @@ class BirdcageApp(App):
|
||||
|
||||
def _setup_craft_client(self) -> None:
|
||||
"""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:
|
||||
control = self.query_one("#control")
|
||||
if hasattr(control, "set_craft_client"):
|
||||
|
||||
@ -3,14 +3,21 @@
|
||||
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
|
||||
dish with motor movement, RSSI signal modeling, and canned firmware responses.
|
||||
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 datetime as _dt
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
|
||||
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition
|
||||
|
||||
|
||||
class _DemoMenu(Enum):
|
||||
"""Simulated firmware submenu states."""
|
||||
@ -375,6 +382,10 @@ class DemoDevice:
|
||||
timeout: float = 120,
|
||||
) -> list[dict[str, float]]:
|
||||
"""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
|
||||
if step_deg <= 0:
|
||||
step_deg = 1.0
|
||||
@ -724,3 +735,216 @@ class DemoDevice:
|
||||
if cmd == "reboot":
|
||||
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
|
||||
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