gendesign/backend/tests/test_osrm_client_local.py
Light1YT 1d367f5d8e
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m47s
CI / backend-tests (pull_request) Successful in 12m5s
feat(site-finder): OSRM road-distance in /analyze behind flag (#39 A2)
Заменяет 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
2026-06-27 02:05:56 +05:00

126 lines
5.1 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.

"""Тесты локального 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)])