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).
274 lines
8.0 KiB
Python
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
|