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
113
tui/tests/test_camera_backend.py
Normal file
113
tui/tests/test_camera_backend.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""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
|
||||
273
tui/tests/test_camera_overlay.py
Normal file
273
tui/tests/test_camera_overlay.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Test camera overlay — F6 toggle, manual capture, trigger controls.
|
||||
|
||||
Uses the same testing pattern as test_craft_mode.py:
|
||||
- app.demo_mode = True for DemoDevice
|
||||
- async with app.run_test() for headless rendering
|
||||
- post_message() for button events (avoids coordinate issues)
|
||||
|
||||
Note: ModalScreen widgets are on the screen stack, not the app's regular
|
||||
DOM. Use app.screen to get the active overlay, then query within it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from birdcage_tui.app import BirdcageApp
|
||||
from birdcage_tui.screens.camera import CameraOverlay, CaptureStatusPanel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f6_opens_camera_overlay(tmp_path: Path):
|
||||
"""F6 should push the camera overlay."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert not app._camera_visible
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._camera_visible
|
||||
# The top screen should be the camera overlay
|
||||
assert isinstance(app.screen, CameraOverlay)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f6_closes_camera_overlay(tmp_path: Path):
|
||||
"""F6 again should dismiss the camera overlay."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert app._camera_visible
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert not app._camera_visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escape_closes_camera_overlay(tmp_path: Path):
|
||||
"""Escape should dismiss the camera overlay."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert app._camera_visible
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert not app._camera_visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_capture_creates_file(tmp_path: Path):
|
||||
"""Pressing 'c' should create a capture file."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
# Trigger manual capture
|
||||
await pilot.press("c")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Check captures directory
|
||||
capture_dir = tmp_path / "captures"
|
||||
jpg_files = list(capture_dir.rglob("*.jpg"))
|
||||
json_files = list(capture_dir.rglob("*.json"))
|
||||
|
||||
assert len(jpg_files) >= 1
|
||||
assert len(json_files) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_button(tmp_path: Path):
|
||||
"""Capture button should trigger a capture."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
# Get the overlay from the screen stack
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
from textual.widgets import Button
|
||||
|
||||
btn = overlay.query_one("#btn-capture", Button)
|
||||
btn.post_message(Button.Pressed(btn))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
capture_dir = tmp_path / "captures"
|
||||
jpg_files = list(capture_dir.rglob("*.jpg"))
|
||||
assert len(jpg_files) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_log_updates(tmp_path: Path):
|
||||
"""Capture log should show entries after captures."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
# Two captures
|
||||
await pilot.press("c")
|
||||
await asyncio.sleep(0.3)
|
||||
await pilot.press("c")
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Status panel should show count
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
|
||||
assert panel._capture_count >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_toggles(tmp_path: Path):
|
||||
"""AOS/TCA/LOS trigger buttons should toggle active state."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
from textual.widgets import Button
|
||||
|
||||
from birdcage_tui.capture_triggers import TriggerType
|
||||
|
||||
# Toggle AOS on
|
||||
aos_btn = overlay.query_one("#btn-trigger-aos", Button)
|
||||
aos_btn.post_message(Button.Pressed(aos_btn))
|
||||
await pilot.pause()
|
||||
assert overlay._pass_detector.is_enabled(TriggerType.AOS)
|
||||
|
||||
# Toggle AOS off
|
||||
aos_btn.post_message(Button.Pressed(aos_btn))
|
||||
await pilot.pause()
|
||||
assert not overlay._pass_detector.is_enabled(TriggerType.AOS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_interval_start_stop(tmp_path: Path):
|
||||
"""Auto interval start/stop should control the timer."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
from textual.widgets import Button
|
||||
|
||||
# Start auto
|
||||
start_btn = overlay.query_one("#btn-auto-start", Button)
|
||||
start_btn.post_message(Button.Pressed(start_btn))
|
||||
await pilot.pause()
|
||||
assert overlay._auto_running
|
||||
|
||||
# Stop auto
|
||||
stop_btn = overlay.query_one("#btn-auto-stop", Button)
|
||||
stop_btn.post_message(Button.Pressed(stop_btn))
|
||||
await pilot.pause()
|
||||
assert not overlay._auto_running
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_camera_overlay_with_console(tmp_path: Path):
|
||||
"""F5 and F6 should open independent overlays."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
# Open camera first
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert app._camera_visible
|
||||
assert not app._console_visible
|
||||
|
||||
# Close camera
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert not app._camera_visible
|
||||
|
||||
# Open console
|
||||
await pilot.press("f5")
|
||||
await pilot.pause()
|
||||
assert app._console_visible
|
||||
assert not app._camera_visible
|
||||
|
||||
# Close console
|
||||
await pilot.press("f5")
|
||||
await pilot.pause()
|
||||
assert not app._console_visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_panel_shows_formats(tmp_path: Path):
|
||||
"""Status panel should list available output formats."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
|
||||
# Should always have JPEG + JSON at minimum
|
||||
assert "JPEG" in panel._formats
|
||||
assert "JSON" in panel._formats
|
||||
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
|
||||
283
tui/tests/test_capture_triggers.py
Normal file
283
tui/tests/test_capture_triggers.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""Test capture triggers — interval timer and pass event detection.
|
||||
|
||||
IntervalTimer fires at configurable intervals.
|
||||
PassEventDetector detects AOS/TCA/LOS transitions with hysteresis.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from birdcage_tui.capture_triggers import (
|
||||
IntervalTimer,
|
||||
PassEventDetector,
|
||||
TriggerEvent,
|
||||
TriggerType,
|
||||
)
|
||||
|
||||
|
||||
class _EventCollector:
|
||||
"""Collects trigger events for test assertions."""
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[TriggerEvent] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __call__(self, event: TriggerEvent) -> None:
|
||||
with self._lock:
|
||||
self.events.append(event)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self.events)
|
||||
|
||||
def types(self) -> list[TriggerType]:
|
||||
with self._lock:
|
||||
return [e.trigger_type for e in self.events]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IntervalTimer tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_interval_timer_fires():
|
||||
"""Timer should fire at least once within 2x the interval."""
|
||||
collector = _EventCollector()
|
||||
timer = IntervalTimer(callback=collector, interval_seconds=0.2)
|
||||
timer.start()
|
||||
time.sleep(0.5)
|
||||
timer.stop()
|
||||
|
||||
assert collector.count >= 1
|
||||
assert all(e.trigger_type == TriggerType.INTERVAL for e in collector.events)
|
||||
|
||||
|
||||
def test_interval_timer_stop():
|
||||
"""Timer should stop cleanly and not fire after stop()."""
|
||||
collector = _EventCollector()
|
||||
timer = IntervalTimer(callback=collector, interval_seconds=0.1)
|
||||
timer.start()
|
||||
time.sleep(0.25)
|
||||
timer.stop()
|
||||
|
||||
count_at_stop = collector.count
|
||||
time.sleep(0.3)
|
||||
assert collector.count == count_at_stop # No new events
|
||||
|
||||
|
||||
def test_interval_timer_set_interval():
|
||||
"""set_interval should update the interval property."""
|
||||
collector = _EventCollector()
|
||||
timer = IntervalTimer(callback=collector, interval_seconds=10.0)
|
||||
assert timer.interval == 10.0
|
||||
|
||||
timer.set_interval(5.0)
|
||||
assert timer.interval == 5.0
|
||||
|
||||
# Minimum clamp
|
||||
timer.set_interval(0.5)
|
||||
assert timer.interval >= 0.5
|
||||
|
||||
|
||||
def test_interval_timer_double_start():
|
||||
"""Starting twice should be safe (no duplicate threads)."""
|
||||
collector = _EventCollector()
|
||||
timer = IntervalTimer(callback=collector, interval_seconds=0.2)
|
||||
timer.start()
|
||||
timer.start() # Should be no-op
|
||||
time.sleep(0.5)
|
||||
timer.stop()
|
||||
|
||||
assert collector.count >= 1
|
||||
|
||||
|
||||
def test_interval_timer_is_running():
|
||||
"""is_running property should reflect timer state."""
|
||||
collector = _EventCollector()
|
||||
timer = IntervalTimer(callback=collector, interval_seconds=1.0)
|
||||
assert not timer.is_running
|
||||
|
||||
timer.start()
|
||||
assert timer.is_running
|
||||
|
||||
timer.stop()
|
||||
assert not timer.is_running
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PassEventDetector tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pass_detector_aos():
|
||||
"""WAITING -> TRACKING should fire AOS."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.AOS)
|
||||
|
||||
detector.update("WAITING", "ISS", 0.0)
|
||||
detector.update("TRACKING", "ISS", 20.0)
|
||||
|
||||
assert collector.count == 1
|
||||
assert collector.events[0].trigger_type == TriggerType.AOS
|
||||
assert "ISS" in collector.events[0].detail
|
||||
|
||||
|
||||
def test_pass_detector_los():
|
||||
"""TRACKING -> WAITING should fire LOS."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.LOS)
|
||||
|
||||
detector.update("TRACKING", "ISS", 45.0)
|
||||
detector.update("WAITING", "ISS", 5.0)
|
||||
|
||||
assert collector.count == 1
|
||||
assert collector.events[0].trigger_type == TriggerType.LOS
|
||||
|
||||
|
||||
def test_pass_detector_tca():
|
||||
"""Elevation peak detection with hysteresis."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.TCA)
|
||||
|
||||
# Simulate ascending pass
|
||||
detector.update("TRACKING", "ISS", 20.0)
|
||||
detector.update("TRACKING", "ISS", 30.0)
|
||||
detector.update("TRACKING", "ISS", 40.0)
|
||||
detector.update("TRACKING", "ISS", 45.0) # Peak
|
||||
detector.update("TRACKING", "ISS", 44.8) # Small jitter, no trigger
|
||||
detector.update("TRACKING", "ISS", 44.4) # Below threshold
|
||||
detector.update("TRACKING", "ISS", 43.0) # Well below peak
|
||||
|
||||
assert collector.count == 1
|
||||
assert collector.events[0].trigger_type == TriggerType.TCA
|
||||
assert "45.0" in collector.events[0].detail
|
||||
|
||||
|
||||
def test_pass_detector_hysteresis():
|
||||
"""Small jitter around peak should NOT trigger TCA."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.TCA)
|
||||
|
||||
# Simulate noisy plateau at peak
|
||||
detector.update("TRACKING", "ISS", 44.8)
|
||||
detector.update("TRACKING", "ISS", 45.0)
|
||||
detector.update("TRACKING", "ISS", 44.9) # -0.1 from peak
|
||||
detector.update("TRACKING", "ISS", 44.8) # -0.2 from peak
|
||||
detector.update("TRACKING", "ISS", 44.7) # -0.3 from peak
|
||||
|
||||
# Still within hysteresis threshold (0.5 deg)
|
||||
assert collector.count == 0
|
||||
|
||||
|
||||
def test_pass_detector_tca_fires_once():
|
||||
"""TCA should fire only once per pass."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.TCA)
|
||||
|
||||
detector.update("TRACKING", "ISS", 45.0) # Peak
|
||||
detector.update("TRACKING", "ISS", 44.0) # -1.0, triggers TCA
|
||||
detector.update("TRACKING", "ISS", 43.0) # Should NOT trigger again
|
||||
detector.update("TRACKING", "ISS", 42.0)
|
||||
|
||||
assert collector.count == 1
|
||||
|
||||
|
||||
def test_pass_detector_full_pass():
|
||||
"""Full pass should generate AOS + TCA + LOS in order."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.AOS, TriggerType.TCA, TriggerType.LOS)
|
||||
|
||||
# Pre-pass
|
||||
detector.update("WAITING", "ISS", 0.0)
|
||||
|
||||
# AOS
|
||||
detector.update("TRACKING", "ISS", 5.0)
|
||||
|
||||
# Ascending
|
||||
detector.update("TRACKING", "ISS", 20.0)
|
||||
detector.update("TRACKING", "ISS", 35.0)
|
||||
detector.update("TRACKING", "ISS", 45.0) # Peak
|
||||
|
||||
# Descending (triggers TCA)
|
||||
detector.update("TRACKING", "ISS", 44.0)
|
||||
detector.update("TRACKING", "ISS", 30.0)
|
||||
detector.update("TRACKING", "ISS", 10.0)
|
||||
|
||||
# LOS
|
||||
detector.update("WAITING", "ISS", 2.0)
|
||||
|
||||
types = collector.types()
|
||||
assert types == [TriggerType.AOS, TriggerType.TCA, TriggerType.LOS]
|
||||
|
||||
|
||||
def test_pass_detector_disabled_triggers():
|
||||
"""Disabled triggers should not fire."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
# Enable only AOS, not TCA or LOS
|
||||
detector.enable(TriggerType.AOS)
|
||||
|
||||
detector.update("WAITING", "ISS", 0.0)
|
||||
detector.update("TRACKING", "ISS", 45.0)
|
||||
detector.update("TRACKING", "ISS", 30.0)
|
||||
detector.update("WAITING", "ISS", 0.0)
|
||||
|
||||
# Only AOS should fire
|
||||
assert collector.count == 1
|
||||
assert collector.events[0].trigger_type == TriggerType.AOS
|
||||
|
||||
|
||||
def test_pass_detector_target_change_resets():
|
||||
"""Changing target should reset peak tracking."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.TCA)
|
||||
|
||||
# First target peaks at 45
|
||||
detector.update("TRACKING", "ISS", 45.0)
|
||||
|
||||
# Switch to new target — peak resets
|
||||
detector.update("TRACKING", "NOAA 19", 30.0)
|
||||
detector.update("TRACKING", "NOAA 19", 35.0) # New peak
|
||||
detector.update("TRACKING", "NOAA 19", 34.0) # -1.0, triggers TCA
|
||||
|
||||
assert collector.count == 1
|
||||
assert "NOAA 19" in collector.events[0].detail
|
||||
|
||||
|
||||
def test_pass_detector_reset():
|
||||
"""reset() should clear all internal state."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
detector.enable(TriggerType.AOS)
|
||||
|
||||
detector.update("TRACKING", "ISS", 45.0)
|
||||
detector.reset()
|
||||
|
||||
# After reset, a new TRACKING should fire AOS again
|
||||
detector.update("WAITING", "ISS", 0.0)
|
||||
detector.update("TRACKING", "ISS", 20.0)
|
||||
|
||||
assert collector.count == 2 # Initial + after reset
|
||||
|
||||
|
||||
def test_pass_detector_enable_disable():
|
||||
"""enable/disable should toggle trigger states."""
|
||||
collector = _EventCollector()
|
||||
detector = PassEventDetector(on_event=collector)
|
||||
|
||||
detector.enable(TriggerType.AOS, TriggerType.LOS)
|
||||
assert detector.is_enabled(TriggerType.AOS)
|
||||
assert detector.is_enabled(TriggerType.LOS)
|
||||
assert not detector.is_enabled(TriggerType.TCA)
|
||||
|
||||
detector.disable(TriggerType.AOS)
|
||||
assert not detector.is_enabled(TriggerType.AOS)
|
||||
assert detector.is_enabled(TriggerType.LOS)
|
||||
Loading…
Add table
Add a link
Reference in a new issue