"""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)