Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""Тесты 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")
|