diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py new file mode 100644 index 0000000..1f48642 --- /dev/null +++ b/tests/test_weather_sat_decoder.py @@ -0,0 +1,643 @@ +"""Tests for WeatherSatDecoder class. + +Covers WeatherSatDecoder methods, subprocess management, progress callbacks, +and image handling. +""" + +from __future__ import annotations + +import os +import tempfile +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch, MagicMock, call, mock_open +import pytest + +from utils.weather_sat import ( + WeatherSatDecoder, + WeatherSatImage, + CaptureProgress, + WEATHER_SATELLITES, + get_weather_sat_decoder, + is_weather_sat_available, +) + + +class TestWeatherSatDecoder: + """Tests for WeatherSatDecoder class.""" + + def test_decoder_initialization(self): + """Decoder should initialize with default output directory.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + assert decoder.is_running is False + assert decoder.decoder_available == 'satdump' + assert decoder.current_satellite == '' + assert decoder.current_frequency == 0.0 + + def test_decoder_initialization_no_satdump(self): + """Decoder should detect when SatDump is unavailable.""" + with patch('shutil.which', return_value=None): + decoder = WeatherSatDecoder() + assert decoder.decoder_available is None + + def test_decoder_custom_output_dir(self): + """Decoder should accept custom output directory.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + custom_dir = '/tmp/custom_output' + decoder = WeatherSatDecoder(output_dir=custom_dir) + assert decoder._output_dir == Path(custom_dir) + + def test_set_callback(self): + """Decoder should accept progress callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + assert decoder._callback == callback + + def test_set_on_complete(self): + """Decoder should accept on_complete callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_on_complete(callback) + assert decoder._on_complete_callback == callback + + def test_start_no_decoder(self): + """start() should fail when no decoder available.""" + with patch('shutil.which', return_value=None): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert progress.status == 'error' + assert 'SatDump' in progress.message + + def test_start_invalid_satellite(self): + """start() should fail with invalid satellite.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert progress.status == 'error' + assert 'Unknown satellite' in progress.message + + @patch('subprocess.Popen') + @patch('pty.openpty') + @patch('utils.weather_sat.register_process') + def test_start_success(self, mock_register, mock_pty, mock_popen): + """start() should successfully start SatDump.""" + with patch('shutil.which', return_value='/usr/bin/satdump'), \ + patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'): + + mock_pty.return_value = (10, 11) + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start( + satellite='NOAA-18', + device_index=0, + gain=40.0, + bias_t=True, + ) + + assert success is True + assert decoder.is_running is True + assert decoder.current_satellite == 'NOAA-18' + assert decoder.current_frequency == 137.9125 + assert decoder.current_mode == 'APT' + assert decoder.device_index == 0 + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == 'satdump' + assert 'live' in cmd + assert 'noaa_apt' in cmd + assert '--bias' in cmd + + @patch('subprocess.Popen') + @patch('pty.openpty') + def test_start_already_running(self, mock_pty, mock_popen): + """start() should return True when already running.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + decoder._running = True + + success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + + assert success is True + mock_popen.assert_not_called() + + @patch('subprocess.Popen') + @patch('pty.openpty') + def test_start_exception_handling(self, mock_pty, mock_popen): + """start() should handle exceptions gracefully.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + mock_pty.return_value = (10, 11) + mock_popen.side_effect = OSError('Device not found') + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + + assert success is False + assert decoder.is_running is False + callback.assert_called() + progress = callback.call_args[0][0] + assert progress.status == 'error' + + def test_start_from_file_no_decoder(self): + """start_from_file() should fail when no decoder available.""" + with patch('shutil.which', return_value=None): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='data/test.wav', + ) + + assert success is False + callback.assert_called() + + @patch('subprocess.Popen') + @patch('pty.openpty') + @patch('pathlib.Path.is_file', return_value=True) + @patch('pathlib.Path.resolve') + def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen): + """start_from_file() should successfully decode from file.""" + with patch('shutil.which', return_value='/usr/bin/satdump'), \ + patch('utils.weather_sat.register_process'): + + # Mock path resolution + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_path.suffix = '.wav' + mock_resolve.return_value = mock_path + + mock_pty.return_value = (10, 11) + mock_process = MagicMock() + mock_popen.return_value = mock_process + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='data/test.wav', + sample_rate=1000000, + ) + + assert success is True + assert decoder.is_running is True + assert decoder.current_satellite == 'NOAA-18' + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == 'satdump' + assert 'noaa_apt' in cmd + assert 'audio_wav' in cmd + assert '--samplerate' in cmd + + @patch('pathlib.Path.resolve') + def test_start_from_file_path_traversal(self, mock_resolve): + """start_from_file() should block path traversal.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + # Mock path outside allowed directory + mock_path = MagicMock() + mock_path.is_relative_to.return_value = False + mock_resolve.return_value = mock_path + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='/etc/passwd', + ) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert 'data/ directory' in progress.message + + @patch('pathlib.Path.is_file', return_value=False) + @patch('pathlib.Path.resolve') + def test_start_from_file_not_found(self, mock_resolve, mock_is_file): + """start_from_file() should fail when file not found.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_resolve.return_value = mock_path + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='data/missing.wav', + ) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert 'not found' in progress.message.lower() + + def test_stop_not_running(self): + """stop() should be safe when not running.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + decoder.stop() # Should not raise + + @patch('utils.weather_sat.safe_terminate') + def test_stop_running(self, mock_terminate): + """stop() should terminate process.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + mock_process = MagicMock() + decoder._process = mock_process + decoder._running = True + decoder._pty_master_fd = 10 + + with patch('os.close') as mock_close: + decoder.stop() + + assert decoder._running is False + mock_terminate.assert_called_once_with(mock_process) + mock_close.assert_called_once_with(10) + + def test_get_images_empty(self): + """get_images() should return empty list initially.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + images = decoder.get_images() + assert images == [] + + @patch('pathlib.Path.glob') + @patch('pathlib.Path.stat') + def test_get_images_scans_directory(self, mock_stat, mock_glob): + """get_images() should scan output directory.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + # Mock image files + mock_file = MagicMock() + mock_file.name = 'NOAA-18_test.png' + mock_file.stat.return_value.st_size = 10000 + mock_file.stat.return_value.st_mtime = time.time() + mock_glob.return_value = [mock_file] + + images = decoder.get_images() + + assert len(images) == 1 + assert images[0].filename == 'NOAA-18_test.png' + assert images[0].satellite == 'NOAA-18' + + def test_delete_image_success(self): + """delete_image() should delete file.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + with patch('pathlib.Path.exists', return_value=True), \ + patch('pathlib.Path.unlink') as mock_unlink: + + result = decoder.delete_image('test.png') + + assert result is True + mock_unlink.assert_called_once() + + def test_delete_image_not_found(self): + """delete_image() should return False for non-existent file.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + with patch('pathlib.Path.exists', return_value=False): + result = decoder.delete_image('missing.png') + + assert result is False + + def test_delete_all_images(self): + """delete_all_images() should delete all images.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + mock_files = [MagicMock() for _ in range(3)] + with patch('pathlib.Path.glob', return_value=mock_files): + count = decoder.delete_all_images() + + assert count == 3 + for f in mock_files: + f.unlink.assert_called_once() + + def test_get_status_idle(self): + """get_status() should return idle status.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + status = decoder.get_status() + + assert status['available'] is True + assert status['decoder'] == 'satdump' + assert status['running'] is False + assert status['satellite'] == '' + + def test_get_status_running(self): + """get_status() should return running status.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + decoder._running = True + decoder._current_satellite = 'NOAA-18' + decoder._current_frequency = 137.9125 + decoder._current_mode = 'APT' + decoder._capture_start_time = time.time() - 60 + + status = decoder.get_status() + + assert status['running'] is True + assert status['satellite'] == 'NOAA-18' + assert status['frequency'] == 137.9125 + assert status['mode'] == 'APT' + assert status['elapsed_seconds'] >= 60 + + def test_classify_log_type_error(self): + """_classify_log_type() should detect errors.""" + assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error' + assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error' + + def test_classify_log_type_progress(self): + """_classify_log_type() should detect progress.""" + assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress' + + def test_classify_log_type_save(self): + """_classify_log_type() should detect save events.""" + assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save' + assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save' + + def test_classify_log_type_signal(self): + """_classify_log_type() should detect signal events.""" + assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal' + assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal' + + def test_classify_log_type_warning(self): + """_classify_log_type() should detect warnings.""" + assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning' + + def test_classify_log_type_debug(self): + """_classify_log_type() should detect debug messages.""" + assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug' + + @patch('subprocess.run') + def test_resolve_device_id_success(self, mock_run): + """_resolve_device_id() should extract serial from rtl_test.""" + mock_result = MagicMock() + mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000' + mock_result.stderr = '' + mock_run.return_value = mock_result + + serial = WeatherSatDecoder._resolve_device_id(0) + + assert serial == '00004000' + mock_run.assert_called_once() + + @patch('subprocess.run') + def test_resolve_device_id_fallback(self, mock_run): + """_resolve_device_id() should fall back to index string.""" + mock_run.side_effect = FileNotFoundError + + serial = WeatherSatDecoder._resolve_device_id(0) + + assert serial == '0' + + def test_parse_product_name_rgb(self): + """_parse_product_name() should identify RGB composite.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png')) + assert product == 'RGB Composite' + + def test_parse_product_name_thermal(self): + """_parse_product_name() should identify thermal imagery.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png')) + assert product == 'Thermal' + + def test_parse_product_name_channel(self): + """_parse_product_name() should identify channel images.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/channel_3.png')) + assert product == 'Channel 3' + + def test_parse_product_name_unknown(self): + """_parse_product_name() should return stem for unknown products.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png')) + assert product == 'unknown_image' + + def test_emit_progress(self): + """_emit_progress() should call callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + progress = CaptureProgress(status='capturing', message='Test') + decoder._emit_progress(progress) + + callback.assert_called_once_with(progress) + + def test_emit_progress_no_callback(self): + """_emit_progress() should handle missing callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + progress = CaptureProgress(status='capturing', message='Test') + decoder._emit_progress(progress) # Should not raise + + def test_emit_progress_callback_exception(self): + """_emit_progress() should handle callback exceptions.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock(side_effect=Exception('Callback error')) + decoder.set_callback(callback) + + progress = CaptureProgress(status='capturing', message='Test') + decoder._emit_progress(progress) # Should not raise + + +class TestWeatherSatImage: + """Tests for WeatherSatImage dataclass.""" + + def test_to_dict(self): + """WeatherSatImage.to_dict() should serialize correctly.""" + image = WeatherSatImage( + filename='test.png', + path=Path('/tmp/test.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + frequency=137.9125, + size_bytes=12345, + product='RGB Composite', + ) + + data = image.to_dict() + + assert data['filename'] == 'test.png' + assert data['satellite'] == 'NOAA-18' + assert data['mode'] == 'APT' + assert data['timestamp'] == '2024-01-01T12:00:00+00:00' + assert data['frequency'] == 137.9125 + assert data['size_bytes'] == 12345 + assert data['product'] == 'RGB Composite' + assert data['url'] == '/weather-sat/images/test.png' + + +class TestCaptureProgress: + """Tests for CaptureProgress dataclass.""" + + def test_to_dict_minimal(self): + """CaptureProgress.to_dict() with minimal fields.""" + progress = CaptureProgress(status='idle') + data = progress.to_dict() + + assert data['type'] == 'weather_sat_progress' + assert data['status'] == 'idle' + assert data['satellite'] == '' + assert data['message'] == '' + assert data['progress'] == 0 + + def test_to_dict_complete(self): + """CaptureProgress.to_dict() with all fields.""" + image = WeatherSatImage( + filename='test.png', + path=Path('/tmp/test.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.9125, + ) + + progress = CaptureProgress( + status='complete', + satellite='NOAA-18', + frequency=137.9125, + mode='APT', + message='Capture complete', + progress_percent=100, + elapsed_seconds=600, + image=image, + log_type='info', + capture_phase='complete', + ) + + data = progress.to_dict() + + assert data['status'] == 'complete' + assert data['satellite'] == 'NOAA-18' + assert data['frequency'] == 137.9125 + assert data['mode'] == 'APT' + assert data['message'] == 'Capture complete' + assert data['progress'] == 100 + assert data['elapsed_seconds'] == 600 + assert 'image' in data + assert data['log_type'] == 'info' + assert data['capture_phase'] == 'complete' + + +class TestGlobalFunctions: + """Tests for global utility functions.""" + + def test_get_weather_sat_decoder_singleton(self): + """get_weather_sat_decoder() should return singleton.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + import utils.weather_sat as mod + old = mod._decoder + mod._decoder = None + + try: + decoder1 = get_weather_sat_decoder() + decoder2 = get_weather_sat_decoder() + + assert decoder1 is decoder2 + finally: + mod._decoder = old + + def test_is_weather_sat_available_true(self): + """is_weather_sat_available() should return True when available.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + import utils.weather_sat as mod + old = mod._decoder + mod._decoder = None + + try: + assert is_weather_sat_available() is True + finally: + mod._decoder = old + + def test_is_weather_sat_available_false(self): + """is_weather_sat_available() should return False when unavailable.""" + with patch('shutil.which', return_value=None): + import utils.weather_sat as mod + old = mod._decoder + mod._decoder = None + + try: + assert is_weather_sat_available() is False + finally: + mod._decoder = old + + +class TestWeatherSatellitesConstant: + """Tests for WEATHER_SATELLITES constant.""" + + def test_weather_satellites_structure(self): + """WEATHER_SATELLITES should have correct structure.""" + assert 'NOAA-18' in WEATHER_SATELLITES + sat = WEATHER_SATELLITES['NOAA-18'] + + assert 'name' in sat + assert 'frequency' in sat + assert 'mode' in sat + assert 'pipeline' in sat + assert 'tle_key' in sat + assert 'description' in sat + assert 'active' in sat + + def test_noaa_satellites(self): + """NOAA satellites should have correct frequencies.""" + assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620 + assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125 + assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100 + + def test_meteor_satellite(self): + """Meteor satellite should use LRPT mode.""" + meteor = WEATHER_SATELLITES['METEOR-M2-3'] + assert meteor['mode'] == 'LRPT' + assert meteor['frequency'] == 137.900 + assert meteor['pipeline'] == 'meteor_m2-x_lrpt' diff --git a/tests/test_weather_sat_predict.py b/tests/test_weather_sat_predict.py new file mode 100644 index 0000000..97c2e29 --- /dev/null +++ b/tests/test_weather_sat_predict.py @@ -0,0 +1,675 @@ +"""Tests for weather satellite pass prediction. + +Covers predict_passes() function, TLE handling, trajectory computation, +and ground track generation. +""" + +from __future__ import annotations + +from datetime import datetime, timezone, timedelta +from unittest.mock import patch, MagicMock +import pytest + +from utils.weather_sat_predict import predict_passes + + +class TestPredictPasses: + """Tests for predict_passes() function.""" + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + def test_predict_passes_no_tle_data(self, mock_tle, mock_load): + """predict_passes() should handle missing TLE data.""" + mock_tle.get.return_value = None + mock_ts = MagicMock() + mock_ts.now.return_value = MagicMock() + mock_ts.utc.return_value = MagicMock() + mock_load.timescale.return_value = mock_ts + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert passes == [] + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load): + """predict_passes() should predict basic passes.""" + # Mock timescale + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + # Mock TLE data + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + # Mock observer + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + # Mock satellite + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + # Mock pass detection - one pass + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + # Mock topocentric calculations + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + pass_data = passes[0] + assert pass_data['satellite'] == 'NOAA-18' + assert pass_data['name'] == 'NOAA 18' + assert pass_data['frequency'] == 137.9125 + assert pass_data['mode'] == 'APT' + assert 'maxEl' in pass_data + assert 'duration' in pass_data + assert 'quality' in pass_data + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_below_min_elevation( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should filter passes below min elevation.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + # Mock low elevation pass + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 10.0 # Below min_elevation of 15 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 0 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_with_trajectory( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should include trajectory when requested.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes( + lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True + ) + + assert len(passes) == 1 + assert 'trajectory' in passes[0] + assert len(passes[0]['trajectory']) == 30 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_with_ground_track( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should include ground track when requested.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + # Mock geocentric position + def mock_at(t): + geocentric = MagicMock() + return geocentric + + mock_satellite_obj.at.side_effect = mock_at + + # Mock subpoint + mock_subpoint = MagicMock() + mock_lat = MagicMock() + mock_lat.degrees = 51.5 + mock_lon = MagicMock() + mock_lon.degrees = -0.1 + mock_subpoint.latitude = mock_lat + mock_subpoint.longitude = mock_lon + mock_wgs84.subpoint.return_value = mock_subpoint + + passes = predict_passes( + lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True + ) + + assert len(passes) == 1 + assert 'groundTrack' in passes[0] + assert len(passes[0]['groundTrack']) == 60 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_quality_excellent( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should mark high elevation passes as excellent.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 75.0 # Excellent pass + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + assert passes[0]['quality'] == 'excellent' + assert passes[0]['maxEl'] >= 60 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_quality_good( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should mark medium elevation passes as good.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 # Good pass + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + assert passes[0]['quality'] == 'good' + assert 30 <= passes[0]['maxEl'] < 60 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_quality_fair( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should mark low elevation passes as fair.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 20.0 # Fair pass + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + assert passes[0]['quality'] == 'fair' + assert passes[0]['maxEl'] < 30 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_inactive_satellite( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should skip inactive satellites.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_load.timescale.return_value = mock_ts + + # Temporarily mark satellite as inactive + from utils.weather_sat import WEATHER_SATELLITES + original_active = WEATHER_SATELLITES['NOAA-18']['active'] + WEATHER_SATELLITES['NOAA-18']['active'] = False + + try: + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + # Should not include NOAA-18 + noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18'] + assert len(noaa_18_passes) == 0 + finally: + WEATHER_SATELLITES['NOAA-18']['active'] = original_active + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_exception_handling( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should handle exceptions gracefully.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + # Make find_discrete raise exception + mock_find.side_effect = Exception('Computation error') + + # Should not raise, just skip this satellite + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + # May include passes from other satellites or be empty + assert isinstance(passes, list) + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load): + """predict_passes() should use live TLE cache if available.""" + with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}): + mock_ts = MagicMock() + mock_ts.now.return_value = MagicMock() + mock_ts.utc.return_value = MagicMock() + mock_load.timescale.return_value = mock_ts + + # Even though TLE_SATELLITES is mocked, should use _tle_cache + with patch('utils.weather_sat_predict.wgs84'), \ + patch('utils.weather_sat_predict.EarthSatellite'), \ + patch('utils.weather_sat_predict.find_discrete', return_value=([], [])): + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + # Should not raise + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_sorted_by_time( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should return passes sorted by start time.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + # Two passes + rise1 = MagicMock() + rise1.utc_datetime.return_value = now + timedelta(hours=4) + set1 = MagicMock() + set1.utc_datetime.return_value = now + timedelta(hours=4, minutes=15) + rise2 = MagicMock() + rise2.utc_datetime.return_value = now + timedelta(hours=2) + set2 = MagicMock() + set2.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + # Return in non-chronological order + mock_find.return_value = ([rise1, set1, rise2, set2], [True, False, True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + # Should be sorted with earliest pass first + if len(passes) >= 2: + assert passes[0]['startTimeISO'] < passes[1]['startTimeISO'] + + @staticmethod + def _mock_time(dt): + """Helper to create mock time object.""" + mock_t = MagicMock() + if isinstance(dt, datetime): + mock_t.utc_datetime.return_value = dt + else: + mock_t.utc_datetime.return_value = datetime.now(timezone.utc) + return mock_t + + +class TestPassDataStructure: + """Tests for pass data structure.""" + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_pass_data_fields( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """Pass data should contain all required fields.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: TestPredictPasses._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + pass_data = passes[0] + + # Check all required fields + required_fields = [ + 'id', 'satellite', 'name', 'frequency', 'mode', + 'startTime', 'startTimeISO', 'endTimeISO', + 'maxEl', 'maxElAz', 'riseAz', 'setAz', + 'duration', 'quality' + ] + for field in required_fields: + assert field in pass_data, f"Missing required field: {field}" + + def test_import_error_propagates(self): + """predict_passes() should raise ImportError if skyfield unavailable.""" + with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}): + with pytest.raises((ImportError, AttributeError)): + predict_passes(lat=51.5, lon=-0.1) diff --git a/tests/test_weather_sat_routes.py b/tests/test_weather_sat_routes.py new file mode 100644 index 0000000..7f13aca --- /dev/null +++ b/tests/test_weather_sat_routes.py @@ -0,0 +1,801 @@ +"""Tests for weather satellite routes. + +Covers all weather_sat endpoints: /status, /satellites, /start, /test-decode, +/stop, /images, /passes, and scheduler endpoints. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open +import pytest + +from utils.weather_sat import WeatherSatImage, WEATHER_SATELLITES +from datetime import datetime, timezone + + +class TestWeatherSatRoutes: + """Tests for weather satellite routes.""" + + def test_get_status(self, client): + """GET /weather-sat/status returns decoder status.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.get_status.return_value = { + 'available': True, + 'decoder': 'satdump', + 'running': False, + 'satellite': '', + 'frequency': 0.0, + 'mode': '', + 'elapsed_seconds': 0, + 'image_count': 0, + } + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/status') + assert response.status_code == 200 + data = response.get_json() + assert data['available'] is True + assert data['decoder'] == 'satdump' + assert data['running'] is False + + def test_list_satellites(self, client): + """GET /weather-sat/satellites returns satellite list.""" + response = client.get('/weather-sat/satellites') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert 'satellites' in data + assert len(data['satellites']) > 0 + + # Check structure + sat = data['satellites'][0] + assert 'key' in sat + assert 'name' in sat + assert 'frequency' in sat + assert 'mode' in sat + assert 'description' in sat + assert 'active' in sat + + # Verify NOAA-18 is in list + noaa_18 = next((s for s in data['satellites'] if s['key'] == 'NOAA-18'), None) + assert noaa_18 is not None + assert noaa_18['frequency'] == 137.9125 + assert noaa_18['mode'] == 'APT' + + def test_start_capture_success(self, client): + """POST /weather-sat/start successfully starts capture.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('routes.weather_sat.queue.Queue') as mock_queue: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + } + + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['satellite'] == 'NOAA-18' + assert data['frequency'] == 137.9125 + assert data['mode'] == 'APT' + assert data['device'] == 0 + + mock_decoder.start.assert_called_once_with( + satellite='NOAA-18', + device_index=0, + gain=40.0, + bias_t=False, + ) + + def test_start_capture_no_satdump(self, client): + """POST /weather-sat/start returns error when SatDump unavailable.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=False): + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'SatDump not installed' in data['message'] + + def test_start_capture_already_running(self, client): + """POST /weather-sat/start when already running.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = True + mock_decoder.current_satellite = 'NOAA-19' + mock_decoder.current_frequency = 137.100 + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'already_running' + assert data['satellite'] == 'NOAA-19' + + def test_start_capture_invalid_satellite(self, client): + """POST /weather-sat/start with invalid satellite.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'FAKE-SAT-99'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Invalid satellite' in data['message'] + + def test_start_capture_invalid_device(self, client): + """POST /weather-sat/start with invalid device index.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18', 'device': -1} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_start_capture_invalid_gain(self, client): + """POST /weather-sat/start with invalid gain.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18', 'gain': 999} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_start_capture_device_busy(self, client): + """POST /weather-sat/start when SDR device is busy.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('app.claim_sdr_device', return_value='Device busy with pager') as mock_claim: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 409 + data = response.get_json() + assert data['status'] == 'error' + assert data['error_type'] == 'DEVICE_BUSY' + assert 'Device busy' in data['message'] + + def test_start_capture_start_failure(self, client): + """POST /weather-sat/start when decoder.start() fails.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 500 + data = response.get_json() + assert data['status'] == 'error' + assert 'Failed to start capture' in data['message'] + + def test_test_decode_success(self, client): + """POST /weather-sat/test-decode successfully starts file decode.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.is_file', return_value=True), \ + patch('pathlib.Path.resolve') as mock_resolve: + + # Mock path resolution to be under data/ + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_resolve.return_value = mock_path + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start_from_file.return_value = True + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': 'data/weather_sat/test.wav', + 'sample_rate': 1000000, + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['satellite'] == 'NOAA-18' + assert data['source'] == 'file' + + def test_test_decode_invalid_path(self, client): + """POST /weather-sat/test-decode with path outside data/.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.resolve') as mock_resolve: + + # Mock path outside allowed directory + mock_path = MagicMock() + mock_path.is_relative_to.return_value = False + mock_resolve.return_value = mock_path + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': '/etc/passwd', + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 403 + data = response.get_json() + assert data['status'] == 'error' + assert 'data/ directory' in data['message'] + + def test_test_decode_file_not_found(self, client): + """POST /weather-sat/test-decode with non-existent file.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.is_file', return_value=False), \ + patch('pathlib.Path.resolve') as mock_resolve: + + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_resolve.return_value = mock_path + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': 'data/missing.wav', + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.get_json() + assert data['status'] == 'error' + assert 'not found' in data['message'].lower() + + def test_test_decode_invalid_sample_rate(self, client): + """POST /weather-sat/test-decode with invalid sample rate.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': 'data/test.wav', + 'sample_rate': 100, # Too low + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'sample_rate' in data['message'] + + def test_stop_capture(self, client): + """POST /weather-sat/stop stops capture.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.device_index = 0 + mock_get.return_value = mock_decoder + + response = client.post('/weather-sat/stop') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'stopped' + mock_decoder.stop.assert_called_once() + + def test_list_images_empty(self, client): + """GET /weather-sat/images with no images.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.get_images.return_value = [] + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['images'] == [] + assert data['count'] == 0 + + def test_list_images_with_data(self, client): + """GET /weather-sat/images with images.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + image = WeatherSatImage( + filename='NOAA-18_test.png', + path=Path('/tmp/test.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + frequency=137.9125, + size_bytes=12345, + product='RGB Composite', + ) + mock_decoder.get_images.return_value = [image] + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 1 + assert data['images'][0]['filename'] == 'NOAA-18_test.png' + assert data['images'][0]['satellite'] == 'NOAA-18' + + def test_list_images_with_filter(self, client): + """GET /weather-sat/images with satellite filter.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + image1 = WeatherSatImage( + filename='NOAA-18_test.png', + path=Path('/tmp/test1.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.9125, + ) + image2 = WeatherSatImage( + filename='NOAA-19_test.png', + path=Path('/tmp/test2.png'), + satellite='NOAA-19', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.100, + ) + mock_decoder.get_images.return_value = [image1, image2] + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images?satellite=NOAA-18') + assert response.status_code == 200 + data = response.get_json() + assert data['count'] == 1 + assert data['images'][0]['satellite'] == 'NOAA-18' + + def test_list_images_with_limit(self, client): + """GET /weather-sat/images with limit.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + images = [ + WeatherSatImage( + filename=f'test{i}.png', + path=Path(f'/tmp/test{i}.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.9125, + ) + for i in range(10) + ] + mock_decoder.get_images.return_value = images + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images?limit=5') + assert response.status_code == 200 + data = response.get_json() + assert data['count'] == 5 + + def test_get_image_success(self, client): + """GET /weather-sat/images/ serves image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('routes.weather_sat.send_file') as mock_send, \ + patch('pathlib.Path.exists', return_value=True): + + mock_decoder = MagicMock() + mock_decoder._output_dir = Path('/tmp') + mock_get.return_value = mock_decoder + mock_send.return_value = MagicMock() + + response = client.get('/weather-sat/images/test_image.png') + mock_send.assert_called_once() + call_args = mock_send.call_args + assert call_args[1]['mimetype'] == 'image/png' + + def test_get_image_invalid_filename(self, client): + """GET /weather-sat/images/ with invalid filename.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images/../../../etc/passwd') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Invalid filename' in data['message'] + + def test_get_image_wrong_extension(self, client): + """GET /weather-sat/images/ with wrong extension.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images/test.txt') + assert response.status_code == 400 + data = response.get_json() + assert 'PNG/JPG' in data['message'] + + def test_get_image_not_found(self, client): + """GET /weather-sat/images/ for non-existent image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.exists', return_value=False): + + mock_decoder = MagicMock() + mock_decoder._output_dir = Path('/tmp') + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images/missing.png') + assert response.status_code == 404 + + def test_delete_image_success(self, client): + """DELETE /weather-sat/images/ deletes image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.delete_image.return_value = True + mock_get.return_value = mock_decoder + + response = client.delete('/weather-sat/images/test.png') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'deleted' + assert data['filename'] == 'test.png' + + def test_delete_image_not_found(self, client): + """DELETE /weather-sat/images/ for non-existent image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.delete_image.return_value = False + mock_get.return_value = mock_decoder + + response = client.delete('/weather-sat/images/missing.png') + assert response.status_code == 404 + + def test_delete_all_images(self, client): + """DELETE /weather-sat/images deletes all images.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.delete_all_images.return_value = 5 + mock_get.return_value = mock_decoder + + response = client.delete('/weather-sat/images') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['deleted'] == 5 + + def test_stream_progress(self, client): + """GET /weather-sat/stream returns SSE stream.""" + response = client.get('/weather-sat/stream') + assert response.status_code == 200 + assert response.mimetype == 'text/event-stream' + assert response.headers['Cache-Control'] == 'no-cache' + + def test_get_passes_missing_params(self, client): + """GET /weather-sat/passes without required params.""" + response = client.get('/weather-sat/passes') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'latitude and longitude' in data['message'] + + def test_get_passes_invalid_coords(self, client): + """GET /weather-sat/passes with invalid coordinates.""" + response = client.get('/weather-sat/passes?latitude=999&longitude=0') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_get_passes_success(self, client): + """GET /weather-sat/passes successfully predicts passes.""" + with patch('routes.weather_sat.predict_passes') as mock_predict: + mock_predict.return_value = [ + { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTime': '2024-01-01 12:00 UTC', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'maxElAz': 180.0, + 'riseAz': 160.0, + 'setAz': 200.0, + 'duration': 15.0, + 'quality': 'good', + } + ] + + response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 1 + assert data['passes'][0]['satellite'] == 'NOAA-18' + + def test_get_passes_with_options(self, client): + """GET /weather-sat/passes with trajectory and ground track.""" + with patch('routes.weather_sat.predict_passes') as mock_predict: + mock_predict.return_value = [] + + response = client.get( + '/weather-sat/passes?latitude=51.5&longitude=-0.1&' + 'hours=48&min_elevation=20&trajectory=true&ground_track=true' + ) + assert response.status_code == 200 + + mock_predict.assert_called_once() + call_kwargs = mock_predict.call_args[1] + assert call_kwargs['lat'] == 51.5 + assert call_kwargs['lon'] == -0.1 + assert call_kwargs['hours'] == 48 + assert call_kwargs['min_elevation'] == 20.0 + assert call_kwargs['include_trajectory'] is True + assert call_kwargs['include_ground_track'] is True + + def test_get_passes_import_error(self, client): + """GET /weather-sat/passes when skyfield not installed.""" + with patch('routes.weather_sat.predict_passes', side_effect=ImportError): + response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + assert response.status_code == 503 + data = response.get_json() + assert data['status'] == 'error' + assert 'skyfield' in data['message'] + + def test_get_passes_prediction_error(self, client): + """GET /weather-sat/passes when prediction fails.""" + with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')): + response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + assert response.status_code == 500 + data = response.get_json() + assert data['status'] == 'error' + + +class TestWeatherSatScheduler: + """Tests for weather satellite scheduler endpoints.""" + + def test_enable_schedule_success(self, client): + """POST /weather-sat/schedule/enable enables scheduler.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.enable.return_value = { + 'enabled': True, + 'observer': {'latitude': 51.5, 'longitude': -0.1}, + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + 'min_elevation': 15.0, + 'scheduled_count': 3, + 'total_passes': 3, + } + mock_get.return_value = mock_scheduler + + payload = { + 'latitude': 51.5, + 'longitude': -0.1, + 'min_elevation': 15, + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + } + + response = client.post( + '/weather-sat/schedule/enable', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['enabled'] is True + + def test_enable_schedule_missing_coords(self, client): + """POST /weather-sat/schedule/enable without coordinates.""" + payload = {'device': 0} + response = client.post( + '/weather-sat/schedule/enable', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'latitude and longitude' in data['message'] + + def test_enable_schedule_invalid_coords(self, client): + """POST /weather-sat/schedule/enable with invalid coordinates.""" + payload = {'latitude': 999, 'longitude': 0} + response = client.post( + '/weather-sat/schedule/enable', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_disable_schedule(self, client): + """POST /weather-sat/schedule/disable disables scheduler.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.disable.return_value = {'status': 'disabled'} + mock_get.return_value = mock_scheduler + + response = client.post('/weather-sat/schedule/disable') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'disabled' + + def test_schedule_status(self, client): + """GET /weather-sat/schedule/status returns scheduler status.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.get_status.return_value = { + 'enabled': False, + 'observer': {'latitude': 0, 'longitude': 0}, + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + 'min_elevation': 15.0, + 'scheduled_count': 0, + 'total_passes': 0, + } + mock_get.return_value = mock_scheduler + + response = client.get('/weather-sat/schedule/status') + assert response.status_code == 200 + data = response.get_json() + assert 'enabled' in data + + def test_schedule_passes(self, client): + """GET /weather-sat/schedule/passes lists scheduled passes.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.get_passes.return_value = [ + { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'status': 'scheduled', + } + ] + mock_get.return_value = mock_scheduler + + response = client.get('/weather-sat/schedule/passes') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 1 + + def test_skip_pass_success(self, client): + """POST /weather-sat/schedule/skip/ skips a pass.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.skip_pass.return_value = True + mock_get.return_value = mock_scheduler + + response = client.post('/weather-sat/schedule/skip/NOAA-18_202401011200') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'skipped' + assert data['pass_id'] == 'NOAA-18_202401011200' + + def test_skip_pass_not_found(self, client): + """POST /weather-sat/schedule/skip/ for non-existent pass.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.skip_pass.return_value = False + mock_get.return_value = mock_scheduler + + response = client.post('/weather-sat/schedule/skip/nonexistent') + assert response.status_code == 404 + + def test_skip_pass_invalid_id(self, client): + """POST /weather-sat/schedule/skip/ with invalid ID.""" + response = client.post('/weather-sat/schedule/skip/../../../etc/passwd') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Invalid pass ID' in data['message'] diff --git a/tests/test_weather_sat_scheduler.py b/tests/test_weather_sat_scheduler.py new file mode 100644 index 0000000..6f079b3 --- /dev/null +++ b/tests/test_weather_sat_scheduler.py @@ -0,0 +1,779 @@ +"""Tests for weather satellite auto-scheduler. + +Covers WeatherSatScheduler class, pass scheduling, timer management, +and automatic capture execution. +""" + +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone, timedelta +from unittest.mock import patch, MagicMock, call +import pytest + +from utils.weather_sat_scheduler import ( + WeatherSatScheduler, + ScheduledPass, + 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) + sp._timer = MagicMock() + sp._stop_timer = MagicMock() + scheduler._passes = [sp] + + result = scheduler.disable() + + assert scheduler._enabled is False + assert scheduler._passes == [] + mock_timer.cancel.assert_called_once() + sp._timer.cancel.assert_called_once() + sp._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) + sp._timer = MagicMock() + scheduler._passes = [sp] + + result = scheduler.skip_pass('NOAA-18_202401011200') + + assert result is True + assert sp.status == 'skipped' + assert sp.skipped is True + sp._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_scheduler.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_scheduler.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_scheduler.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_scheduler.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('utils.weather_sat_scheduler.get_weather_sat_decoder') + @patch('threading.Timer') + def test_execute_capture_success(self, mock_timer, mock_get): + """_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 + 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) + mock_decoder.start.assert_called_once_with( + satellite='NOAA-18', + device_index=0, + gain=40.0, + bias_t=False, + ) + event_cb.assert_called_once() + event_data = event_cb.call_args[0][0] + assert event_data['type'] == 'schedule_capture_start' + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + def test_execute_capture_start_failed(self, mock_get): + """_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 + 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_SCHEDULE_REFRESH_MINUTES, + WEATHER_SAT_CAPTURE_BUFFER_SECONDS, + ) + + 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 TestSchedulerIntegration: + """Integration tests for scheduler.""" + + @patch('utils.weather_sat_scheduler.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): + """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 + 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