"""B16: тесты локального OSM-слоя (PostGIS вместо Overpass). Тесты работают и без БД: если DATABASE_URL недоступен или таблицы не залиты, osm_available() возвращает False и fallback-ветка geo_service ведёт себя по-старому (проверяется моком). """ from __future__ import annotations from unittest.mock import patch import pytest from services import geo_service from services import osm_local class TestOsmLocal: def test_sector_wkt_is_valid_pie(self): """WKT сектора — пирог: центр + дуга, замкнутый.""" wkt = osm_local._sector_wkt_3857(53.9, 27.56, 1000, 'N') assert wkt.startswith('POLYGON((') assert wkt.endswith('))') # Центр входит дважды (начало и конец) assert wkt.count('27.5600000') >= 2 or '27.56 ' in wkt def test_sector_geometry_valid(self): """PostGIS-валидность: сектор не самопересекается (если БД доступна).""" if not osm_local.osm_available(): pytest.skip('OSM PostGIS недоступен') import sqlalchemy from sqlalchemy import text e = sqlalchemy.create_engine(osm_local._PG_URL) wkt = osm_local._sector_wkt_3857(53.9, 27.56, 1000, 'NE') with e.connect() as c: valid = c.execute(text( "SELECT ST_IsValid(ST_GeomFromText(:w, 4326))"), {'w': wkt} ).scalar() assert valid def test_features_contract(self): """Контракт Zone-фич: все ключи, типы, диапазоны.""" if not osm_local.osm_available(): pytest.skip('OSM PostGIS недоступен') f = osm_local.get_zone_features_postgis(53.9, 27.56, 'N', 1000) assert set(f) == {'roads_km', 'road_density', 'water_distance_km', 'settlement_distance_km', 'forest_pct'} assert f['roads_km'] >= 0 # Контракт: лес — ДОЛЯ 0..1 (scoring_service умножает weight × forest_pct). # Регресс: раньше отдавались проценты 0..100 — лес доминировал со 100× весом. assert 0 <= f['forest_pct'] <= 1 assert f['water_distance_km'] is None or f['water_distance_km'] > 0 def test_zones_differ_between_directions(self): """Критерий B16: соседние направления дают РАЗНЫЕ характеристики.""" if not osm_local.osm_available(): pytest.skip('OSM PostGIS недоступен') n = osm_local.get_zone_features_postgis(53.9, 27.56, 'N', 1000) ne = osm_local.get_zone_features_postgis(53.9, 27.56, 'NE', 1000) assert (n['roads_km'], n['forest_pct']) != (ne['roads_km'], ne['forest_pct']) def test_wild_point_outside_data(self): """Точка вне Беларуси → нули (не падает).""" if not osm_local.osm_available(): pytest.skip('OSM PostGIS недоступен') f = osm_local.get_zone_features_postgis(40.0, 0.0, 'N', 1000) assert f['roads_km'] == 0.0 assert f['forest_pct'] == 0.0 class TestGeoServiceIntegration: async def test_build_search_zones_uses_postgis_when_available(self): """osm_available()=True → Overpass НЕ вызывается.""" with patch.object(geo_service, 'osm_available', return_value=True), \ patch.object(geo_service, 'get_zone_features_postgis') as mock_pg, \ patch.object(geo_service, 'get_zone_features', wraps=geo_service.get_zone_features): mock_pg.side_effect = lambda lat, lon, d, r: { 'roads_km': 1.0, 'road_density': 0.8, 'water_distance_km': 0.5, 'settlement_distance_km': 0.3, 'forest_pct': 10.0, } zones = await geo_service.build_search_zones(53.9, 27.56, {}, 1.08) assert len(zones) == 32 assert mock_pg.call_count == 32 assert zones[0].forest_pct == 10.0 async def test_build_search_zones_falls_back_to_overpass(self): """osm_available()=False → идёт старый Overpass-путь (мокаем get_zone_features).""" with patch.object(geo_service, 'osm_available', return_value=False), \ patch.object(geo_service, 'get_zone_features') as mock_get: mock_get.side_effect = lambda lat, lon, d, r: { 'roads_km': 2.0, 'road_density': 1.6, 'water_distance_km': 0.7, 'settlement_distance_km': 0.4, 'forest_pct': 5.0, } zones = await geo_service.build_search_zones(53.9, 27.56, {}, 1.08) assert len(zones) == 32 assert mock_get.call_count == 32 assert zones[0].forest_pct == 5.0 def test_no_overpass_calls_with_local_data(self): """Критерий B16: с локальными данными query_overpass не дёргается.""" if not osm_local.osm_available(): pytest.skip('OSM PostGIS недоступен') with patch.object(geo_service, 'query_overpass') as mock_q: import asyncio zones = asyncio.run(geo_service.build_search_zones(53.9, 27.56, {}, 1.08)) mock_q.assert_not_called()