gendesign/backend/tests/test_velocity.py

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)