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-сигнатуру.
235 lines
9 KiB
Python
235 lines
9 KiB
Python
"""Тесты 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()
|