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