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).
172 lines
5.4 KiB
Python
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
|