"""Тесты geom-match domrf↔cad_buildings + wire parking_ratio (#96 wiring). Без живой БД и без сети: мокаем db.execute (PostGIS KNN-строка), get_competitors, resolve_cad_for_domrf и get_building_premises_for_match. Проверяем: • resolve_cad_for_domrf — точка внутри footprint → cad_num; вне радиуса → None; нет зданий → None; objdoc_id из cad_buildings прокидывается. • get_competitors_parking — top-N конкурентов получают parking_ratio; промах geom-match / None-premises → поля None (graceful, не падаем), matched_count. """ from __future__ import annotations import os from typing import Any from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.schemas.nspd_bulk import ObjectsListing from app.schemas.parcel import ( Competitor, CompetitorsRequest, CompetitorsResponse, CompetitorsSummary, ) from app.services.site_finder import competitors as competitors_mod from app.services.site_finder import premises_lookup as pl from app.services.site_finder.premises_lookup import BuildingMatch, resolve_cad_for_domrf # ── resolve_cad_for_domrf (geom-match) ───────────────────────────────────────── def _db_returning(row: dict[str, Any] | None) -> MagicMock: """Мок Session.execute(...).mappings().first() → row (или None).""" db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = row return db def test_resolve_cad_inside_footprint_returns_cad() -> None: """Точка внутри footprint (distance 0) → BuildingMatch с cad_num + objdoc_id.""" db = _db_returning({"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 0.0}) match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6) assert match is not None assert match.cad_num == "66:41:0106036:183" assert match.objdoc_id == 40995027 assert match.distance_m == 0.0 def test_resolve_cad_within_radius_returns_cad() -> None: """Ближайшее здание в пределах радиуса (30м ≤ 50м) → матч.""" db = _db_returning({"cad_num": "66:41:0106036:9", "objdoc_id": None, "distance_m": 30.0}) match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6) assert match is not None assert match.cad_num == "66:41:0106036:9" # objdoc_id NULL в cad_buildings → None (фоллбэк на cad_num search в lookup). assert match.objdoc_id is None assert match.distance_m == 30.0 def test_resolve_cad_beyond_radius_returns_none() -> None: """Ближайшее здание дальше радиуса (80м > 50м) → None (не приписываем чужое).""" db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0}) assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None def test_resolve_cad_custom_radius_accepts_far_match() -> None: """max_dist_m=100 принимает 80м-матч, который дефолтные 50м отвергли бы.""" db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0}) match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6, max_dist_m=100.0) assert match is not None assert match.cad_num == "66:41:0106036:1" def test_resolve_cad_no_buildings_returns_none() -> None: """Нет зданий с geom (пустой результат) → None.""" db = _db_returning(None) assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None def test_resolve_cad_sql_error_returns_none() -> None: """SQL/PostGIS-сбой → None (graceful, lazy-endpoint не падает).""" db = MagicMock() db.execute.side_effect = RuntimeError("postgis boom") assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None # ── get_competitors_parking (wire) ────────────────────────────────────────────── def _competitor(obj_id: int, lat: float = 56.84, lng: float = 60.6) -> Competitor: """Минимальный Competitor для wire-теста (только нужные wire поля заполнены).""" return Competitor( obj_id=obj_id, comm_name=f"ЖК-{obj_id}", dev_name=None, obj_class="комфорт", distance_m=100.0, lat=lat, lng=lng, stage="Строящиеся", flats_total=200, flats_sold=None, sold_pct=None, velocity_per_month=5.0, avg_price_per_m2=None, is_active=True, ) def _competitors_response(*obj_ids: int) -> CompetitorsResponse: comps = [_competitor(oid) for oid in obj_ids] return CompetitorsResponse( competitors=comps, summary=CompetitorsSummary( total_competitors=len(comps), active_count=len(comps), weighted_avg_velocity=5.0, radius_km=1.0, time_window="last_quarter", ), ) def test_parking_wire_happy_path(monkeypatch: Any) -> None: """top-конкурент сматчился на здание → получает parking_ratio из НСПД.""" monkeypatch.setattr( competitors_mod, "get_competitors", lambda **_: _competitors_response(101) ) monkeypatch.setattr( competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3), ) listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100) monkeypatch.setattr( competitors_mod, "get_building_premises_for_match", lambda _m: listing ) out = competitors_mod.get_competitors_parking( MagicMock(), "66:41:0303001:1", CompetitorsRequest() ) assert out.matched_count == 1 assert len(out.items) == 1 item = out.items[0] assert item.obj_id == 101 assert item.matched_cad_num == "66:41:0106036:183" assert item.match_distance_m == 12.3 assert item.parking_ratio == 0.5 assert item.parking_count == 100 assert item.flats_count == 200 def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None: """Из 5 конкурентов обогащаются только первые top_n (строгий лимит).""" monkeypatch.setattr( competitors_mod, "get_competitors", lambda **_: _competitors_response(1, 2, 3, 4, 5), ) calls: list[float] = [] def _resolve(_db: Any, *, lat: float, lon: float) -> BuildingMatch: calls.append(lat) return BuildingMatch("66:41:0106036:1", 1, 0.0) monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", _resolve) monkeypatch.setattr( competitors_mod, "get_building_premises_for_match", lambda _m: ObjectsListing(objdoc_id=1, flats_count=10, parking_count=5), ) out = competitors_mod.get_competitors_parking( MagicMock(), "66:41:0303001:1", CompetitorsRequest(), top_n=2 ) assert len(out.items) == 2 assert len(calls) == 2 # geom-match вызван ровно top_n раз — не на всех 5 assert {i.obj_id for i in out.items} == {1, 2} def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None: """geom-match промахнулся → конкурент в items с None-полями, matched_count=0.""" monkeypatch.setattr( competitors_mod, "get_competitors", lambda **_: _competitors_response(7) ) monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None) premises = MagicMock() monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises) out = competitors_mod.get_competitors_parking( MagicMock(), "66:41:0303001:1", CompetitorsRequest() ) assert out.matched_count == 0 assert out.items[0].obj_id == 7 assert out.items[0].matched_cad_num is None assert out.items[0].parking_ratio is None premises.assert_not_called() # без матча НСПД не дёргаем def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None: """Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None.""" monkeypatch.setattr( competitors_mod, "get_competitors", lambda **_: _competitors_response(9) ) monkeypatch.setattr( competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: BuildingMatch("66:41:0106036:5", None, 40.0), ) monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: None) out = competitors_mod.get_competitors_parking( MagicMock(), "66:41:0303001:1", CompetitorsRequest() ) assert out.matched_count == 0 assert out.items[0].matched_cad_num == "66:41:0106036:5" assert out.items[0].match_distance_m == 40.0 assert out.items[0].parking_ratio is None def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None: """Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500.""" monkeypatch.setattr( competitors_mod, "get_competitors", lambda **_: _competitors_response(11) ) monkeypatch.setattr( competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: BuildingMatch("66:41:0106036:5", 1, 0.0), ) def _boom(_m: Any) -> Any: raise RuntimeError("nspd exploded") monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", _boom) out = competitors_mod.get_competitors_parking( MagicMock(), "66:41:0303001:1", CompetitorsRequest() ) assert out.matched_count == 0 assert out.items[0].matched_cad_num == "66:41:0106036:5" assert out.items[0].parking_ratio is None # ── get_building_premises_for_match (short-circuit objdoc) ────────────────────── def test_premises_for_match_uses_objdoc_skips_search(monkeypatch: Any) -> None: """objdoc_id из cad_buildings → пропускаем НСПД search, сразу tab-group.""" listing = ObjectsListing(objdoc_id=555, flats_count=50, parking_count=60) monkeypatch.setattr(pl.asyncio, "run", lambda coro: (coro.close(), listing)[1]) # search_by_cad НЕ должен вызываться при known objdoc_id. full_path = MagicMock() monkeypatch.setattr(pl, "get_building_premises", full_path) out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", 555, 0.0)) assert out is listing full_path.assert_not_called() def test_premises_for_match_no_objdoc_falls_back(monkeypatch: Any) -> None: """objdoc_id None → фоллбэк на get_building_premises(cad_num) (2 НСПД-запроса).""" listing = ObjectsListing(objdoc_id=1, flats_count=10, parking_count=3) fallback = MagicMock(return_value=listing) monkeypatch.setattr(pl, "get_building_premises", fallback) out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", None, 0.0)) assert out is listing fallback.assert_called_once_with("66:41:0106036:5")