"""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, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) assert success is False assert error_msg is not None 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, error_msg = 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, error_msg = decoder.start( satellite='NOAA-18', device_index=0, gain=40.0, bias_t=True, ) assert success is True assert error_msg is None 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'), \ patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'): decoder = WeatherSatDecoder() decoder._running = True success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) assert success is True assert error_msg is None 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, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) assert success is False assert error_msg is not None 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, error_msg = decoder.start_from_file( satellite='NOAA-18', input_file='data/test.wav', ) assert success is False assert error_msg is not None 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_process.poll.return_value = None # Process still running mock_popen.return_value = mock_process decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) success, error_msg = decoder.start_from_file( satellite='NOAA-18', input_file='data/test.wav', sample_rate=1000000, ) assert success is True assert error_msg is None 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, error_msg = 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, error_msg = 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 return None when no serial found.""" mock_run.side_effect = FileNotFoundError serial = WeatherSatDecoder._resolve_device_id(0) assert serial is None 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'