gendesign/backend/tests/test_velocity.py
lekss361 8fa1d005fd
feat(velocity): migrate D2 sales source → objective_corpus_room_month (#158)
* feat(velocity): migrate D2 sales source from stale sale_graph to objective_corpus_room_month

Closes #156

* fix(velocity): cross-stack contract + NULL safety per pre-push review

3 pre-push code-reviewer findings fixed:

1. TS union extended ('objective'|'sale_graph'|'rosreestr_fallback')
   + UI conditions handle both objective и sale_graph как valid sources.
2. COALESCE(deals_total_vol_m2, deals_total_count * 45.0) — NULL safety
   for DKP-only rows (vol_m2 nullable, count > 0).
3. room_bucket parking filter — verified false positive (все 5 buckets
   apartments: студия/1/2/3/4+).

Refs: PR #157 pre-push code-reviewer

* fix(types): add 'objective' to velocity_source union (cherry-pick miss)

Cherry-pick от закрытого PR #157 потерял этот файл. Frontend type-check fails
с 'no overlap' error. Restoring.

Refs: PR #158 frontend CI failure

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 09:34:00 +03:00

237 lines
8.2 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.

"""Tests for velocity-score service (#34 D2).
Mock-based — не требуют живой БД.
Источник данных — objective_corpus_room_month (мигрировано с domrf_kn_sale_graph).
Mock shape совместим: sales query возвращает те же aliases (obj_id, total_sqm,
months_with_data, period_start, period_end) через GROUP BY domrf_obj_id.
"""
from __future__ import annotations
import datetime
from unittest.mock import MagicMock, patch
import pytest
from app.services.site_finder.velocity import (
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
VelocityResult,
compute_velocity,
)
# Тестовый WKT — небольшой квадрат в центре ЕКБ.
_PARCEL_WKT = "POINT(60.605 56.838)"
# ── Вспомогательные фабрики mock-строк ────────────────────────────────────────
def _comp_row(obj_id: int, distance_m: float = 500.0) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"obj_class": "комфорт",
"district_name": "Ленинский",
"distance_m": distance_m,
}[k]
return r
def _sales_row(
obj_id: int,
total_sqm: float,
months: int,
start: str = "2024-11-01",
end: str = "2025-04-01",
) -> MagicMock:
r = MagicMock()
start_d = datetime.date.fromisoformat(start)
end_d = datetime.date.fromisoformat(end)
r.__getitem__ = lambda self, k: {
"obj_id": obj_id,
"total_sqm": total_sqm,
"months_with_data": months,
"period_start": start_d,
"period_end": end_d,
}[k]
return r
def _make_db(comp_rows: list, sales_rows: list) -> MagicMock:
"""Сконструировать mock Session с двумя последовательными вызовами execute."""
db = MagicMock()
execute_results = []
for rows in [comp_rows, sales_rows]:
result = MagicMock()
result.mappings.return_value.all.return_value = rows
execute_results.append(result)
db.execute.side_effect = execute_results
return db
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_no_competitors_returns_none():
"""Нет ЖК в радиусе → None."""
db = _make_db(comp_rows=[], sales_rows=[])
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is None
def test_no_sales_data_returns_none():
"""ЖК есть, но нет данных objective_corpus_room_month → None."""
comp_rows = [_comp_row(1), _comp_row(2)]
db = _make_db(comp_rows=comp_rows, sales_rows=[])
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is None
def test_healthy_sales_returns_result():
"""12 конкурентов с нормальными продажами → score в (0,1), confidence='high'."""
n = 12
comp_rows = [_comp_row(i, distance_m=300.0 + i * 100) for i in range(1, n + 1)]
# Каждый ЖК продаёт 4500 м² за 6 мес → 750 м²/мес. Суммарно: 4500*12 = 54000 за 6 мес.
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.competitors_count == n
assert 0.0 < result.velocity_score <= 1.0
assert result.confidence == "high"
assert result.months_observed == 6
def test_few_competitors_low_confidence():
"""2 конкурента → confidence='low'."""
comp_rows = [_comp_row(1), _comp_row(2)]
sales_rows = [_sales_row(1, total_sqm=3000.0, months=2)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.confidence == "low"
def test_medium_confidence():
"""7 конкурентов, 4 месяца → confidence='medium'."""
n = 7
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
sales_rows = [_sales_row(i, total_sqm=4000.0, months=4) for i in range(1, n + 1)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.confidence == "medium"
def test_ekb_median_fallback_used_when_none():
"""Если _get_ekb_median вернул None — используется fallback-константа."""
comp_rows = [_comp_row(1)]
sales_rows = [_sales_row(1, total_sqm=9000.0, months=6)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch("app.services.site_finder.velocity._get_ekb_median", return_value=None):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.ekb_median_sqm == _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
def test_score_capped_at_1():
"""Огромный объём → score не превышает 1.0."""
comp_rows = [_comp_row(1)]
# 1 000 000 м² за месяц — абсурдно много
sales_rows = [_sales_row(1, total_sqm=6_000_000.0, months=6)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.velocity_score == pytest.approx(1.0)
def test_score_zero_when_no_sales_sqm():
"""total_sqm=0 → None (нет данных, не score=0)."""
comp_rows = [_comp_row(1)]
# total_sqm=0 — нет продаж → должен вернуть None
sales_rows = [_sales_row(1, total_sqm=0.0, months=5)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is None
def test_as_dict_structure():
"""as_dict() содержит все ожидаемые ключи."""
vr = VelocityResult(
competitors_count=5,
monthly_velocity_sqm=3000.0,
ekb_median_sqm=4500.0,
velocity_score=0.333,
confidence="medium",
months_observed=4,
period_start="2024-11",
period_end="2025-02",
sample_competitors=[],
)
d = vr.as_dict()
assert "competitors_count" in d
assert "velocity_score" in d
assert "confidence" in d
assert "period" in d
assert d["period"]["start"] == "2024-11"
assert d["period"]["end"] == "2025-02"
assert d["velocity_score"] == pytest.approx(0.333, abs=1e-3)
def test_sample_competitors_top5():
"""sample_competitors содержит не более 5 элементов, отсортированных по убыванию."""
n = 8
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
sales_rows = [_sales_row(i, total_sqm=float(i * 1000), months=5) for i in range(1, n + 1)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert len(result.sample_competitors) <= 5
sqms = [c["total_sqm_period"] for c in result.sample_competitors]
assert sqms == sorted(sqms, reverse=True)