Resolve conflict in best_layouts.py: keep SF-01 inline velocity (deals_window numerator) + add SF-05 sold_pct clamp at 100% and is_oversold flag from main.
443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""Тесты для 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.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty)
|
||
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() (пропускается если latest_snap is None)
|
||
"""
|
||
|
||
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,
|
||
latest_snap: dt.date | None = None,
|
||
) -> MagicMock:
|
||
"""Сконструировать mock Session.
|
||
|
||
db.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply.
|
||
Порядок db.execute():
|
||
1. centroid → .mappings().first()
|
||
2. competitors-in-radius → .mappings().all()
|
||
3. velocity → .mappings().all()
|
||
4. supply → .mappings().all() (только если latest_snap is not None)
|
||
"""
|
||
db = MagicMock()
|
||
|
||
# db.scalar — pre-computed MAX(snapshot_date) для supply query
|
||
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
|
||
|
||
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."""
|
||
# sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0
|
||
id_rows = [_obj_id_row(1)]
|
||
vel_rows_fixed = [_vel_row("2", sum_deals=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_year velocity должна быть выше (делитель меньше: 2 vs 24)
|
||
assert v_year > v_month
|
||
assert v_month == pytest.approx(1.0)
|
||
assert v_year == pytest.approx(12.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", sum_deals=60.0, obj_ids=[1]),
|
||
_vel_row("2", sum_deals=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)]
|
||
# sum_deals=199, supply=100 → raw = 199% (несопоставимые окна)
|
||
vel_rows = [_vel_row("2", sum_deals=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,
|
||
latest_snap=dt.date.today(),
|
||
)
|
||
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)]
|
||
# sum_deals=50, supply=100 → raw = 50%
|
||
vel_rows = [_vel_row("1", sum_deals=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,
|
||
latest_snap=dt.date.today(),
|
||
)
|
||
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", sum_deals=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()
|