birdcage/tui/tests/test_camera_overlay.py
Ryan Malloy 7035d814a1 Add Camera Capture overlay (F6) with multi-trigger capture pipeline
Camera backend abstraction wrapping fswebcam/ffmpeg/libcamera-still via
subprocess, with DemoCamera fallback generating valid JPEG from raw bytes.

Capture pipeline writes JPEG + JSON sidecar always, optional FITS (astropy)
and EXIF (Pillow) when available. Thread-safe orchestrator with session
tracking, sequence numbering, and date-based output directories.

Trigger system: manual capture, configurable interval timer, and pass event
detection (AOS/TCA/LOS) with 0.5-degree TCA hysteresis. PassEventDetector
runs in the Craft tracking loop, fires callbacks to the camera overlay.

F6 overlay follows the F5 ConsoleOverlay pattern — ModalScreen with
install_screen persistence. Status panel, scrollable capture log, interval
controls, and AOS/TCA/LOS toggle buttons.

Tagline: "a generic AZ/EL positioner that doesn't care about wavelength"
added to TUI header subtitle.

51 new tests (77 total passing).
2026-02-16 05:08:18 -07:00

274 lines
8.0 KiB
Python

"""Test camera overlay — F6 toggle, manual capture, trigger controls.
Uses the same testing pattern as test_craft_mode.py:
- app.demo_mode = True for DemoDevice
- async with app.run_test() for headless rendering
- post_message() for button events (avoids coordinate issues)
Note: ModalScreen widgets are on the screen stack, not the app's regular
DOM. Use app.screen to get the active overlay, then query within it.
"""
import asyncio
from pathlib import Path
import pytest
from birdcage_tui.app import BirdcageApp
from birdcage_tui.screens.camera import CameraOverlay, CaptureStatusPanel
@pytest.mark.asyncio
async def test_f6_opens_camera_overlay(tmp_path: Path):
"""F6 should push the camera overlay."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
assert not app._camera_visible
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
# The top screen should be the camera overlay
assert isinstance(app.screen, CameraOverlay)
@pytest.mark.asyncio
async def test_f6_closes_camera_overlay(tmp_path: Path):
"""F6 again should dismiss the camera overlay."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
await pilot.press("f6")
await pilot.pause()
assert not app._camera_visible
@pytest.mark.asyncio
async def test_escape_closes_camera_overlay(tmp_path: Path):
"""Escape should dismiss the camera overlay."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
await pilot.press("escape")
await pilot.pause()
assert not app._camera_visible
@pytest.mark.asyncio
async def test_manual_capture_creates_file(tmp_path: Path):
"""Pressing 'c' should create a capture file."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
# Trigger manual capture
await pilot.press("c")
await asyncio.sleep(0.5)
# Check captures directory
capture_dir = tmp_path / "captures"
jpg_files = list(capture_dir.rglob("*.jpg"))
json_files = list(capture_dir.rglob("*.json"))
assert len(jpg_files) >= 1
assert len(json_files) >= 1
@pytest.mark.asyncio
async def test_capture_button(tmp_path: Path):
"""Capture button should trigger a capture."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
# Get the overlay from the screen stack
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
from textual.widgets import Button
btn = overlay.query_one("#btn-capture", Button)
btn.post_message(Button.Pressed(btn))
await asyncio.sleep(0.5)
capture_dir = tmp_path / "captures"
jpg_files = list(capture_dir.rglob("*.jpg"))
assert len(jpg_files) >= 1
@pytest.mark.asyncio
async def test_capture_log_updates(tmp_path: Path):
"""Capture log should show entries after captures."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
# Two captures
await pilot.press("c")
await asyncio.sleep(0.3)
await pilot.press("c")
await asyncio.sleep(0.3)
# Status panel should show count
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
assert panel._capture_count >= 2
@pytest.mark.asyncio
async def test_trigger_toggles(tmp_path: Path):
"""AOS/TCA/LOS trigger buttons should toggle active state."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
from textual.widgets import Button
from birdcage_tui.capture_triggers import TriggerType
# Toggle AOS on
aos_btn = overlay.query_one("#btn-trigger-aos", Button)
aos_btn.post_message(Button.Pressed(aos_btn))
await pilot.pause()
assert overlay._pass_detector.is_enabled(TriggerType.AOS)
# Toggle AOS off
aos_btn.post_message(Button.Pressed(aos_btn))
await pilot.pause()
assert not overlay._pass_detector.is_enabled(TriggerType.AOS)
@pytest.mark.asyncio
async def test_auto_interval_start_stop(tmp_path: Path):
"""Auto interval start/stop should control the timer."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
from textual.widgets import Button
# Start auto
start_btn = overlay.query_one("#btn-auto-start", Button)
start_btn.post_message(Button.Pressed(start_btn))
await pilot.pause()
assert overlay._auto_running
# Stop auto
stop_btn = overlay.query_one("#btn-auto-stop", Button)
stop_btn.post_message(Button.Pressed(stop_btn))
await pilot.pause()
assert not overlay._auto_running
@pytest.mark.asyncio
async def test_camera_overlay_with_console(tmp_path: Path):
"""F5 and F6 should open independent overlays."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
# Open camera first
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
assert not app._console_visible
# Close camera
await pilot.press("f6")
await pilot.pause()
assert not app._camera_visible
# Open console
await pilot.press("f5")
await pilot.pause()
assert app._console_visible
assert not app._camera_visible
# Close console
await pilot.press("f5")
await pilot.pause()
assert not app._console_visible
@pytest.mark.asyncio
async def test_status_panel_shows_formats(tmp_path: Path):
"""Status panel should list available output formats."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
# Should always have JPEG + JSON at minimum
assert "JPEG" in panel._formats
assert "JSON" in panel._formats