gendesign/backend/tests/api/v1/test_analyze_osrm_distances.py
Light1YT 453a1f08da fix(report): newbuild-consistent district median + obj_class dedup (#1953)
FIX A (#1955) «Что хорошо продаётся»: убран фантомный класс 'Comfort'.
- Миграция 172: v_bucket_success_score COALESCE(obj_class,'Comfort')
  → COALESCE(obj_class,'не указан'). Английский литерал заполнял 397 NULL
  и сливался отдельным классом от русского 'Комфорт' → визуальные дубли
  бакетов в UI. Источник уже канонически-русский (проверено на проде),
  synonym-mapping не нужен.
- parcels.py: obj_class протаскивается в success-ranking query + dict.
- TS SuccessRankingBucket.obj_class добавлен.

FIX B (#1960) «Медиана рынка» = 64k (квартальная росреестровская n=1 ДКП):
- district.median_price_per_m2 больше не COALESCE(median_12m, ekb_ref) в SQL.
  Basis-приоритет (newbuild-first): Objective по имени района →
  geo_radius (Objective в 3км) → ekb_districts reference →
  квартальная росреестровская медиана ТОЛЬКО при deals_count≥5.
  Для 66:41:0205010:287: 64k → 132690 (geo_radius, newbuild-consistent).
- median_price_basis добавлен в payload + TS type (nullable median).
- Frontend null-guards для нового nullable median.

Tests: +4 (geo_radius basis, objective-приоритет, deals-guard, obj_class
passthrough); обновлены district-моки в 9 analyze-тестах под новую
SQL-сигнатуру.
2026-06-27 15:10:49 +05:00

235 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": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"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 ekb_reference_median" 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()