gendesign/backend/tests/services/site_finder/test_competitors_parking.py
bot-backend 5507bbc1a4
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
feat(site-finder): domrf→cad geom-match + parking_ratio конкурентов (#96) (#1327)
2026-06-14 14:49:28 +00:00

286 lines
11 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.

"""Тесты 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")