birdcage/tui/tests/test_sweep_stop.py
Ryan Malloy 145763fcfb Rebuild TUI as 4-tab layout with 10 new widgets
Replace sidebar + 5-screen layout with horizontal tab bar (F1-F4)
and persistent StatusStrip. Consolidate Position + Scan screens into
Signal (F3) with Monitor/Sweep/Sky Map sub-modes via ModeBar.

Screens:
  - F1 Dashboard: system health, tracking panel, quick actions, presets
  - F2 Control: motor tuning, compass rose, preset management
  - F3 Signal: RSSI monitor, 1D sweep (SweepPlot), 2D sky map (heatmap)
  - F4 System: NVS editor with regex filter, EEPROM, firmware info
  - F5 Console: push/pop overlay (no longer a tab)

New widgets: StatusStrip, ModeBar, SweepPlot, QuickActions, PresetList,
ReceiverInfo, MotorTuning, NvsFilter, SystemHealth, TrackingPanel.

Removed: PositionScreen, ScanScreen, DeviceStatusBar (functionality
absorbed into new screens and StatusStrip).

App-level position poll feeds StatusStrip and active screen at ~2 Hz.
Fix shared threading.Event across instances (class-level mutable default).
2026-02-14 18:04:50 -07:00

154 lines
4.9 KiB
Python

"""Test that the sweep Stop button actually stops a sweep.
Uses Textual's run_test() and pilot to drive the TUI in demo mode,
start a sweep, click Stop, and verify the sweep terminates.
"""
import asyncio
import pytest
from textual.widgets import Button
from birdcage_tui.app import BirdcageApp
@pytest.mark.asyncio
async def test_sweep_stop_firmware():
"""Stop button should abort a firmware sweep and reset UI state."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
# Wait for app to fully mount.
await pilot.pause()
# Navigate to Signal screen (F3).
await pilot.press("f3")
await pilot.pause()
# Switch to Sweep mode via the mode bar.
signal = app.query_one("SignalScreen")
signal.switch_mode("sweep")
await pilot.pause()
# Click Start Sweep.
start_btn = app.query_one("#btn-start-sweep", Button)
await pilot.click(start_btn)
# Give the firmware sweep worker time to start and complete
# (demo mode takes ~0.5s).
await asyncio.sleep(1.0)
# Click Stop.
stop_btn = app.query_one("#btn-stop-sweep", Button)
await pilot.click(stop_btn)
await pilot.pause()
# Verify: _sweeping should be False.
assert not signal._sweeping, "_sweeping should be False after Stop"
# Verify: Start button should have primary variant (ready for new sweep).
assert start_btn.variant == "primary", (
f"Start button variant should be 'primary', got {start_btn.variant!r}"
)
# Take a screenshot for visual inspection.
app.save_screenshot("/tmp/birdcage_sweep_stop_test.svg")
@pytest.mark.asyncio
async def test_sweep_stop_during_software():
"""Stop button should abort a software sweep mid-execution."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f3")
await pilot.pause()
signal = app.query_one("SignalScreen")
signal.switch_mode("sweep")
await pilot.pause()
# Check the "Software mode" checkbox to force slow path.
from textual.widgets import Checkbox
sw_checkbox = app.query_one("#sweep-software-mode", Checkbox)
sw_checkbox.value = True
await pilot.pause()
# Start sweep (software mode — takes longer per point).
start_btn = app.query_one("#btn-start-sweep", Button)
await pilot.click(start_btn)
# Brief pause to let the worker start, but not finish.
await asyncio.sleep(0.3)
# Click Stop while the software sweep is in progress.
stop_btn = app.query_one("#btn-stop-sweep", Button)
await pilot.click(stop_btn)
# Wait for the worker to notice the stop flag.
await asyncio.sleep(1.0)
# Verify: sweep stopped.
assert not signal._sweeping, "_sweeping should be False after Stop"
assert start_btn.variant == "primary"
app.save_screenshot("/tmp/birdcage_sweep_stop_sw_test.svg")
@pytest.mark.asyncio
async def test_sweep_restart_after_stop():
"""After stopping, a new sweep should start without errors."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f3")
await pilot.pause()
signal = app.query_one("SignalScreen")
signal.switch_mode("sweep")
await pilot.pause()
# Verify device is available.
assert signal._device is not None, "Device should be set after mount"
# First sweep -- click Start and wait for completion.
start_btn = app.query_one("#btn-start-sweep", Button)
await pilot.click(start_btn)
# Give the worker time to start.
await asyncio.sleep(0.5)
# Poll until sweep completes.
for _ in range(40):
await asyncio.sleep(0.2)
if not signal._sweeping:
# Give one more beat for call_from_thread callbacks.
await asyncio.sleep(0.3)
break
assert not signal._sweeping, "Sweep still running after polling"
assert len(signal._sweep_data) > 0, (
f"First sweep should have data; device type={type(signal._device).__name__}"
)
# Second sweep.
await pilot.click(start_btn)
await asyncio.sleep(0.5)
for _ in range(40):
await asyncio.sleep(0.2)
if not signal._sweeping:
await asyncio.sleep(0.3)
break
assert not signal._sweeping
assert len(signal._sweep_data) > 0, "Second sweep should have data"
app.save_screenshot("/tmp/birdcage_sweep_restart_test.svg")