Files
gps-denied-onboard/test_f04_satellite_data_manager.py
T
Denys Zaitsev d7e1066c60 Initial commit
2026-04-03 23:25:54 +03:00

303 lines
12 KiB
Python

import pytest
import shutil
import numpy as np
from pathlib import Path
from unittest.mock import patch, Mock
from f04_satellite_data_manager import SatelliteDataManager, TileCoords, GPSPoint, CacheConfig
import httpx
import h06_web_mercator_utils as H06
@pytest.fixture
def test_config():
config = CacheConfig(cache_dir="./test_satellite_cache")
yield config
# Cleanup after tests
if Path(config.cache_dir).exists():
shutil.rmtree(config.cache_dir)
@pytest.fixture
def sdm(test_config):
return SatelliteDataManager(config=test_config)
@pytest.fixture
def dummy_tile():
# Create a simple 256x256 RGB image array
return np.zeros((256, 256, 3), dtype=np.uint8)
class TestSatelliteDataManager:
# --- 04.01 Feature: Tile Cache Management ---
def test_cache_tile_success_and_hit(self, sdm, dummy_tile):
coords = TileCoords(x=10, y=20, zoom=15)
# Cache Miss
assert sdm.get_cached_tile("flight_1", coords) is None
# Cache Tile
assert sdm.cache_tile("flight_1", coords, dummy_tile) is True
# Cache Hit
retrieved = sdm.get_cached_tile("flight_1", coords)
assert retrieved is not None
assert retrieved.shape == (256, 256, 3)
def test_get_cached_tile_global_fallback(self, sdm, dummy_tile):
coords = TileCoords(x=30, y=40, zoom=15)
# Cache globally
sdm.cache_tile("global", coords, dummy_tile)
# Retrieve from flight-specific request (should fall back to global)
retrieved = sdm.get_cached_tile("flight_2", coords)
assert retrieved is not None
def test_clear_flight_cache(self, sdm, dummy_tile):
coords = TileCoords(x=10, y=20, zoom=15)
sdm.cache_tile("flight_clear", coords, dummy_tile)
assert sdm.clear_flight_cache("flight_clear") is True
assert sdm.get_cached_tile("flight_clear", coords) is None
# Ensure global cannot be cleared this way
sdm.cache_tile("global", coords, dummy_tile)
assert sdm.clear_flight_cache("global") is False
def test_update_cache_index(self, sdm):
coords = TileCoords(x=10, y=20, zoom=15)
sdm._update_cache_index("flight_idx", coords, "add")
key = f"flight_idx_{coords.zoom}_{coords.x}_{coords.y}"
assert sdm.index_cache.get(key) is True
sdm._update_cache_index("flight_idx", coords, "remove")
assert sdm.index_cache.get(key) is None
# --- 04.02 Feature: Tile Coordinate Operations ---
def test_compute_tile_coords_ukraine(self, sdm):
# Kyiv coordinates roughly
lat, lon, zoom = 50.4501, 30.5234, 19
coords = sdm.compute_tile_coords(lat, lon, zoom)
assert coords.zoom == 19
assert coords.x > 0
assert coords.y > 0
# Verify reverse transformation is reasonably close
r_lat, r_lon = sdm._tile_to_latlon(coords.x + 0.5, coords.y + 0.5, coords.zoom)
assert abs(r_lat - lat) < 0.01
assert abs(r_lon - lon) < 0.01
def test_compute_tile_bounds(self, sdm):
coords = TileCoords(x=305000, y=175000, zoom=19)
bounds = sdm.compute_tile_bounds(coords)
assert bounds.gsd > 0.0
assert bounds.nw.lat > bounds.sw.lat # NW is further North
assert bounds.ne.lon > bounds.nw.lon # NE is further East
def test_get_tile_grid(self, sdm):
center = TileCoords(x=100, y=100, zoom=15)
grid_1 = sdm.get_tile_grid(center, 1)
assert len(grid_1) == 1
assert grid_1[0] == center
grid_4 = sdm.get_tile_grid(center, 4) # 2x2
assert len(grid_4) == 4
grid_9 = sdm.get_tile_grid(center, 9) # 3x3
assert len(grid_9) == 9
grid_25 = sdm.get_tile_grid(center, 25) # 5x5
assert len(grid_25) == 25
def test_internal_coordinate_helpers(self, sdm):
assert sdm._compute_grid_offset(1) == 0
assert sdm._compute_grid_offset(4) == 1
assert sdm._compute_grid_offset(9) == 1
rows, cols = sdm._grid_size_to_dimensions(9)
assert rows == 3 and cols == 3
rows, cols = sdm._grid_size_to_dimensions(16)
assert rows == 4 and cols == 4
center = TileCoords(x=10, y=10, zoom=15)
tiles = sdm._generate_grid_tiles(center, 2, 2)
assert len(tiles) == 4
def test_expand_search_grid(self, sdm):
center = TileCoords(x=100, y=100, zoom=15)
# Expanding from 4 to 9 should yield 5 new tiles
new_tiles_9 = sdm.expand_search_grid(center, 4, 9)
assert len(new_tiles_9) == 5
# Expanding from 9 to 16 should yield 7 new tiles
new_tiles_16 = sdm.expand_search_grid(center, 9, 16)
assert len(new_tiles_16) == 7
def test_h06_delegation_verify(self, sdm):
lat, lon, zoom = 48.0, 37.0, 15
coords = sdm.compute_tile_coords(lat, lon, zoom)
h06_x, h06_y = H06.latlon_to_tile(lat, lon, zoom)
assert coords.x == h06_x and coords.y == h06_y
# --- 04.03 Feature: Tile Fetching ---
def test_fetch_tile_invalid_coords(self, sdm):
assert sdm.fetch_tile(95.0, 37.0, 15) is None
assert sdm.fetch_tile(48.0, 185.0, 15) is None
@patch("httpx.get")
def test_fetch_tile_api_call(self, mock_get, sdm, dummy_tile):
# Mock successful HTTP response
mock_response = Mock()
mock_response.content = sdm._serialize_tile(dummy_tile)
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
lat, lon = 48.0, 37.0
fetched = sdm.fetch_tile(lat, lon, 15, "flight_api")
assert fetched is not None
assert fetched.shape == (256, 256, 3)
mock_get.assert_called_once()
# Second fetch should hit cache, not the API
mock_get.reset_mock()
fetched_cached = sdm.fetch_tile(lat, lon, 15, "flight_api")
assert fetched_cached is not None
mock_get.assert_not_called()
@patch("httpx.get")
def test_fetch_tile_api_error_and_retry_exhausted(self, mock_get, sdm):
# Simulate 500 Server Error for all retries
mock_get.side_effect = httpx.HTTPError("500 Server Error")
fetched = sdm.fetch_tile(48.0, 37.0, 15, "flight_error")
assert fetched is None
assert mock_get.call_count == 3 # Ensures max_retries=3 is respected
@patch.object(SatelliteDataManager, "_fetch_from_api")
def test_fetch_tile_retry_success(self, mock_fetch_api, sdm, dummy_tile):
# Fail the first two attempts, succeed on the third
mock_fetch_api.side_effect = [None, None, dummy_tile]
coords = TileCoords(x=10, y=20, zoom=15)
result = sdm._fetch_with_retry(coords)
assert result is not None
assert mock_fetch_api.call_count == 3
@patch("httpx.get")
def test_fetch_tile_grid(self, mock_get, sdm, dummy_tile):
mock_response = Mock()
mock_response.content = sdm._serialize_tile(dummy_tile)
mock_get.return_value = mock_response
grid = sdm.fetch_tile_grid(48.0, 37.0, 4, 15)
assert len(grid) == 4
for tile_id, data in grid.items():
assert data.shape == (256, 256, 3)
@patch.object(SatelliteDataManager, "_fetch_with_retry")
def test_fetch_tile_grid_partial_failure(self, mock_fetch, sdm, dummy_tile):
# Force failure on a specific tile coordinate to test graceful degradation
def side_effect(coords):
if coords.x == sdm.compute_tile_coords(48.0, 37.0, 15).x:
return None
return dummy_tile
mock_fetch.side_effect = side_effect
grid = sdm.fetch_tile_grid(48.0, 37.0, 9, 15)
# Should successfully fetch 6 out of 9 tiles (3 fail due to sharing the same 'x' coordinate column in 3x3 grid)
assert len(grid) == 6
@patch.object(SatelliteDataManager, "fetch_tile_grid")
def test_progressive_fetch(self, mock_fetch_grid, sdm):
mock_fetch_grid.side_effect = [{"1": "t1"}, {"1":"t1", "2":"t2"}, {"1":"t1", "2":"t2", "3":"t3"}]
sizes = [1, 4, 9]
iterator = sdm.progressive_fetch(48.0, 37.0, sizes, 15)
results = list(iterator)
assert len(results) == 3
assert len(results[2]) == 3
@patch.object(SatelliteDataManager, "fetch_tile_grid")
def test_progressive_fetch_early_termination(self, mock_fetch_grid, sdm):
mock_fetch_grid.side_effect = [{"1": "t1"}, {"1":"t1", "2":"t2"}, {"1":"t1", "2":"t2", "3":"t3"}]
iterator = sdm.progressive_fetch(48.0, 37.0, [1, 4, 9, 16, 25], 15)
# Process only the first two layers (simulating finding a match early)
next(iterator)
next(iterator)
# Grid should only be fetched twice, skipping 9, 16, and 25
assert mock_fetch_grid.call_count == 2
def test_compute_corridor_tiles(self, sdm):
waypoints = [
GPSPoint(lat=48.0, lon=37.0),
GPSPoint(lat=48.005, lon=37.005) # Increased distance to test interpolation
]
tiles = sdm._compute_corridor_tiles(waypoints, 100.0, 15)
assert len(tiles) > 0
# Interpolation should add multiple points in between, creating a larger continuous set
assert len(tiles) > 9
@patch.object(SatelliteDataManager, "_fetch_tiles_parallel")
def test_prefetch_route_corridor_success(self, mock_parallel, sdm, dummy_tile):
mock_parallel.return_value = {"15_10_20": dummy_tile}
waypoints = [GPSPoint(lat=48.0, lon=37.0)]
# Mock _compute_corridor_tiles to return a controlled subset for predictable test
with patch.object(sdm, "_compute_corridor_tiles", return_value=[TileCoords(x=10, y=20, zoom=15)]):
assert sdm.prefetch_route_corridor(waypoints, 100.0, 15) is True
assert sdm.get_cached_tile("global", TileCoords(x=10, y=20, zoom=15)) is not None
@patch.object(SatelliteDataManager, "_fetch_tiles_parallel")
def test_prefetch_route_corridor_partial_failure(self, mock_parallel, sdm, dummy_tile):
# Simulate one success, one failure
mock_parallel.return_value = {"15_10_20": dummy_tile}
waypoints = [GPSPoint(lat=48.0, lon=37.0)]
tiles = [TileCoords(x=10, y=20, zoom=15), TileCoords(x=11, y=20, zoom=15)]
with patch.object(sdm, "_compute_corridor_tiles", return_value=tiles):
assert sdm.prefetch_route_corridor(waypoints, 100.0, 15) is True # Partial success still returns True
@patch.object(SatelliteDataManager, "_fetch_tiles_parallel")
def test_prefetch_route_corridor_complete_failure(self, mock_parallel, sdm):
mock_parallel.return_value = {} # Empty dict means all failed
waypoints = [GPSPoint(lat=48.0, lon=37.0)]
assert sdm.prefetch_route_corridor(waypoints, 100.0, 15) is False
@patch.object(SatelliteDataManager, "_fetch_with_retry")
def test_fetch_tiles_parallel(self, mock_fetch, sdm, dummy_tile):
mock_fetch.return_value = dummy_tile
tiles = [
TileCoords(x=10, y=20, zoom=15),
TileCoords(x=11, y=20, zoom=15)
]
results = sdm._fetch_tiles_parallel(tiles)
assert len(results) == 2
@patch.object(SatelliteDataManager, "_fetch_with_retry")
def test_concurrent_fetch_stress(self, mock_fetch, sdm, dummy_tile):
# Mock immediate return to strictly test threading overhead and mapping
mock_fetch.return_value = dummy_tile
# Create 100 distinct tiles
tiles = [TileCoords(x=i, y=i, zoom=15) for i in range(100)]
results = sdm._fetch_tiles_parallel(tiles, max_concurrent=50)
assert len(results) == 100
assert mock_fetch.call_count == 100