diff --git a/Dockerfile b/Dockerfile index 41b0f7e..afd8894 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ libncurses-dev \ libsndfile1-dev \ + # GTK is required for slowrx (SSTV decoder GUI dependency). + # Note: slowrx is kept for backwards compatibility, but the pure Python + # SSTV decoder in utils/sstv/ is now the primary implementation. + # GTK can be removed if slowrx is deprecated in future releases. libgtk-3-dev \ libasound2-dev \ libsoapysdr-dev \ @@ -195,6 +199,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && ldconfig \ && rm -rf /tmp/dsd-fme \ # Cleanup build tools to reduce image size + # Note: libgtk-3-dev is removed here but runtime GTK libs (from first stage) + # remain for slowrx. This adds ~10MB to the image but is required for slowrx + # to function. Consider removing slowrx build entirely if moving fully to + # the pure Python SSTV decoder. && apt-get remove -y \ build-essential \ git \ diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 0caa5ad..f7ae788 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -120,9 +120,10 @@ def start_capture(): device_index = validate_device_index(data.get('device', 0)) gain = validate_gain(data.get('gain', 40.0)) except ValueError as e: + logger.warning('Invalid parameter in start_capture: %s', e) return jsonify({ 'status': 'error', - 'message': str(e) + 'message': 'Invalid parameter value' }), 400 bias_t = bool(data.get('bias_t', False)) @@ -464,7 +465,8 @@ def get_passes(): lat = validate_latitude(raw_lat) lon = validate_longitude(raw_lon) except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 + logger.warning('Invalid coordinates in get_passes: %s', e) + return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 hours = max(1, min(request.args.get('hours', 24, type=int), 72)) min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90)) @@ -555,9 +557,10 @@ def enable_schedule(): device = validate_device_index(data.get('device', 0)) gain_val = validate_gain(data.get('gain', 40.0)) except ValueError as e: + logger.warning('Invalid parameter in enable_schedule: %s', e) return jsonify({ 'status': 'error', - 'message': str(e) + 'message': 'Invalid parameter value' }), 400 scheduler = get_weather_sat_scheduler() diff --git a/templates/index.html b/templates/index.html index 8c6361d..dfeccee 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15270,172 +15270,6 @@ {% include 'partials/settings-modal.html' %} - -
- - 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/