Files
vector/backend/tests/test_geo_service.py

403 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"