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).
This commit is contained in:
parent
6c1e9da773
commit
7035d814a1
13 changed files with 2576 additions and 2 deletions
171
tui/tests/test_capture_manager.py
Normal file
171
tui/tests/test_capture_manager.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue