gendesign/backend/tests/api/v1/test_parcel_best_layouts.py
Light1YT 0acd72a325
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 13m45s
fix(best-layouts): per-object latest snapshot for supply (#1956)
_SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате
(f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной
глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир →
supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал
«Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first
дедуп snapshot'ов объектов, который сам по себе корректен).

Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id,
snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный
последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп
(#1944) сохранён → fan-out по снимкам не возвращается. Глобальный
db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены.

Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675
(per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один
не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно),
но фикс корректно двигает supply с 0 к реальным per-object числам.

Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot;
обновлены mock-фабрики (db.scalar больше не вызывается).
2026-06-27 23:24:28 +05:00

445 lines
17 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.

"""Тесты для POST /api/v1/parcels/{cad_num}/best-layouts (Issue #113 Phase 2.1).
Mock-based — не требуют живой БД.
Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db].
Порядок вызовов в get_best_layouts (Fix SF-01 — inline velocity):
db.execute() calls:
1. _PARCEL_CENTROID_SQL → .mappings().first()
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
3. _INLINE_VELOCITY_SQL → .mappings().all()
4. _SUPPLY_BATCH_SQL → .mappings().all()
Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
(flats_latest CTE) — отдельного db.scalar(MAX(snapshot_date)) больше нет.
"""
from __future__ import annotations
import datetime as dt
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
CAD_NUM = "66:41:0303161:123"
_TODAY = dt.date.today()
def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
"""Центроид участка (EPSG:4326 lon/lat)."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
return r
def _obj_id_row(obj_id: int) -> MagicMock:
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
return r
def _vel_row(
room_bucket: str = "2",
deals_window: float = 48.0,
avg_area: float = 55.0,
avg_price_rub: float | None = 120000.0,
obj_ids: list[int] | None = None,
window_start: dt.date | None = None,
window_end: dt.date | None = None,
) -> MagicMock:
"""Строка из _INLINE_VELOCITY_SQL (Fix SF-01: deals_window за честный интервал)."""
oids = obj_ids if obj_ids is not None else [1]
ws = window_start or _TODAY - dt.timedelta(days=90)
we = window_end or _TODAY
r = MagicMock()
r.__getitem__ = lambda self, k: {
"room_bucket": room_bucket,
"deals_window": deals_window,
"avg_area_m2": avg_area,
"avg_price_per_m2_rub": avg_price_rub,
"competitor_obj_ids": oids,
"competitor_count": len(oids),
"window_start": ws,
"window_end": we,
}[k]
return r
def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
"""Строка из _SUPPLY_BATCH_SQL."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
return r
# ── Построение mock DB ────────────────────────────────────────────────────────
def _make_db(
coord: MagicMock | None = None,
id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL,
отдельного db.scalar(MAX(snapshot_date)) больше нет.
Порядок db.execute():
1. centroid → .mappings().first()
2. competitors-in-radius → .mappings().all()
3. velocity → .mappings().all()
4. supply → .mappings().all()
"""
db = MagicMock()
results: list[MagicMock] = []
# 1: centroid
r0 = MagicMock()
r0.mappings.return_value.first.return_value = coord
results.append(r0)
# 2: competitors-in-radius
r1 = MagicMock()
r1.mappings.return_value.all.return_value = id_rows or []
results.append(r1)
# 3: velocity (only queried if id_rows non-empty)
r2 = MagicMock()
r2.mappings.return_value.all.return_value = vel_rows or []
results.append(r2)
# 4: supply
r3 = MagicMock()
r3.mappings.return_value.all.return_value = supply_rows or []
results.append(r3)
db.execute.side_effect = results
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
def _post(client: TestClient, cad: str = CAD_NUM, **body_kwargs) -> dict:
payload = {"radius_km": 1.0, "time_window": "last_quarter", **body_kwargs}
resp = client.post(f"/api/v1/parcels/{cad}/best-layouts", json=payload)
return resp
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_parcel_not_found_404() -> None:
"""Если центроид не найден → 404."""
db = _make_db(coord=None)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), cad="99:99:9999999:999")
assert resp.status_code == 404, resp.text
finally:
app.dependency_overrides.clear()
def test_empty_competitor_set_returns_low_confidence() -> None:
"""Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
db = _make_db(coord=_coord_row(), id_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low"
assert body["data_quality"]["objects_total_in_radius"] == 0
rec = body["recommendation_for_tz"]
assert rec["based_on_obj_count"] == 0
assert rec["based_on_total_deals"] == 0
assert rec["mix"] == []
finally:
app.dependency_overrides.clear()
def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
"""3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100.
last_quarter (3 мес): velocity = deals_window / 3.0
studio: 9/3=3.0, 1: 24/3=8.0, 2: 48/3=16.0 → rank1="2"
"""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
vel_rows = [
_vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]),
_vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]),
_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]),
]
supply_rows = [
_supply_row("studio", "<25", 20),
_supply_row("1", "40-60", 60),
_supply_row("2", "40-60", 80),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), time_window="last_quarter")
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 3
# rank 1 = самая высокая velocity (2-комн: 48/3=16.0 per month)
assert top[0]["rank"] == 1
assert top[0]["room_bucket"] == "2"
# все ранги уникальны
assert sorted(t["rank"] for t in top) == [1, 2, 3]
# sum pct = 100
mix = body["recommendation_for_tz"]["mix"]
assert sum(m["pct"] for m in mix) == 100
finally:
app.dependency_overrides.clear()
def test_exclude_competitor_obj_ids_filter() -> None:
"""exclude_competitor_obj_ids исключает obj_id: при all excluded → пустой ответ."""
# Если после исключения obj_id_list пуст → _empty_response → top_layouts=[]
id_rows = [_obj_id_row(20)] # единственный конкурент
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), exclude_competitor_obj_ids=[20])
assert resp.status_code == 200, resp.text
body = resp.json()
# После исключения obj_id=20 список пуст → пустой ответ
assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low"
# objects_total_in_radius = 1 (до исключения)
assert body["data_quality"]["objects_total_in_radius"] == 1
finally:
app.dependency_overrides.clear()
def test_min_velocity_per_month_filters_low_rows() -> None:
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.
last_quarter (3 мес): studio=6/3=2.0 < 5.0 (убран), 1=30/3=10.0 > 5.0 (остаётся).
"""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("studio", deals_window=6.0, obj_ids=[1]),
_vel_row("1", deals_window=30.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), min_velocity_per_month=5.0)
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 1
assert top[0]["room_bucket"] == "1"
assert top[0]["velocity_per_month"] == pytest.approx(10.0)
finally:
app.dependency_overrides.clear()
def test_time_window_velocity_scaling() -> None:
"""last_month vs last_year дают разный velocity_per_month для одних deals.
Fix SF-01: velocity_per_month = deals_window / months_in_window. Делители
из _TIME_WINDOW_PARAMS: last_month=1.0, last_year=12.0. При одинаковом
deals_window короткое окно даёт ВЫШЕ месячную velocity (делитель меньше).
"""
# deals_window=24 → last_month: 24/1=24.0, last_year: 24/12=2.0
id_rows = [_obj_id_row(1)]
vel_rows_fixed = [_vel_row("2", deals_window=24.0, obj_ids=[1])]
from app.core.db import get_db
# last_month
db_m = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
app.dependency_overrides[get_db] = _override_db(db_m)
try:
resp_m = _post(TestClient(app), time_window="last_month")
assert resp_m.status_code == 200, resp_m.text
v_month = resp_m.json()["top_layouts"][0]["velocity_per_month"]
finally:
app.dependency_overrides.clear()
# last_year
db_y = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
app.dependency_overrides[get_db] = _override_db(db_y)
try:
resp_y = _post(TestClient(app), time_window="last_year")
assert resp_y.status_code == 200, resp_y.text
v_year = resp_y.json()["top_layouts"][0]["velocity_per_month"]
finally:
app.dependency_overrides.clear()
# last_month velocity выше (делитель меньше: 1 vs 12) при одинаковом deals_window
assert v_month > v_year
assert v_month == pytest.approx(24.0)
assert v_year == pytest.approx(2.0)
def test_obj_class_filter_passes_through() -> None:
"""obj_class_filter передаётся в SQL — endpoint не ломается, возвращает 200."""
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(5)],
vel_rows=[_vel_row("2", obj_ids=[5])],
supply_rows=[],
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), obj_class_filter="comfort")
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["top_layouts"]) > 0
finally:
app.dependency_overrides.clear()
def test_mv_empty_for_competitors_returns_empty_top_layouts() -> None:
"""Конкуренты есть в радиусе, но MV пустой → top_layouts=[], data_quality.confidence=low."""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["top_layouts"] == []
dq = body["data_quality"]
assert dq["objects_total_in_radius"] == 2
assert dq["objects_with_velocity_data"] == 0
assert dq["confidence"] == "low"
finally:
app.dependency_overrides.clear()
def test_target_total_flats_fills_abs_units() -> None:
"""target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("1", deals_window=60.0, obj_ids=[1]),
_vel_row("2", deals_window=40.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), target_total_flats=100)
assert resp.status_code == 200, resp.text
mix = resp.json()["recommendation_for_tz"]["mix"]
# все abs_units заполнены
for m in mix:
assert m["abs_units"] is not None
# сумма abs_units близка к 100 (round-off ±1)
total_abs = sum(m["abs_units"] for m in mix)
assert 98 <= total_abs <= 102
finally:
app.dependency_overrides.clear()
def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
"""raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True."""
id_rows = [_obj_id_row(1)]
# deals_window=199, supply=100 → raw = 199% (несопоставимые окна)
vel_rows = [_vel_row("2", deals_window=199.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 100)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 1
row = top[0]
assert row["sold_pct_of_supply"] == 100.0, "sold_pct_of_supply должен быть clamped до 100"
assert row["is_oversold"] is True, "is_oversold должен быть True когда raw > 100"
finally:
app.dependency_overrides.clear()
def test_sold_pct_below_100_is_not_oversold() -> None:
"""raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False."""
id_rows = [_obj_id_row(1)]
# deals_window=50, supply=100 → raw = 50%
vel_rows = [_vel_row("1", deals_window=50.0, obj_ids=[1])]
supply_rows = [_supply_row("1", "40-60", 100)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 1
row = top[0]
assert row["sold_pct_of_supply"] == pytest.approx(50.0)
assert row["is_oversold"] is False
finally:
app.dependency_overrides.clear()
def test_filter_competitor_obj_ids_applied() -> None:
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
# После фильтрации остаётся только obj_id=1, velocity запрос получит [1]
vel_rows = [_vel_row("2", deals_window=24.0, obj_ids=[1])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), filter_competitor_obj_ids=[1])
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) >= 1
# competitor_obj_ids должен содержать только 1
for row in top:
for oid in row["competitor_obj_ids"]:
assert oid == 1
finally:
app.dependency_overrides.clear()