gendesign/backend/tests/api/v1/test_analyze_osrm_distances.py
bot-backend f48db87d39
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m40s
Deploy / build-worker (push) Successful in 2m43s
feat(site-finder): OSRM road-distance in /analyze behind flag (#39 A2) (#1932)
2026-06-26 21:25:50 +00:00

237 lines
9 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 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()