Files
vector/backend/tests/test_osm_local.py
T
Sadmin 59f97c850e fix: forest_pct — доля 0..1 вместо процентов (вес леса падает со 100×)
Закономерность «приоритетная зона вверх-вправо»: score_zone умножает
weight × forest_pct, ожидая ДОЛЮ (тесты скоринга 0.3–0.8), а гео-сервисы
(osm_local и legacy Overpass calculate_forest_coverage) отдавали
ПРОЦЕНТЫ 0..100. Лес получил фактический вес в 100 раз больше задумки —
топ зон систематически занимали чуть более зелёные сектора.

Фикс: osm_local.calculate_forest_coverage возвращают долю 0..1.
Разброс скорингов по Минску: 1.244 → 0.032 (честно однородный город).
Остаточные различия — вода/дороги; дифференциация плотностей город/село —
ревью скоринга (B1), коэффициенты не тронуты.

+регресс-тест 0<=forest_pct<=1. 197 passed.
2026-09-09 19:33:53 +03:00

105 lines
5.3 KiB
Python
Raw 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.
"""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()