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