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

172 lines
5.4 KiB
Python

"""Test capture manager — file naming, JSON sidecars, session tracking.
All tests use DemoCamera for zero-hardware-dependency captures.
"""
import json
from pathlib import Path
import pytest
from birdcage_tui.camera import DemoCamera
from birdcage_tui.capture_manager import (
CaptureManager,
CaptureMetadata,
CaptureSession,
_build_filename,
_sanitize_name,
)
@pytest.fixture
def manager(tmp_path: Path) -> CaptureManager:
"""CaptureManager with DemoCamera in a temp directory."""
backend = DemoCamera()
return CaptureManager(backend, output_dir=tmp_path)
@pytest.fixture
def tracking_metadata() -> CaptureMetadata:
"""Metadata simulating an active tracking capture."""
return CaptureMetadata(
mount_az=245.30,
mount_el=34.20,
target_name="ISS (ZARYA)",
target_type="satellite",
target_id="25544",
target_az=245.30,
target_el=34.20,
target_distance_km=420.0,
target_range_rate=-2.1,
tracking_mode="craft",
tracking_status="TRACKING",
trigger="manual",
)
def test_capture_writes_jpeg(manager: CaptureManager, tracking_metadata):
"""Capture should produce a JPEG file."""
result = manager.capture(tracking_metadata)
assert result.success
assert result.path.exists()
assert result.path.suffix == ".jpg"
def test_capture_writes_json_sidecar(manager: CaptureManager, tracking_metadata):
"""JSON sidecar should always be written alongside JPEG."""
result = manager.capture(tracking_metadata)
assert result.success
# Find the JSON sidecar
json_files = list(result.path.parent.glob("*.json"))
assert len(json_files) == 1
data = json.loads(json_files[0].read_text())
assert data["mount_az"] == 245.30
assert data["mount_el"] == 34.20
assert data["target_name"] == "ISS (ZARYA)"
assert data["trigger"] == "manual"
def test_filename_generation():
"""Filenames should follow the naming convention."""
meta = CaptureMetadata(
mount_az=245.30,
mount_el=34.20,
target_name="ISS (ZARYA)",
timestamp="2026-02-16T08:25:00Z",
)
name = _build_filename(meta, "jpg")
assert name.startswith("ISS-ZARYA_")
assert "245.30" in name
assert "34.20" in name
assert name.endswith(".jpg")
def test_filename_sanitization():
"""Special characters should be stripped from target names."""
assert _sanitize_name("ISS (ZARYA)") == "ISS-ZARYA"
assert _sanitize_name("NOAA 19") == "NOAA-19"
assert _sanitize_name("") == "manual"
assert _sanitize_name("a/b\\c:d") == "abcd"
def test_filename_no_target():
"""Without a target name, filename should use 'manual'."""
meta = CaptureMetadata(
mount_az=180.0,
mount_el=45.0,
timestamp="2026-02-16T12:00:00Z",
)
name = _build_filename(meta, "jpg")
assert name.startswith("manual_")
def test_session_counter():
"""Session sequence should increment monotonically."""
session = CaptureSession()
assert session.next_sequence() == 1
assert session.next_sequence() == 2
assert session.next_sequence() == 3
assert session.capture_count == 3
def test_session_id_unique():
"""Each session should have a unique ID."""
s1 = CaptureSession()
s2 = CaptureSession()
assert s1.session_id != s2.session_id
def test_subdirectory_creation(manager: CaptureManager, tracking_metadata):
"""Captures should go into date-based subdirectories."""
result = manager.capture(tracking_metadata)
assert result.success
# The parent should be a date directory like "2026-02-16"
parent = result.path.parent.name
assert len(parent) == 10 # YYYY-MM-DD
assert parent.count("-") == 2
def test_multiple_captures_increment(manager: CaptureManager, tracking_metadata):
"""Multiple captures should increment the session counter."""
r1 = manager.capture(tracking_metadata)
r2 = manager.capture(tracking_metadata)
assert r1.success and r2.success
assert manager.session.capture_count == 2
# Filenames should differ
assert r1.path.name != r2.path.name
def test_pillow_fallback(manager: CaptureManager):
"""Manager should report Pillow status without crashing."""
# This test just verifies the property doesn't throw
_ = manager.has_pillow # True if installed, False if not
def test_astropy_fallback(manager: CaptureManager):
"""Manager should report astropy status without crashing."""
_ = manager.has_astropy # True if installed, False if not
def test_metadata_session_populated(manager: CaptureManager, tracking_metadata):
"""After capture, metadata should have session info populated."""
manager.capture(tracking_metadata)
assert tracking_metadata.session_id == manager.session.session_id
assert tracking_metadata.sequence_number == 1
def test_manual_capture_no_target(manager: CaptureManager):
"""Capture without tracking should work with minimal metadata."""
meta = CaptureMetadata(
mount_az=180.0,
mount_el=45.0,
trigger="manual",
)
result = manager.capture(meta)
assert result.success
# JSON sidecar should exist
json_files = list(result.path.parent.glob("*.json"))
assert len(json_files) >= 1