gendesign/backend/tests/api/v1/test_parcel_competitors.py
Light1YT 6a32acb3aa
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m32s
Deploy / build-worker (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m56s
fix(competitors): size-weight avg velocity by flats_total (#949 audit, option B)
weighted_avg_velocity was a naive mean despite the name — a 500-flat ЖК weighed
the same as a 20-flat one. Now count-weighted by flats_total (sql.md AVG
principle): Σ(velocity*flats_total)/Σ(flats_total). Competitors with unknown
flats_total are excluded from weights; if sizes are unknown for ALL, graceful
fallback to the simple mean (den>0 guard). Field name + API contract UNCHANGED
(zero consumer ripple — traced: only CompetitorsSummary, no frontend ref).

Tests: equal sizes → weighted==naive (existing 6.0 stays); NEW test with
500-flat@40 + 20-flat@2 → 38.54 (not naive 21.0), proving the weighting.
2026-06-04 14:05:20 +05:00

417 lines
15 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}/competitors (Issue #112).
Mock-based — не требуют живой БД.
Паттерн mock DB: аналогично test_admin_cadastre.py — dependency_overrides[get_db].
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock:
"""Строка центроида участка."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k]
return r
def _obj_row(
obj_id: int = 1,
distance_m: float = 400.0,
site_status: str = "sales",
obj_class: str | None = "comfort",
velocity: float = 5.0,
flat_count: int | None = 200,
) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"obj_class": obj_class,
"latitude": 56.838 + distance_m / 1_000_000,
"longitude": 60.605 + distance_m / 1_000_000,
"flat_count": flat_count,
"site_status": site_status,
"distance_m": distance_m,
"velocity_per_month": velocity,
}[k]
return r
def _price_row(obj_id: int, price: float) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k]
return r
# ── Построение mock DB ────────────────────────────────────────────────────────
def _make_db(
coord: MagicMock | None = None,
obj_rows: list[MagicMock] | None = None,
price_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок вызовов execute:
1. centroid query → coord
2. competitors query → obj_rows
3. avg_price query → price_rows
"""
db = MagicMock()
results: list[MagicMock] = []
for rows, is_first in [
(coord, True),
(obj_rows or [], False),
(price_rows or [], False),
]:
result = MagicMock()
if is_first:
# centroid → .mappings().first()
result.mappings.return_value.first.return_value = rows
else:
result.mappings.return_value.all.return_value = rows
results.append(result)
db.execute.side_effect = results
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_competitors_basic() -> None:
"""3 конкурента → корректная форма ответа, сортировка по distance."""
rows = [
_obj_row(obj_id=1, distance_m=200.0, velocity=4.0),
_obj_row(obj_id=2, distance_m=500.0, velocity=6.0),
_obj_row(obj_id=3, distance_m=900.0, velocity=2.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:123/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "competitors" in body
assert "summary" in body
assert len(body["competitors"]) == 3
# первый должен быть ближайшим
assert body["competitors"][0]["obj_id"] == 1
assert body["competitors"][0]["distance_m"] == pytest.approx(200.0)
# все поля присутствуют
first = body["competitors"][0]
for key in (
"obj_id",
"comm_name",
"dev_name",
"obj_class",
"distance_m",
"lat",
"lng",
"stage",
"flats_total",
"flats_sold",
"sold_pct",
"velocity_per_month",
"avg_price_per_m2",
"is_active",
):
assert key in first, f"missing key: {key}"
finally:
app.dependency_overrides.clear()
def test_competitors_summary_calc() -> None:
"""summary: total_competitors, active_count, weighted_avg_velocity корректны."""
rows = [
_obj_row(obj_id=1, site_status="sales", velocity=10.0),
_obj_row(obj_id=2, site_status="construction", velocity=6.0),
_obj_row(obj_id=3, site_status="completed", velocity=2.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
summary = resp.json()["summary"]
assert summary["total_competitors"] == 3
assert summary["active_count"] == 2 # sales + construction
# equal flats_total (200 each) → size-weighted AVG == naive: (10+6+2)/3 = 6.0
assert summary["weighted_avg_velocity"] == pytest.approx(6.0)
assert summary["radius_km"] == pytest.approx(1.0)
assert summary["time_window"] == "last_quarter"
finally:
app.dependency_overrides.clear()
def test_competitors_weighted_velocity_by_size() -> None:
"""weighted_avg_velocity взвешен по flats_total — крупный ЖК весит больше (#949)."""
rows = [
_obj_row(obj_id=1, velocity=40.0, flat_count=500),
_obj_row(obj_id=2, velocity=2.0, flat_count=20),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
wav = resp.json()["summary"]["weighted_avg_velocity"]
# size-weighted = (40*500 + 2*20) / (500+20) = 20040/520 ≈ 38.54,
# НЕ наивное среднее (40+2)/2 = 21.0 — доказывает взвешивание по flats_total.
assert wav == pytest.approx(38.54, abs=0.05)
assert wav != pytest.approx(21.0, abs=0.1)
finally:
app.dependency_overrides.clear()
def test_competitors_exclude_obj_ids() -> None:
"""exclude_obj_ids исключает указанные ЖК из результата."""
rows = [
_obj_row(obj_id=1, distance_m=100.0),
_obj_row(obj_id=2, distance_m=200.0),
_obj_row(obj_id=3, distance_m=300.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]},
)
assert resp.status_code == 200, resp.text
ids = [c["obj_id"] for c in resp.json()["competitors"]]
assert 2 not in ids
assert 1 in ids
assert 3 in ids
finally:
app.dependency_overrides.clear()
def test_competitors_obj_class_filter() -> None:
"""obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается."""
rows = [_obj_row(obj_id=10, obj_class="economy")]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"},
)
assert resp.status_code == 200, resp.text
comps = resp.json()["competitors"]
assert len(comps) == 1
assert comps[0]["obj_class"] == "economy"
finally:
app.dependency_overrides.clear()
def test_competitors_time_window_velocity() -> None:
"""time_window влияет на velocity_per_month (last_month vs last_year)."""
# Здесь проверяем, что endpoint принимает оба варианта без ошибок
# и возвращает velocity из mock-строки (DB-расчёт мокирован).
rows_month = [_obj_row(obj_id=1, velocity=12.0)]
rows_year = [_obj_row(obj_id=1, velocity=3.0)]
from app.core.db import get_db
# last_month
db_m = _make_db(coord=_coord_row(), obj_rows=rows_month)
app.dependency_overrides[get_db] = _override_db(db_m)
try:
client = TestClient(app)
resp_m = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_month"},
)
assert resp_m.status_code == 200, resp_m.text
finally:
app.dependency_overrides.clear()
# last_year
db_y = _make_db(coord=_coord_row(), obj_rows=rows_year)
app.dependency_overrides[get_db] = _override_db(db_y)
try:
client = TestClient(app)
resp_y = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_year"},
)
assert resp_y.status_code == 200, resp_y.text
v_month = resp_m.json()["competitors"][0]["velocity_per_month"]
v_year = resp_y.json()["competitors"][0]["velocity_per_month"]
# month velocity выше чем year в нашем моке
assert v_month > v_year
finally:
app.dependency_overrides.clear()
def test_competitors_parcel_not_found_404() -> None:
"""Если центроид участка не найден → 404."""
db = _make_db(coord=None) # first() вернёт None
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/99:99:9999999:999/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 404, resp.text
finally:
app.dependency_overrides.clear()
def test_competitors_empty_radius() -> None:
"""Нет конкурентов в радиусе → пустой список + summary с нулями."""
db = _make_db(coord=_coord_row(), obj_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 0.1, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["competitors"] == []
assert body["summary"]["total_competitors"] == 0
assert body["summary"]["active_count"] == 0
assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0)
finally:
app.dependency_overrides.clear()
def test_competitors_sold_pct_null() -> None:
"""sold_pct и flats_sold — None (MVP: данные недоступны из domrf_kn_objects).
Полный расчёт продаж требует JOIN с domrf_kn_flats COUNT WHERE status='sold'
— отложен за пределы текущего PR.
"""
rows = [_obj_row(obj_id=1, flat_count=200)]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["flats_sold"] is None
assert comp["sold_pct"] is None
assert comp["flats_total"] == 200
finally:
app.dependency_overrides.clear()
def test_competitors_avg_price_populated() -> None:
"""avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой.
Регрессионный тест для Issue #227: фильтр status='sold' давал 0 строк
(поле status в domrf_kn_flats 99.8% NULL). После фикса — убран, AVG
считается по всем квартирам с known price_per_m2.
"""
rows = [_obj_row(obj_id=1)]
price_rows = [_price_row(obj_id=1, price=150_000.0)]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
)
finally:
app.dependency_overrides.clear()
def test_competitors_is_active_flag() -> None:
"""is_active=True для sales/construction, False для completed/null."""
rows = [
_obj_row(obj_id=1, site_status="sales"),
_obj_row(obj_id=2, site_status="construction"),
_obj_row(obj_id=3, site_status="completed"),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
assert comps[1]["is_active"] is True
assert comps[2]["is_active"] is True
assert comps[3]["is_active"] is False
finally:
app.dependency_overrides.clear()