birdcage/tui/tests/test_camera_backend.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

114 lines
3.6 KiB
Python

"""Test camera backend abstraction — DemoCamera, SubprocessCamera, detection.
DemoCamera is always available and produces valid output files.
SubprocessCamera reports unavailable when the tool doesn't exist.
"""
from pathlib import Path
import pytest
from birdcage_tui.camera import (
CameraConfig,
DemoCamera,
SubprocessCamera,
auto_select_backend,
detect_cameras,
)
def test_demo_camera_captures_file(tmp_path: Path):
"""DemoCamera should produce a file at the requested path."""
camera = DemoCamera()
output = tmp_path / "test.jpg"
result = camera.capture(output)
assert result.success
assert output.exists()
assert output.stat().st_size > 0
assert result.timestamp # ISO-8601 UTC string
assert result.duration_ms >= 0
def test_demo_camera_always_available():
"""DemoCamera.is_available() should always return True."""
camera = DemoCamera()
assert camera.is_available()
def test_demo_camera_name():
"""DemoCamera.name should be 'demo'."""
camera = DemoCamera()
assert camera.name == "demo"
def test_demo_camera_valid_jpeg(tmp_path: Path):
"""DemoCamera should produce a file starting with JPEG SOI marker."""
camera = DemoCamera()
output = tmp_path / "test.jpg"
result = camera.capture(output)
assert result.success
data = output.read_bytes()
# JPEG files start with SOI marker: FF D8
assert data[:2] == b"\xff\xd8"
def test_demo_camera_custom_resolution():
"""DemoCamera should accept custom resolution."""
camera = DemoCamera(resolution=(640, 480))
assert camera._resolution == (640, 480)
def test_subprocess_camera_not_available():
"""SubprocessCamera with nonexistent tool should be unavailable."""
config = CameraConfig(backend_cmd="nonexistent_camera_tool_xyz")
camera = SubprocessCamera(config)
assert not camera.is_available()
def test_subprocess_camera_capture_missing_tool(tmp_path: Path):
"""Capture with missing tool should return failure."""
config = CameraConfig(backend_cmd="nonexistent_camera_tool_xyz")
camera = SubprocessCamera(config)
output = tmp_path / "test.jpg"
result = camera.capture(output)
assert not result.success
assert result.error # Has a meaningful error message
def test_subprocess_camera_name():
"""SubprocessCamera.name should include tool and device."""
config = CameraConfig(backend_cmd="fswebcam", device="/dev/video0")
camera = SubprocessCamera(config)
assert "fswebcam" in camera.name
assert "/dev/video0" in camera.name
def test_detect_cameras_returns_list():
"""detect_cameras() should return a list (may be empty in CI)."""
devices = detect_cameras()
assert isinstance(devices, list)
def test_auto_select_returns_available_backend():
"""auto_select should return an available backend (demo or real)."""
config = CameraConfig(backend_cmd="nonexistent_tool_xyz")
backend = auto_select_backend(config)
# Should always return something available
assert backend.is_available()
# If no real backends exist, should be demo; otherwise a real one
assert backend.name # Has a name
@pytest.mark.parametrize("backend_cmd", ["fswebcam", "ffmpeg", "libcamera-still"])
def test_subprocess_unknown_backend_fails(tmp_path: Path, backend_cmd: str):
"""Unknown backend command pattern should be in the lookup table."""
config = CameraConfig(backend_cmd=backend_cmd)
SubprocessCamera(config)
# The command template should exist even if the tool isn't installed
assert backend_cmd in SubprocessCamera._COMMANDS