mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-04-23 05:36:36 +00:00
303 lines
12 KiB
Python
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 |