feat(site-finder): OSRM road-distance in /analyze behind flag (#39 A2) #1932
5 changed files with 620 additions and 4 deletions
|
|
@ -70,6 +70,10 @@ from app.services.site_finder.custom_pois import (
|
|||
from app.services.site_finder.developer_attribution import get_developer_attribution
|
||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
||||
from app.services.site_finder.osrm_client_local import (
|
||||
OsrmLocalUnavailableError,
|
||||
get_road_distances_m,
|
||||
)
|
||||
from app.services.site_finder.parcel_financial import synthesize_parcel_financial
|
||||
from app.services.site_finder.poi_score import (
|
||||
PoiScoreResponse,
|
||||
|
|
@ -243,6 +247,61 @@ def _verbal_for_poi(
|
|||
return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"
|
||||
|
||||
|
||||
def _apply_osrm_road_distances(
|
||||
pois: list[dict[str, Any]],
|
||||
*,
|
||||
origin_lon: float,
|
||||
origin_lat: float,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""In-place заменить `distance_m` каждого POI на дорожное расстояние из OSRM (#39 A2).
|
||||
|
||||
POI без валидных lon/lat и POI, до которых OSRM не построил маршрут (None),
|
||||
сохраняют свой straight-line `distance_m`. При ЛЮБОМ сбое OSRM
|
||||
(OsrmLocalUnavailableError) — логируем и НИЧЕГО не меняем (straight-line по
|
||||
всем POI). Эта функция НИКОГДА не бросает исключение — /analyze не падает,
|
||||
даже если OSRM лежит.
|
||||
|
||||
Args:
|
||||
pois: список dict с ключами 'lon', 'lat', 'distance_m' (мутируется in-place).
|
||||
origin_lon, origin_lat: центроид участка = OSRM sources=0.
|
||||
label: контекст для логов (например, "osm cad=66:...").
|
||||
"""
|
||||
# Берём только POI с валидными координатами; индексы для обратного маппинга.
|
||||
routable: list[tuple[int, tuple[float, float]]] = [
|
||||
(i, (float(p["lon"]), float(p["lat"])))
|
||||
for i, p in enumerate(pois)
|
||||
if p.get("lon") is not None and p.get("lat") is not None
|
||||
]
|
||||
if not routable:
|
||||
return
|
||||
|
||||
try:
|
||||
road = get_road_distances_m(
|
||||
origin_lon,
|
||||
origin_lat,
|
||||
[coord for _, coord in routable],
|
||||
)
|
||||
except OsrmLocalUnavailableError as exc:
|
||||
# Graceful: OSRM недоступен → оставляем straight-line по всем POI.
|
||||
logger.warning(
|
||||
"OSRM road-distance unavailable (%s) — straight-line fallback: %s", label, exc
|
||||
)
|
||||
return
|
||||
|
||||
replaced = 0
|
||||
for (orig_idx, _coord), dist in zip(routable, road, strict=True):
|
||||
if dist is not None:
|
||||
pois[orig_idx]["distance_m"] = dist
|
||||
replaced += 1
|
||||
logger.info(
|
||||
"OSRM road-distance applied (%s): %d/%d POI заменены (остальные straight-line)",
|
||||
label,
|
||||
replaced,
|
||||
len(pois),
|
||||
)
|
||||
|
||||
|
||||
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||||
# Добавляй регионы по мере расширения географии продукта.
|
||||
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||||
|
|
@ -1566,9 +1625,12 @@ def analyze_parcel(
|
|||
.first()
|
||||
)
|
||||
|
||||
# 3) POI в радиусе 1 км — список с distance_m
|
||||
poi_rows = (
|
||||
db.execute(
|
||||
# 3) POI в радиусе 1 км — список с distance_m (straight-line, ST_Distance).
|
||||
# В dict, а не RowMapping (read-only) — чтобы при включённом OSRM (#39 A2)
|
||||
# можно было перезаписать distance_m реальным дорожным расстоянием.
|
||||
poi_rows: list[dict[str, Any]] = [
|
||||
dict(r)
|
||||
for r in db.execute(
|
||||
text("""
|
||||
SELECT category,
|
||||
name,
|
||||
|
|
@ -1591,7 +1653,31 @@ def analyze_parcel(
|
|||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
]
|
||||
|
||||
# 3a) OSRM road-distance (#39 A2) — flag-gated, default OFF.
|
||||
# При use_osrm_distances=True заменяем straight-line distance_m на реальное
|
||||
# дорожное (центроид→POI) из локального OSRM. Любой сбой OSRM → straight-line
|
||||
# fallback (analyze НЕ падает). При OFF — байт-в-байт как раньше (нет вызова).
|
||||
if settings.use_osrm_distances and poi_rows:
|
||||
_osrm_origin = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT ST_X(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lon,
|
||||
ST_Y(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lat
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if _osrm_origin and _osrm_origin["lon"] is not None and _osrm_origin["lat"] is not None:
|
||||
_apply_osrm_road_distances(
|
||||
poi_rows,
|
||||
origin_lon=float(_osrm_origin["lon"]),
|
||||
origin_lat=float(_osrm_origin["lat"]),
|
||||
label=f"osm cad={cad_num}",
|
||||
)
|
||||
|
||||
# 3b) Resolve effective POI weights (inline → profile → user default → system)
|
||||
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
|
||||
|
|
@ -2904,6 +2990,15 @@ def analyze_parcel(
|
|||
_custom_overlaps = _get_custom_poi_overlaps(
|
||||
db, geom_wkt, _session_id, parcel_cad=cad_num
|
||||
)
|
||||
# OSRM road-distance (#39 A2) — flag-gated, default OFF. Тот же graceful
|
||||
# fallback, что и для OSM POI: сбой OSRM → straight-line, analyze не падает.
|
||||
if settings.use_osrm_distances and _custom_overlaps and not centroid_is_fallback:
|
||||
_apply_osrm_road_distances(
|
||||
_custom_overlaps,
|
||||
origin_lon=centroid_lon,
|
||||
origin_lat=centroid_lat,
|
||||
label=f"custom cad={cad_num}",
|
||||
)
|
||||
for cp in _custom_overlaps:
|
||||
_distance_m = cp["distance_m"]
|
||||
_decay = max(0.0, 1.0 - _distance_m / 1000.0)
|
||||
|
|
|
|||
|
|
@ -183,6 +183,24 @@ class Settings(BaseSettings):
|
|||
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
|
||||
openrouteservice_api_key: str = ""
|
||||
|
||||
# ── OSRM road-distance в /analyze (#39 A2) ────────────────────────────────
|
||||
# Реальное дорожное расстояние центроид→POI из локального OSRM-сервиса
|
||||
# (self-hosted, /table GET с annotations=distance → метры) вместо
|
||||
# straight-line ST_Distance в POI-скоринге.
|
||||
#
|
||||
# use_osrm_distances СТРОГО default False: включение сдвигает ВСЕ POI-score
|
||||
# на ~20-30% (дорога длиннее прямой), что есть осознанное ПОЗДНЕЕ решение
|
||||
# (после валидации), а НЕ этот деплой. При OFF /analyze байт-в-байт как
|
||||
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
|
||||
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
|
||||
use_osrm_distances: bool = False
|
||||
# URL локального OSRM (docker-compose service `osrm`). /table endpoint:
|
||||
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
|
||||
osrm_local_url: str = "http://osrm:5000"
|
||||
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
|
||||
# сервисе — при превышении → fallback на straight-line.
|
||||
osrm_distance_timeout_s: float = 12.0
|
||||
|
||||
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
|
||||
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
|
||||
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
|
||||
|
|
|
|||
140
backend/app/services/site_finder/osrm_client_local.py
Normal file
140
backend/app/services/site_finder/osrm_client_local.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Тонкий клиент локального OSRM — реальное дорожное расстояние для /analyze (#39 A2).
|
||||
|
||||
В отличие от ORS (`ors_client.py`, внешний SaaS с лимитом 2000/день и /matrix POST),
|
||||
это self-hosted OSRM (docker-compose service `osrm`, `settings.osrm_local_url`). Его
|
||||
table-API — это **GET** с координатами в URL и `annotations=distance`, отдающий
|
||||
**реальное дорожное расстояние в МЕТРАХ** (а не durations×скорость — это была бы
|
||||
ORS-семантика):
|
||||
|
||||
GET {url}/table/v1/driving/{lon0},{lat0};{lon1},{lat1};...?sources=0&annotations=distance
|
||||
→ {"code":"Ok","distances":[[0, d1, d2, ...]]} # метры; row[0] = origin→каждый dest
|
||||
|
||||
origin = центроид участка (первая координата, sources=0); destinations = POI.
|
||||
Читаем `distances[0][1:]` (пропускаем self origin→origin = 0). Недостижимый POI =
|
||||
`null` → возвращаем None по этому индексу (caller оставит straight-line).
|
||||
|
||||
Graceful: при ЛЮБОМ сбое (HTTP, timeout, code!=Ok, битая форма) → `OsrmLocalUnavailableError`.
|
||||
Caller обязан поймать и сделать fallback на straight-line, НЕ валя /analyze.
|
||||
|
||||
OSRM запускается с `--max-table-size 8000`, поэтому ≤100 POI на analyze проходят
|
||||
без усечения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OSRM `--max-table-size` (число пар sources+destinations). Дефолт сервиса — 8000;
|
||||
# с 1 origin это 7999 POI, что с запасом покрывает наши ≤100. Держим как guard.
|
||||
MAX_TABLE_SIZE = 8000
|
||||
|
||||
|
||||
class OsrmLocalUnavailableError(Exception):
|
||||
"""Локальный OSRM недоступен (сетевой сбой / timeout / code!=Ok / битый ответ).
|
||||
|
||||
Caller обязан поймать и сделать fallback (straight-line), не валя /analyze.
|
||||
"""
|
||||
|
||||
|
||||
def _fmt_coord(lon: float, lat: float) -> str:
|
||||
"""OSRM-координата `lon,lat`, URL-safe (на случай экзотических локалей/инпута)."""
|
||||
return f"{quote(str(lon), safe='')},{quote(str(lat), safe='')}"
|
||||
|
||||
|
||||
def get_road_distances_m(
|
||||
origin_lon: float,
|
||||
origin_lat: float,
|
||||
destinations: list[tuple[float, float]],
|
||||
*,
|
||||
profile: str = "driving",
|
||||
timeout_s: float | None = None,
|
||||
) -> list[float | None]:
|
||||
"""Дорожное расстояние (метры) от одного origin до каждого destination через OSRM /table.
|
||||
|
||||
Args:
|
||||
origin_lon, origin_lat: координаты источника (центроид участка) = sources=0.
|
||||
destinations: список (lon, lat) — POI. ≤MAX_TABLE_SIZE-1 (для нас всегда).
|
||||
profile: OSRM-профиль ('driving' по умолчанию).
|
||||
timeout_s: httpx timeout (обязателен — иначе worker зависает).
|
||||
None → settings.osrm_distance_timeout_s.
|
||||
|
||||
Returns:
|
||||
Список той же длины, что `destinations`, выровненный по индексу. Элемент —
|
||||
дорожное расстояние в метрах (float) или None, если OSRM не смог построить
|
||||
маршрут до этой точки (POI на острове/без дорожной связи) — caller оставит
|
||||
straight-line по этому POI. Пустой `destinations` → [].
|
||||
|
||||
Raises:
|
||||
OsrmLocalUnavailableError: сетевой сбой / timeout / HTTP!=2xx / code!=Ok /
|
||||
неожиданный формат / число расстояний != числу destinations. Caller
|
||||
делает straight-line fallback, /analyze не падает.
|
||||
"""
|
||||
if not destinations:
|
||||
return []
|
||||
|
||||
if len(destinations) > MAX_TABLE_SIZE - 1:
|
||||
# OSRM отвергнет таблицу больше max-table-size — считаем это «недоступностью»
|
||||
# для данного запроса (caller → straight-line по всем POI).
|
||||
raise OsrmLocalUnavailableError(
|
||||
f"OSRM table: {len(destinations)} destinations > max-table-size "
|
||||
f"({MAX_TABLE_SIZE - 1} с одним origin)"
|
||||
)
|
||||
|
||||
effective_timeout = timeout_s if timeout_s is not None else settings.osrm_distance_timeout_s
|
||||
|
||||
# Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку.
|
||||
coords = ";".join(
|
||||
[_fmt_coord(origin_lon, origin_lat)]
|
||||
+ [_fmt_coord(lon, lat) for lon, lat in destinations]
|
||||
)
|
||||
base = settings.osrm_local_url.rstrip("/")
|
||||
url = f"{base}/table/v1/{profile}/{coords}"
|
||||
params = {"sources": "0", "annotations": "distance"}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=effective_timeout) as client:
|
||||
resp = client.get(url, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise OsrmLocalUnavailableError(
|
||||
f"OSRM table HTTP {exc.response.status_code}: {exc.response.text[:200]}"
|
||||
) from exc
|
||||
except Exception as exc: # сетевой сбой / timeout / JSON decode → fallback, не 500
|
||||
raise OsrmLocalUnavailableError(f"OSRM table request failed: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict) or data.get("code") != "Ok":
|
||||
code = data.get("code") if isinstance(data, dict) else "<non-dict>"
|
||||
raise OsrmLocalUnavailableError(f"OSRM table: code != Ok ({code!r})")
|
||||
|
||||
# Ответ: {"distances": [[0, d1, d2, ...]]} — метры, одна строка (один source).
|
||||
distances = data.get("distances")
|
||||
if not isinstance(distances, list) or not distances:
|
||||
raise OsrmLocalUnavailableError("OSRM table: пустой/битый distances в ответе")
|
||||
row = distances[0]
|
||||
if not isinstance(row, list) or not row:
|
||||
raise OsrmLocalUnavailableError("OSRM table: distances[0] не список / пуст")
|
||||
|
||||
# row[0] = origin→origin = 0 (self) — пропускаем, читаем 1:.
|
||||
dest_distances = row[1:]
|
||||
out: list[float | None] = []
|
||||
for d in dest_distances:
|
||||
if d is None:
|
||||
out.append(None) # OSRM не построил маршрут до этой точки
|
||||
else:
|
||||
out.append(float(d))
|
||||
|
||||
# Длина должна совпадать с числом destinations — иначе zip у вызывающего
|
||||
# рассинхронит POI↔distance. Закрываем как OSRM-сбой → straight-line fallback.
|
||||
if len(out) != len(destinations):
|
||||
raise OsrmLocalUnavailableError(
|
||||
f"OSRM table: distances ({len(out)}) != destinations ({len(destinations)})"
|
||||
)
|
||||
return out
|
||||
237
backend/tests/api/v1/test_analyze_osrm_distances.py
Normal file
237
backend/tests/api/v1/test_analyze_osrm_distances.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""Тесты OSRM road-distance в POST /api/v1/parcels/{cad}/analyze (#39 A2).
|
||||
|
||||
Покрывает:
|
||||
1. flag ON + mocked OSRM → distance_m в score_breakdown заменён дорожным расстоянием,
|
||||
POI-вклад (score) отражает road-distance, а не straight-line.
|
||||
2. flag ON + OSRM бросает OsrmLocalUnavailableError → fallback на straight-line,
|
||||
analyze возвращает 200 (НЕ 500), score = straight-line baseline.
|
||||
3. flag OFF (default) → get_road_distances_m НЕ вызывается ни разу (байт-в-байт
|
||||
как сегодня).
|
||||
|
||||
Mock-стратегия как в test_analyze_inline_weights.py: DB через dependency_overrides,
|
||||
тяжёлые service-вызовы через patch, OSRM-клиент через patch на
|
||||
app.api.v1.parcels.get_road_distances_m.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
from app.main import app
|
||||
from app.services.site_finder.osrm_client_local import OsrmLocalUnavailableError
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
|
||||
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
|
||||
|
||||
|
||||
def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.__getitem__ = lambda self, k: data[k]
|
||||
m.get = lambda k, default=None: data.get(k, default)
|
||||
return m
|
||||
|
||||
|
||||
def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
|
||||
db = MagicMock()
|
||||
|
||||
geom_row = _make_mapping(
|
||||
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
|
||||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT})
|
||||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
sql = " ".join(str(args[0]).split()) if args else ""
|
||||
|
||||
first_val: Any = None
|
||||
all_val: list[Any] = []
|
||||
|
||||
if "AS geom_geojson" in sql:
|
||||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
# И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape).
|
||||
first_val = centroid_row
|
||||
elif "category" in sql and "ST_Distance" in sql and "p.geom" in sql:
|
||||
all_val = poi_rows
|
||||
|
||||
r = MagicMock()
|
||||
r.mappings.return_value.first.return_value = first_val
|
||||
r.mappings.return_value.all.return_value = all_val
|
||||
r.scalar.return_value = 0
|
||||
return r
|
||||
|
||||
db.execute.side_effect = _execute_side_effect
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__ = MagicMock(return_value=ctx)
|
||||
ctx.__exit__ = MagicMock(return_value=False)
|
||||
db.begin_nested.return_value = ctx
|
||||
return db
|
||||
|
||||
|
||||
def _override_db(db: MagicMock):
|
||||
def _get_db_override():
|
||||
yield db
|
||||
|
||||
return _get_db_override
|
||||
|
||||
|
||||
_PATCHES = [
|
||||
patch("app.api.v1.parcels.get_air_quality_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||||
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_quarter_dump_data",
|
||||
return_value={
|
||||
"nspd_zoning": None,
|
||||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
|
||||
},
|
||||
),
|
||||
patch("app.api.v1.parcels.compute_velocity", return_value=None),
|
||||
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
|
||||
patch(
|
||||
"app.api.v1.parcels.build_ird_analyze_block",
|
||||
return_value={
|
||||
"ird_overlaps": [],
|
||||
"ird_by_kind": {},
|
||||
"opportunity_overlaps": [],
|
||||
"planning_projects": [],
|
||||
"functional_zone": None,
|
||||
"krt": [],
|
||||
"zone_regulation": None,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _start_patches() -> None:
|
||||
for p in _PATCHES:
|
||||
p.start()
|
||||
|
||||
|
||||
def _stop_patches() -> None:
|
||||
for p in _PATCHES:
|
||||
p.stop()
|
||||
|
||||
|
||||
def _poi_row(category: str, name: str, lon: float, lat: float, distance_m: float) -> MagicMock:
|
||||
"""POI-строка как RowMapping: parcels.py делает dict(r), поэтому нужен .keys()+items."""
|
||||
data = {
|
||||
"category": category,
|
||||
"name": name,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"distance_m": distance_m,
|
||||
"last_osm_edit_date": None,
|
||||
}
|
||||
m = MagicMock()
|
||||
m.__getitem__ = lambda self, k: data[k]
|
||||
m.keys = lambda: data.keys()
|
||||
m.__iter__ = lambda self: iter(data)
|
||||
return m
|
||||
|
||||
|
||||
def _distance_in_breakdown(body: dict[str, Any], name: str) -> float:
|
||||
"""Найти distance_m конкретного POI по имени в score_breakdown."""
|
||||
for _cat, items in body["score_breakdown"].items():
|
||||
for it in items:
|
||||
if it["name"] == name:
|
||||
return float(it["distance_m"])
|
||||
raise AssertionError(f"POI {name!r} не найден в score_breakdown: {body['score_breakdown']}")
|
||||
|
||||
|
||||
def test_flag_on_osrm_replaces_distance(monkeypatch) -> None:
|
||||
"""flag ON + mocked OSRM → distance_m заменён дорожным, score отражает road-distance."""
|
||||
from app.core.db import get_db
|
||||
|
||||
# straight-line 200м; OSRM вернёт 700м (дорога длиннее) → меньший decay → меньший score.
|
||||
poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)]
|
||||
db = _make_db_for_analyze(poi_rows)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
monkeypatch.setattr(settings, "use_osrm_distances", True)
|
||||
_start_patches()
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_road_distances_m", return_value=[700.0]
|
||||
) as mock_osrm:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# OSRM вызван ровно один раз (один POI-fetch).
|
||||
assert mock_osrm.call_count >= 1
|
||||
# distance_m в breakdown — дорожное (700), не straight-line (200).
|
||||
assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(700.0, abs=1.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_flag_on_osrm_unavailable_falls_back(monkeypatch) -> None:
|
||||
"""flag ON + OSRM бросает → straight-line fallback, analyze=200 (НЕ 500)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)]
|
||||
db = _make_db_for_analyze(poi_rows)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
monkeypatch.setattr(settings, "use_osrm_distances", True)
|
||||
_start_patches()
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_road_distances_m",
|
||||
side_effect=OsrmLocalUnavailableError("osrm down"),
|
||||
):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
# КРИТИЧНО: /analyze НЕ падает в 500 когда OSRM лежит.
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# distance_m остаётся straight-line baseline (200), OSRM проигнорирован.
|
||||
assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(200.0, abs=1.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_flag_off_never_calls_osrm(monkeypatch) -> None:
|
||||
"""flag OFF (default) → get_road_distances_m НЕ вызывается, distance = straight-line."""
|
||||
from app.core.db import get_db
|
||||
|
||||
poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)]
|
||||
db = _make_db_for_analyze(poi_rows)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
# Явно OFF (default, но фиксируем на случай env-override в окружении).
|
||||
monkeypatch.setattr(settings, "use_osrm_distances", False)
|
||||
_start_patches()
|
||||
try:
|
||||
with patch("app.api.v1.parcels.get_road_distances_m") as mock_osrm:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# OSRM НЕ дёрнут ни разу — байт-в-байт как сегодня.
|
||||
assert mock_osrm.call_count == 0
|
||||
# distance_m = straight-line baseline.
|
||||
assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(200.0, abs=1.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
126
backend/tests/test_osrm_client_local.py
Normal file
126
backend/tests/test_osrm_client_local.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Тесты локального OSRM-клиента (#39 A2) — /table annotations=distance.
|
||||
|
||||
Сеть НЕ дёргается: подменяем httpx.Client на MockTransport. Покрывает:
|
||||
- корректный URL (sources=0, annotations=distance, координаты в path);
|
||||
- парсинг distances в МЕТРАХ + дроп self-index (row[0]);
|
||||
- None для недостижимого POI;
|
||||
- OsrmLocalUnavailableError на 500 / timeout / code!=Ok / битой форме;
|
||||
- пустой destinations → [].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder import osrm_client_local as osrm
|
||||
|
||||
|
||||
def _install_transport(monkeypatch: pytest.MonkeyPatch, handler) -> None:
|
||||
"""Подменить httpx.Client в модуле osrm_client_local на MockTransport(handler).
|
||||
|
||||
Захватываем реальный httpx.Client ДО патча — иначе factory рекурсивно зовёт сама себя.
|
||||
"""
|
||||
real_client = httpx.Client
|
||||
|
||||
def client_factory(*_args, **kwargs):
|
||||
kwargs.pop("transport", None)
|
||||
return real_client(transport=httpx.MockTransport(handler), **kwargs)
|
||||
|
||||
monkeypatch.setattr(osrm.httpx, "Client", client_factory)
|
||||
|
||||
|
||||
def test_builds_correct_url_and_parses_meters(monkeypatch):
|
||||
"""URL: /table/v1/driving/{coords}?sources=0&annotations=distance; distances в метрах."""
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["path"] = request.url.path
|
||||
seen["params"] = dict(request.url.params)
|
||||
# distances[0] = origin→[origin, d1, d2] — метры; self = 0.
|
||||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 1234.5, 6789.0]]})
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
out = osrm.get_road_distances_m(
|
||||
60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]
|
||||
)
|
||||
|
||||
# Дорожные расстояния (метры), self-index сброшен.
|
||||
assert out == [1234.5, 6789.0]
|
||||
# URL: профиль driving, координаты origin первой (lon,lat) и POI.
|
||||
assert seen["path"] == "/table/v1/driving/60.6,56.8;60.61,56.81;60.62,56.82"
|
||||
# Параметры sources=0, annotations=distance.
|
||||
assert seen["params"]["sources"] == "0"
|
||||
assert seen["params"]["annotations"] == "distance"
|
||||
|
||||
|
||||
def test_unreachable_poi_returns_none(monkeypatch):
|
||||
"""OSRM вернул null (нет маршрута) → None в результате (caller оставит straight-line)."""
|
||||
|
||||
def handler(_request):
|
||||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 500.0, None]]})
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||||
assert out == [500.0, None]
|
||||
|
||||
|
||||
def test_empty_destinations_returns_empty():
|
||||
"""Пустой destinations → [] без HTTP-вызова."""
|
||||
assert osrm.get_road_distances_m(60.6, 56.8, []) == []
|
||||
|
||||
|
||||
def test_http_500_raises_unavailable(monkeypatch):
|
||||
"""HTTP 500 → OsrmLocalUnavailableError (не пробрасываем HTTPStatusError)."""
|
||||
|
||||
def handler(_request):
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="HTTP 500"):
|
||||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||
|
||||
|
||||
def test_timeout_raises_unavailable(monkeypatch):
|
||||
"""Timeout/сетевой сбой → OsrmLocalUnavailableError."""
|
||||
|
||||
def handler(_request):
|
||||
raise httpx.ConnectTimeout("timed out")
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="request failed"):
|
||||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||
|
||||
|
||||
def test_code_not_ok_raises_unavailable(monkeypatch):
|
||||
"""code != Ok (например NoRoute) → OsrmLocalUnavailableError."""
|
||||
|
||||
def handler(_request):
|
||||
return httpx.Response(200, json={"code": "NoRoute", "distances": None})
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="code != Ok"):
|
||||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||
|
||||
|
||||
def test_bad_shape_raises_unavailable(monkeypatch):
|
||||
"""Ответ без distances → OsrmLocalUnavailableError."""
|
||||
|
||||
def handler(_request):
|
||||
return httpx.Response(200, json={"code": "Ok", "unexpected": True})
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
with pytest.raises(osrm.OsrmLocalUnavailableError):
|
||||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||||
|
||||
|
||||
def test_length_mismatch_raises_unavailable(monkeypatch):
|
||||
"""Число расстояний != числу destinations → OsrmLocalUnavailableError (рассинхрон)."""
|
||||
|
||||
def handler(_request):
|
||||
# 2 destinations, но distances содержит только self + 1.
|
||||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 500.0]]})
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="!= destinations"):
|
||||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||||
Loading…
Add table
Reference in a new issue