""" Tests for geo_service.py Tests the geographic zone building and Overpass API integration. """ import pytest import math from unittest.mock import AsyncMock, patch, MagicMock from pathlib import Path from datetime import datetime, timedelta import json from services.geo_service import ( haversine, get_sector_bounds, get_cache_key, get_cached_result, save_to_cache, calculate_road_length, find_nearest_distance, calculate_forest_coverage, build_search_zones, DIRECTIONS, SEARCH_DISTANCES, _build_search_distances, CACHE_DIR ) class TestHaversine: """Test haversine distance calculation.""" def test_same_point(self): """Test distance between same point is zero.""" distance = haversine(53.9, 27.5, 53.9, 27.5) assert distance == pytest.approx(0.0, abs=0.01) def test_known_distance(self): """Test known distance between cities.""" # Minsk to Brest approximately 350 km minsk_lat, minsk_lon = 53.9, 27.5 brest_lat, brest_lon = 52.1, 23.7 distance = haversine(minsk_lat, minsk_lon, brest_lat, brest_lon) # Should be around 350 km assert 300 < distance < 400 def test_short_distance(self): """Test short distance calculation.""" # 1 km north lat1, lon1 = 53.9, 27.5 lat2 = lat1 + 0.009 # ~1 km lon2 = lon1 distance = haversine(lat1, lon1, lat2, lon2) assert distance == pytest.approx(1.0, abs=0.1) class TestSectorBounds: """Test sector boundary calculations.""" def test_north_sector(self): """Test north sector bounds.""" lat, lon = 53.9, 27.5 bounds = get_sector_bounds(lat, lon, "N", 1000) min_lat, min_lon, max_lat, max_lon = bounds # North sector should extend north assert max_lat > lat assert isinstance(min_lat, float) assert isinstance(max_lat, float) def test_all_directions(self): """Test all 8 directions return valid bounds.""" lat, lon = 53.9, 27.5 for direction in DIRECTIONS: bounds = get_sector_bounds(lat, lon, direction, 1000) min_lat, min_lon, max_lat, max_lon = bounds assert min_lat < max_lat assert min_lon < max_lon assert all(isinstance(x, float) for x in bounds) def test_different_radii(self): """Test different radii produce different bounds.""" lat, lon = 53.9, 27.5 bounds_500 = get_sector_bounds(lat, lon, "N", 500) bounds_5000 = get_sector_bounds(lat, lon, "N", 5000) # Larger radius should have larger bounds assert (bounds_5000[2] - bounds_5000[0]) > (bounds_500[2] - bounds_500[0]) class TestCaching: """Test caching functionality.""" def test_cache_key_generation(self): """Test cache key is consistent.""" query = "test query" key1 = get_cache_key(query) key2 = get_cache_key(query) assert key1 == key2 assert len(key1) == 32 # MD5 hash length def test_cache_key_different_queries(self): """Test different queries produce different keys.""" key1 = get_cache_key("query 1") key2 = get_cache_key("query 2") assert key1 != key2 def test_save_and_get_cache(self): """Test saving and retrieving from cache.""" cache_key = "test_key_123" test_data = {'elements': [{'id': 1, 'type': 'node'}]} # Save to cache save_to_cache(cache_key, test_data) # Retrieve from cache cached = get_cached_result(cache_key) assert cached is not None assert cached == test_data # Cleanup cache_file = CACHE_DIR / f"{cache_key}.json" if cache_file.exists(): cache_file.unlink() def test_cache_expiration(self): """Test cache expires after TTL.""" cache_key = "test_key_expired" test_data = {'elements': []} # Save to cache with old timestamp CACHE_DIR.mkdir(exist_ok=True) cache_file = CACHE_DIR / f"{cache_key}.json" old_time = datetime.now() - timedelta(hours=25) with open(cache_file, 'w') as f: json.dump({ 'timestamp': old_time.isoformat(), 'data': test_data }, f) # Should return None (expired) cached = get_cached_result(cache_key) assert cached is None # Cleanup if cache_file.exists(): cache_file.unlink() def test_cache_not_found(self): """Test cache returns None for non-existent key.""" cached = get_cached_result("nonexistent_key_xyz") assert cached is None class TestRoadLength: """Test road length calculation.""" def test_empty_elements(self): """Test empty elements returns zero.""" length = calculate_road_length([]) assert length == 0.0 def test_single_way(self): """Test single way calculation.""" elements = [ { 'type': 'way', 'geometry': [ {'lat': 53.9, 'lon': 27.5}, {'lat': 53.91, 'lon': 27.5} ] } ] length = calculate_road_length(elements) # Should be approximately 1.1 km assert 0.5 < length < 2.0 def test_multiple_ways(self): """Test multiple ways are summed.""" elements = [ { 'type': 'way', 'geometry': [ {'lat': 53.9, 'lon': 27.5}, {'lat': 53.91, 'lon': 27.5} ] }, { 'type': 'way', 'geometry': [ {'lat': 53.9, 'lon': 27.5}, {'lat': 53.9, 'lon': 27.51} ] } ] length = calculate_road_length(elements) assert length > 0 def test_ignores_non_ways(self): """Test non-way elements are ignored.""" elements = [ {'type': 'node', 'lat': 53.9, 'lon': 27.5}, { 'type': 'way', 'geometry': [ {'lat': 53.9, 'lon': 27.5}, {'lat': 53.91, 'lon': 27.5} ] } ] length = calculate_road_length(elements) assert length > 0 class TestNearestDistance: """Test nearest distance calculation.""" def test_empty_elements(self): """Test empty elements returns None.""" distance = find_nearest_distance(53.9, 27.5, []) assert distance is None def test_single_node(self): """Test single node distance.""" elements = [ {'type': 'node', 'lat': 53.91, 'lon': 27.5} ] distance = find_nearest_distance(53.9, 27.5, elements) assert distance is not None assert distance > 0 def test_finds_nearest(self): """Test finds nearest among multiple nodes.""" elements = [ {'type': 'node', 'lat': 53.95, 'lon': 27.5}, # Far {'type': 'node', 'lat': 53.901, 'lon': 27.5}, # Near {'type': 'node', 'lat': 54.0, 'lon': 27.5} # Very far ] distance = find_nearest_distance(53.9, 27.5, elements) # Should find the nearest (53.901) assert distance < 0.2 def test_ignores_non_nodes(self): """Test non-node elements are ignored.""" elements = [ {'type': 'way', 'geometry': []}, {'type': 'node', 'lat': 53.91, 'lon': 27.5} ] distance = find_nearest_distance(53.9, 27.5, elements) assert distance is not None class TestForestCoverage: """Test forest coverage calculation.""" def test_no_forest(self): """Test no forest returns 0%.""" coverage = calculate_forest_coverage([], 1000) assert coverage == 0.0 def test_some_forest(self): """Test forest coverage calculation.""" elements = [ {'type': 'way', 'tags': {'landuse': 'forest'}}, {'type': 'way', 'tags': {'natural': 'wood'}} ] coverage = calculate_forest_coverage(elements, 1000) assert 0 < coverage <= 100 def test_coverage_capped_at_100(self): """Test coverage is capped at 100%.""" # Many forest ways elements = [{'type': 'way'} for _ in range(1000)] coverage = calculate_forest_coverage(elements, 100) assert coverage <= 100.0 class TestBuildSearchZones: """Test search zone building.""" @pytest.mark.asyncio async def test_zone_count(self): """Test correct number of zones are created.""" with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features: mock_features.return_value = { 'roads_km': 5.0, 'road_density': 2.0, 'water_distance_km': 1.5, 'settlement_distance_km': 3.0, 'forest_pct': 40.0 } zones = await build_search_zones(53.9, 27.5, {}) # 8 directions × 4 distances = 32 zones assert len(zones) == 32 @pytest.mark.asyncio async def test_all_directions_covered(self): """Test all 8 directions are included.""" with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features: mock_features.return_value = { 'roads_km': 5.0, 'road_density': 2.0, 'water_distance_km': 1.5, 'settlement_distance_km': 3.0, 'forest_pct': 40.0 } zones = await build_search_zones(53.9, 27.5, {}) directions_found = set(z.direction for z in zones) assert directions_found == set(DIRECTIONS) @pytest.mark.asyncio async def test_all_distances_covered(self): """Test all 4 distances are included.""" with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features: mock_features.return_value = { 'roads_km': 5.0, 'road_density': 2.0, 'water_distance_km': 1.5, 'settlement_distance_km': 3.0, 'forest_pct': 40.0 } zones = await build_search_zones(53.9, 27.5, {}) distances_found = set(z.distance_km for z in zones) # build_search_zones derives distances from max_distance_km (default 3.0 km), # not from the legacy SEARCH_DISTANCES constant. expected_distances = set(d / 1000 for d in _build_search_distances(3.0)) assert distances_found == expected_distances @pytest.mark.asyncio async def test_zone_structure(self): """Test zone objects have correct structure.""" with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features: mock_features.return_value = { 'roads_km': 5.0, 'road_density': 2.0, 'water_distance_km': 1.5, 'settlement_distance_km': 3.0, 'forest_pct': 40.0 } zones = await build_search_zones(53.9, 27.5, {}) for zone in zones: assert hasattr(zone, 'direction') assert hasattr(zone, 'distance_km') assert hasattr(zone, 'forest_pct') assert hasattr(zone, 'road_density') assert hasattr(zone, 'water_distance_km') assert hasattr(zone, 'settlement_distance_km') class TestConstants: """Test module constants.""" def test_directions_count(self): """Test there are 8 directions.""" assert len(DIRECTIONS) == 8 def test_directions_values(self): """Test direction values are correct.""" expected = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] assert DIRECTIONS == expected def test_search_distances(self): """Test search distances are correct.""" expected = [500, 1000, 2000, 5000] assert SEARCH_DISTANCES == expected def test_cache_dir_path(self): """Test cache directory path is set.""" assert isinstance(CACHE_DIR, Path) assert str(CACHE_DIR) == "/tmp/overpass_cache"