"""Tests for weather satellite auto-scheduler. Covers WeatherSatScheduler class, pass scheduling, timer management, and automatic capture execution. """ from __future__ import annotations import threading from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch import pytest from utils.weather_sat_scheduler import ( ScheduledPass, WeatherSatScheduler, _parse_utc_iso, get_weather_sat_scheduler, ) class TestScheduledPass: """Tests for ScheduledPass class.""" def test_scheduled_pass_initialization(self): """ScheduledPass should initialize from pass data.""" pass_data = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) assert sp.id == "NOAA-18_202401011200" assert sp.satellite == "NOAA-18" assert sp.name == "NOAA 18" assert sp.frequency == 137.9125 assert sp.mode == "APT" assert sp.max_el == 45.0 assert sp.duration == 15.0 assert sp.quality == "good" assert sp.status == "scheduled" assert sp.skipped is False def test_scheduled_pass_start_dt(self): """ScheduledPass.start_dt should parse ISO datetime.""" pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) assert sp.start_dt.year == 2024 assert sp.start_dt.month == 1 assert sp.start_dt.day == 1 assert sp.start_dt.hour == 12 assert sp.start_dt.tzinfo == timezone.utc def test_scheduled_pass_end_dt(self): """ScheduledPass.end_dt should parse ISO datetime.""" pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) assert sp.end_dt.year == 2024 assert sp.end_dt.minute == 15 def test_scheduled_pass_to_dict(self): """ScheduledPass.to_dict() should serialize correctly.""" pass_data = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) sp.status = "complete" data = sp.to_dict() assert data["id"] == "NOAA-18_202401011200" assert data["satellite"] == "NOAA-18" assert data["status"] == "complete" assert data["skipped"] is False class TestWeatherSatScheduler: """Tests for WeatherSatScheduler class.""" def test_scheduler_initialization(self): """Scheduler should initialize with defaults.""" scheduler = WeatherSatScheduler() assert scheduler.enabled is False assert scheduler._lat == 0.0 assert scheduler._lon == 0.0 assert scheduler._min_elevation == 15.0 assert scheduler._device == 0 assert scheduler._gain == 40.0 assert scheduler._bias_t is False assert scheduler._passes == [] def test_set_callbacks(self): """Scheduler should accept callbacks.""" scheduler = WeatherSatScheduler() progress_cb = MagicMock() event_cb = MagicMock() scheduler.set_callbacks(progress_cb, event_cb) assert scheduler._progress_callback == progress_cb assert scheduler._event_callback == event_cb @patch("utils.weather_sat_scheduler.WeatherSatScheduler._refresh_passes") def test_enable(self, mock_refresh): """enable() should start scheduler.""" scheduler = WeatherSatScheduler() result = scheduler.enable( lat=51.5, lon=-0.1, min_elevation=20.0, device=1, gain=35.0, bias_t=True, ) assert scheduler._enabled is True assert scheduler._lat == 51.5 assert scheduler._lon == -0.1 assert scheduler._min_elevation == 20.0 assert scheduler._device == 1 assert scheduler._gain == 35.0 assert scheduler._bias_t is True mock_refresh.assert_called_once() assert "enabled" in result def test_disable(self): """disable() should stop scheduler and cancel timers.""" scheduler = WeatherSatScheduler() scheduler._enabled = True # Add mock timer mock_timer = MagicMock() scheduler._refresh_timer = mock_timer # Add pass with timer pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) mock_pass_timer = MagicMock() mock_stop_timer = MagicMock() sp._timer = mock_pass_timer sp._stop_timer = mock_stop_timer scheduler._passes = [sp] result = scheduler.disable() assert scheduler._enabled is False assert scheduler._passes == [] mock_timer.cancel.assert_called_once() mock_pass_timer.cancel.assert_called_once() mock_stop_timer.cancel.assert_called_once() assert result["status"] == "disabled" def test_skip_pass_success(self): """skip_pass() should skip a scheduled pass.""" scheduler = WeatherSatScheduler() event_cb = MagicMock() scheduler.set_callbacks(MagicMock(), event_cb) pass_data = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) mock_pass_timer = MagicMock() sp._timer = mock_pass_timer scheduler._passes = [sp] result = scheduler.skip_pass("NOAA-18_202401011200") assert result is True assert sp.status == "skipped" assert sp.skipped is True mock_pass_timer.cancel.assert_called_once() event_cb.assert_called_once() def test_skip_pass_not_found(self): """skip_pass() should return False for non-existent pass.""" scheduler = WeatherSatScheduler() result = scheduler.skip_pass("NONEXISTENT") assert result is False def test_skip_pass_already_complete(self): """skip_pass() should not skip already complete passes.""" scheduler = WeatherSatScheduler() pass_data = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) sp.status = "complete" scheduler._passes = [sp] result = scheduler.skip_pass("NOAA-18_202401011200") assert result is False assert sp.status == "complete" def test_get_status(self): """get_status() should return scheduler state.""" scheduler = WeatherSatScheduler() scheduler._enabled = True scheduler._lat = 51.5 scheduler._lon = -0.1 scheduler._device = 0 scheduler._gain = 40.0 scheduler._bias_t = False scheduler._min_elevation = 15.0 pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._passes = [sp] status = scheduler.get_status() assert status["enabled"] is True assert status["observer"]["latitude"] == 51.5 assert status["observer"]["longitude"] == -0.1 assert status["device"] == 0 assert status["gain"] == 40.0 assert status["bias_t"] is False assert status["min_elevation"] == 15.0 assert status["scheduled_count"] == 1 assert status["total_passes"] == 1 def test_get_passes(self): """get_passes() should return list of scheduled passes.""" scheduler = WeatherSatScheduler() pass_data = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._passes = [sp] passes = scheduler.get_passes() assert len(passes) == 1 assert passes[0]["id"] == "NOAA-18_202401011200" @patch("utils.weather_sat_predict.predict_passes") @patch("threading.Timer") def test_refresh_passes(self, mock_timer, mock_predict): """_refresh_passes() should schedule future passes.""" now = datetime.now(timezone.utc) future_pass = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": (now + timedelta(hours=2)).isoformat(), "endTimeISO": (now + timedelta(hours=2, minutes=15)).isoformat(), "maxEl": 45.0, "duration": 15.0, "quality": "good", } mock_predict.return_value = [future_pass] mock_timer_instance = MagicMock() mock_timer.return_value = mock_timer_instance scheduler = WeatherSatScheduler() scheduler._enabled = True scheduler._lat = 51.5 scheduler._lon = -0.1 scheduler._refresh_passes() mock_predict.assert_called_once() assert len(scheduler._passes) == 1 assert scheduler._passes[0].satellite == "NOAA-18" mock_timer_instance.start.assert_called() @patch("utils.weather_sat_predict.predict_passes") def test_refresh_passes_skip_past(self, mock_predict): """_refresh_passes() should skip passes that already started.""" now = datetime.now(timezone.utc) past_pass = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": (now - timedelta(hours=1)).isoformat(), "endTimeISO": (now - timedelta(hours=1) + timedelta(minutes=15)).isoformat(), "maxEl": 45.0, "duration": 15.0, "quality": "good", } mock_predict.return_value = [past_pass] scheduler = WeatherSatScheduler() scheduler._enabled = True scheduler._lat = 51.5 scheduler._lon = -0.1 scheduler._refresh_passes() # Should not schedule past passes assert len(scheduler._passes) == 0 @patch("utils.weather_sat_predict.predict_passes") @patch("threading.Timer") def test_refresh_passes_active_window_triggers_immediately(self, mock_timer, mock_predict): """_refresh_passes() should trigger immediately during an active pass window.""" now = datetime.now(timezone.utc) active_pass = { "id": "NOAA-18_ACTIVE", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": (now - timedelta(minutes=2)).isoformat(), "endTimeISO": (now + timedelta(minutes=8)).isoformat(), "maxEl": 45.0, "duration": 10.0, "quality": "good", } mock_predict.return_value = [active_pass] pass_timer = MagicMock() refresh_timer = MagicMock() mock_timer.side_effect = [pass_timer, refresh_timer] scheduler = WeatherSatScheduler() scheduler._enabled = True scheduler._lat = 51.5 scheduler._lon = -0.1 scheduler._refresh_passes() assert len(scheduler._passes) == 1 first_delay = mock_timer.call_args_list[0][0][0] assert first_delay == pytest.approx(0.0, abs=0.01) pass_timer.start.assert_called_once() @patch("utils.weather_sat_predict.predict_passes") def test_refresh_passes_disabled(self, mock_predict): """_refresh_passes() should do nothing when disabled.""" scheduler = WeatherSatScheduler() scheduler._enabled = False scheduler._refresh_passes() mock_predict.assert_not_called() @patch("utils.weather_sat_predict.predict_passes") def test_refresh_passes_error_handling(self, mock_predict): """_refresh_passes() should handle prediction errors.""" mock_predict.side_effect = Exception("TLE error") scheduler = WeatherSatScheduler() scheduler._enabled = True scheduler._lat = 51.5 scheduler._lon = -0.1 # Should not raise scheduler._refresh_passes() assert len(scheduler._passes) == 0 @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") def test_execute_capture_disabled(self, mock_get): """_execute_capture() should do nothing when disabled.""" scheduler = WeatherSatScheduler() scheduler._enabled = False pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._execute_capture(sp) mock_get.assert_not_called() @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") def test_execute_capture_skipped(self, mock_get): """_execute_capture() should do nothing for skipped passes.""" scheduler = WeatherSatScheduler() scheduler._enabled = True pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) sp.skipped = True scheduler._execute_capture(sp) mock_get.assert_not_called() @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") def test_execute_capture_decoder_busy(self, mock_get): """_execute_capture() should skip when decoder is busy.""" scheduler = WeatherSatScheduler() scheduler._enabled = True event_cb = MagicMock() scheduler.set_callbacks(MagicMock(), event_cb) mock_decoder = MagicMock() mock_decoder.is_running = True mock_get.return_value = mock_decoder pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._execute_capture(sp) assert sp.status == "skipped" assert sp.skipped is True event_cb.assert_called_once() event_data = event_cb.call_args[0][0] assert event_data["type"] == "schedule_capture_skipped" assert event_data["reason"] == "sdr_busy" @patch("app.claim_sdr_device", return_value=None) @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") @patch("threading.Timer") def test_execute_capture_success(self, mock_timer, mock_get, mock_claim): """_execute_capture() should start capture.""" scheduler = WeatherSatScheduler() scheduler._enabled = True scheduler._device = 0 scheduler._gain = 40.0 scheduler._bias_t = False progress_cb = MagicMock() event_cb = MagicMock() scheduler.set_callbacks(progress_cb, event_cb) mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = (True, None) mock_get.return_value = mock_decoder mock_timer_instance = MagicMock() mock_timer.return_value = mock_timer_instance now = datetime.now(timezone.utc) pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": (now + timedelta(seconds=10)).isoformat(), "endTimeISO": (now + timedelta(minutes=15)).isoformat(), "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._execute_capture(sp) assert sp.status == "capturing" mock_decoder.set_callback.assert_called_once_with(progress_cb) call_kwargs = mock_decoder.start.call_args[1] assert call_kwargs["satellite"] == "NOAA-18" assert call_kwargs["device_index"] == 0 assert call_kwargs["gain"] == 40.0 assert call_kwargs["bias_t"] is False event_cb.assert_called_once() event_data = event_cb.call_args[0][0] assert event_data["type"] == "schedule_capture_start" @patch("app.claim_sdr_device", return_value=None) @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") def test_execute_capture_start_failed(self, mock_get, mock_claim): """_execute_capture() should handle start failure.""" scheduler = WeatherSatScheduler() scheduler._enabled = True event_cb = MagicMock() scheduler.set_callbacks(MagicMock(), event_cb) mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = (False, "Start failed") mock_get.return_value = mock_decoder pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._execute_capture(sp) assert sp.status == "skipped" event_cb.assert_called_once() event_data = event_cb.call_args[0][0] assert event_data["reason"] == "start_failed" @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") def test_stop_capture(self, mock_get): """_stop_capture() should stop decoder.""" scheduler = WeatherSatScheduler() mock_decoder = MagicMock() mock_decoder.is_running = True mock_get.return_value = mock_decoder pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._stop_capture(sp) mock_decoder.stop.assert_called_once() def test_on_capture_complete(self): """_on_capture_complete() should mark pass complete and emit event.""" scheduler = WeatherSatScheduler() event_cb = MagicMock() scheduler.set_callbacks(MagicMock(), event_cb) release_fn = MagicMock() pass_data = { "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": "2024-01-01T12:00:00+00:00", "endTimeISO": "2024-01-01T12:15:00+00:00", "maxEl": 45.0, "duration": 15.0, "quality": "good", } sp = ScheduledPass(pass_data) scheduler._on_capture_complete(sp, release_fn) assert sp.status == "complete" release_fn.assert_called_once() event_cb.assert_called_once() event_data = event_cb.call_args[0][0] assert event_data["type"] == "schedule_capture_complete" def test_emit_event(self): """_emit_event() should call event callback.""" scheduler = WeatherSatScheduler() event_cb = MagicMock() scheduler.set_callbacks(MagicMock(), event_cb) event = {"type": "test_event", "data": "test"} scheduler._emit_event(event) event_cb.assert_called_once_with(event) def test_emit_event_no_callback(self): """_emit_event() should handle missing callback.""" scheduler = WeatherSatScheduler() event = {"type": "test_event"} scheduler._emit_event(event) # Should not raise def test_emit_event_callback_exception(self): """_emit_event() should handle callback exceptions.""" scheduler = WeatherSatScheduler() event_cb = MagicMock(side_effect=Exception("Callback error")) scheduler.set_callbacks(MagicMock(), event_cb) event = {"type": "test_event"} scheduler._emit_event(event) # Should not raise class TestGlobalScheduler: """Tests for global scheduler singleton.""" def test_get_weather_sat_scheduler_singleton(self): """get_weather_sat_scheduler() should return singleton.""" import utils.weather_sat_scheduler as mod old = mod._scheduler mod._scheduler = None try: scheduler1 = get_weather_sat_scheduler() scheduler2 = get_weather_sat_scheduler() assert scheduler1 is scheduler2 finally: mod._scheduler = old def test_get_weather_sat_scheduler_thread_safe(self): """get_weather_sat_scheduler() should be thread-safe.""" import utils.weather_sat_scheduler as mod old = mod._scheduler mod._scheduler = None schedulers = [] def create_scheduler(): schedulers.append(get_weather_sat_scheduler()) try: threads = [threading.Thread(target=create_scheduler) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() # All should be the same instance assert all(s is schedulers[0] for s in schedulers) finally: mod._scheduler = old class TestSchedulerConfiguration: """Tests for scheduler configuration constants.""" def test_config_constants(self): """Scheduler should have configuration constants.""" from utils.weather_sat_scheduler import ( WEATHER_SAT_CAPTURE_BUFFER_SECONDS, WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, ) assert isinstance(WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, int) assert isinstance(WEATHER_SAT_CAPTURE_BUFFER_SECONDS, int) assert WEATHER_SAT_SCHEDULE_REFRESH_MINUTES > 0 assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0 class TestUtcIsoParsing: """Tests for UTC ISO timestamp parsing.""" def test_parse_utc_iso_with_z_suffix(self): """_parse_utc_iso should handle Z timestamps.""" dt = _parse_utc_iso("2026-02-19T12:34:56Z") assert dt.tzinfo == timezone.utc assert dt.hour == 12 assert dt.minute == 34 assert dt.second == 56 def test_parse_utc_iso_with_legacy_suffix(self): """_parse_utc_iso should handle legacy +00:00Z timestamps.""" dt = _parse_utc_iso("2026-02-19T12:34:56+00:00Z") assert dt.tzinfo == timezone.utc assert dt.hour == 12 class TestSchedulerIntegration: """Integration tests for scheduler.""" @patch("app.claim_sdr_device", return_value=None) @patch("utils.weather_sat_predict.predict_passes") @patch("utils.weather_sat_scheduler.get_weather_sat_decoder") @patch("threading.Timer") def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict, mock_claim): """Test complete scheduling cycle from enable to execute.""" now = datetime.now(timezone.utc) future_pass = { "id": "NOAA-18_202401011200", "satellite": "NOAA-18", "name": "NOAA 18", "frequency": 137.9125, "mode": "APT", "startTimeISO": (now + timedelta(hours=2)).isoformat(), "endTimeISO": (now + timedelta(hours=2, minutes=15)).isoformat(), "maxEl": 45.0, "duration": 15.0, "quality": "good", } mock_predict.return_value = [future_pass] mock_timer_instance = MagicMock() mock_timer.return_value = mock_timer_instance mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = (True, None) mock_get_decoder.return_value = mock_decoder scheduler = WeatherSatScheduler() progress_cb = MagicMock() event_cb = MagicMock() scheduler.set_callbacks(progress_cb, event_cb) # Enable scheduler result = scheduler.enable(lat=51.5, lon=-0.1) assert result["enabled"] is True assert len(scheduler._passes) == 1 assert scheduler._passes[0].satellite == "NOAA-18" # Simulate timer firing (capture start) scheduler._execute_capture(scheduler._passes[0]) assert scheduler._passes[0].status == "capturing" mock_decoder.start.assert_called_once() # Simulate completion release_fn = MagicMock() scheduler._on_capture_complete(scheduler._passes[0], release_fn) assert scheduler._passes[0].status == "complete" release_fn.assert_called_once() # Disable scheduler scheduler.disable() assert scheduler.enabled is False assert len(scheduler._passes) == 0