From 1d367f5d8eb343b9b584f78ce822a5cd8f50cbb1 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 02:05:56 +0500 Subject: [PATCH] feat(site-finder): OSRM road-distance in /analyze behind flag (#39 A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет straight-line ST_Distance в POI-скоринге /analyze реальным дорожным расстоянием центроид→POI из локального OSRM (/table GET с annotations=distance → метры), за флагом use_osrm_distances. Флаг default OFF → деплой поведенчески-нейтрален: при OFF OSRM не дёргается вообще, /analyze байт-в-байт как сегодня. Включение сдвигает все POI-score на ~20-30% (дорога длиннее прямой) — осознанное позднее решение после валидации, НЕ этот PR. Graceful fallback: любой сбой OSRM (HTTP / timeout / code!=Ok / битая форма) → OsrmLocalUnavailableError → straight-line по всем POI, /analyze НЕ падает в 500. Недостижимый POI (null от OSRM) → сохраняет straight-line. Покрыто и для OSM POI (osm_poi_ekb), и для custom POI (user_custom_pois). Refs #39 --- backend/app/api/v1/parcels.py | 103 +++++++- backend/app/core/config.py | 18 ++ .../services/site_finder/osrm_client_local.py | 140 +++++++++++ .../api/v1/test_analyze_osrm_distances.py | 237 ++++++++++++++++++ backend/tests/test_osrm_client_local.py | 126 ++++++++++ 5 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 backend/app/services/site_finder/osrm_client_local.py create mode 100644 backend/tests/api/v1/test_analyze_osrm_distances.py create mode 100644 backend/tests/test_osrm_client_local.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index f222b3a0..7a3c73f7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index bc36143c..ea0031fb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 + прогрева diff --git a/backend/app/services/site_finder/osrm_client_local.py b/backend/app/services/site_finder/osrm_client_local.py new file mode 100644 index 00000000..c6141c01 --- /dev/null +++ b/backend/app/services/site_finder/osrm_client_local.py @@ -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 "" + 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 diff --git a/backend/tests/api/v1/test_analyze_osrm_distances.py b/backend/tests/api/v1/test_analyze_osrm_distances.py new file mode 100644 index 00000000..47bcd01f --- /dev/null +++ b/backend/tests/api/v1/test_analyze_osrm_distances.py @@ -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() diff --git a/backend/tests/test_osrm_client_local.py b/backend/tests/test_osrm_client_local.py new file mode 100644 index 00000000..a7825dbf --- /dev/null +++ b/backend/tests/test_osrm_client_local.py @@ -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)])