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).
114 lines
3.6 KiB
Python
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
|