feat(site-finder): OSRM road-distance in /analyze behind flag (#39 A2) #1932

Merged
bot-backend merged 1 commit from feat/39-osrm-analyze-distances into main 2026-06-26 21:25:51 +00:00
5 changed files with 620 additions and 4 deletions

View file

@ -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,6 +1653,30 @@ 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)
@ -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)

View file

@ -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 + прогрева

View 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 originorigin = 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

View 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()

View 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)])