gendesign/backend/tests/test_velocity.py
bot-backend b40995780b
Some checks failed
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
fix(site-finder): раскрыть proxy-fraction в EKB-median velocity-бенчмарке (#2445-A4) (#2448)
2026-07-05 18:54:42 +00:00

604 lines
24 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, has_mapping) через LEFT JOIN
all_competitors + mapped (OBJ-2: ALL competitors included, unmapped has_mapping=False).
Третий вызов execute — bucket_rows (obj_id, room_bucket, units_sold, sqm_sold).
"""
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_rosreestr_fallback,
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",
has_mapping: bool = True,
) -> 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,
# OBJ-2: LEFT JOIN all_competitors — все конкуренты включены,
# has_mapping=True если есть маппинг в objective_complex_mapping.
"has_mapping": has_mapping,
}[k]
return r
def _bucket_row(obj_id: int, room_bucket: str, units_sold: int, sqm_sold: float) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {
"obj_id": obj_id,
"room_bucket": room_bucket,
"units_sold": units_sold,
"sqm_sold": sqm_sold,
}[k]
return r
def _make_db(
comp_rows: list,
sales_rows: list,
bucket_rows: list | None = None,
) -> MagicMock:
"""Сконструировать mock Session с тремя последовательными вызовами execute.
Порядок: comp_rows → sales_rows → bucket_rows.
bucket_rows=None → пустой список (bucket query gracefully degraded).
"""
db = MagicMock()
execute_results = []
for rows in [comp_rows, sales_rows, bucket_rows if bucket_rows is not None else []]:
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, 0.0),
):
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, 0.0),
):
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, 0.0),
):
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
# Audit #2445 A4: DB-fallback (no benchmark data at all) uses the calibrated
# constant, not a contaminated median -> proxy_fraction is 0.0 (nothing to disclose).
assert result.ekb_median_proxy_fraction == pytest.approx(0.0)
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, 0.0),
):
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 → VelocityResult с velocity_data_available=False, score=0.
OBJ-2: функция больше не возвращает None при нулевых продажах —
возвращает «пустое» состояние (velocity_data_available=False, source='none').
Rosreestr-fallback пропускается т.к. cad_quarter не передан.
"""
comp_rows = [_comp_row(1)]
# total_sqm=0 — нет продаж → velocity_data_available=False
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, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.velocity_data_available is False
assert result.velocity_score == pytest.approx(0.0)
assert result.velocity_source == "none"
def test_as_dict_structure():
"""as_dict() содержит все ожидаемые ключи, включая by_room_bucket."""
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=[],
by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}},
velocity_data_available=True,
velocity_source="objective",
objective_coverage_pct=66.7,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=0.25,
)
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)
assert "by_room_bucket" in d
assert d["by_room_bucket"]["1"]["units"] == 10
# OBJ-2: поля velocity_data_available и velocity_source
assert d["velocity_data_available"] is True
assert d["velocity_source"] == "objective"
# SF#1871 P2: coverage% + proxy disclosure
assert d["objective_coverage_pct"] == pytest.approx(66.7)
assert d["proxy_used"] is False
assert d["proxy_sqm_per_deal"] is None
# Audit #2445 A4: ekb_median_proxy_fraction disclosure field, rounded to 3dp.
assert d["ekb_median_proxy_fraction"] == pytest.approx(0.25)
# RANK 1: project_absorption_sqm_per_month присутствует (None по умолчанию).
assert "project_absorption_sqm_per_month" in d
assert d["project_absorption_sqm_per_month"] is None
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, 0.0),
):
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)
def test_by_room_bucket_aggregation():
"""by_room_bucket агрегирует units/sqm поверх всех конкурентов корректно."""
comp_rows = [_comp_row(1), _comp_row(2)]
sales_rows = [
_sales_row(1, total_sqm=3000.0, months=3),
_sales_row(2, total_sqm=2000.0, months=3),
]
bucket_rows = [
_bucket_row(1, "студия", units_sold=38, sqm_sold=1520.0),
_bucket_row(1, "1", units_sold=22, sqm_sold=990.0),
_bucket_row(2, "студия", units_sold=18, sqm_sold=720.0),
_bucket_row(2, "1", units_sold=13, sqm_sold=585.0),
]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=bucket_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert "студия" in result.by_room_bucket
assert "1" in result.by_room_bucket
# студия: 38+18=56 units, complexes from obj 1 and 2
assert result.by_room_bucket["студия"]["units"] == 56
assert result.by_room_bucket["студия"]["complexes_count"] == 2
# 1-к: 22+13=35 units
assert result.by_room_bucket["1"]["units"] == 35
# sqm rounded
assert result.by_room_bucket["студия"]["sqm"] == pytest.approx(2240.0)
def test_by_room_bucket_empty_when_no_bucket_data():
"""Если bucket query вернул пустой список — by_room_bucket пустой dict."""
comp_rows = [_comp_row(1)]
sales_rows = [_sales_row(1, total_sqm=5000.0, months=5)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=[])
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.by_room_bucket == {}
def test_mapping_confidence_gate_in_sales_query():
"""OBJ-2 (#307): sales/bucket queries фильтруют objective_complex_mapping
по confidence — unreviewed low-score auto-matches исключены.
Проверяем, что SQL, переданный в db.execute для маппинг-CTE, содержит
предикат gate (is_reviewed / manual / match_score >= 0.85). Это гарантирует,
что ~115 fuzzy-trgm строк с is_reviewed=false и score<0.85 не попадают в velocity.
"""
comp_rows = [_comp_row(1), _comp_row(2)]
sales_rows = [
_sales_row(1, total_sqm=4000.0, months=4),
_sales_row(2, total_sqm=3000.0, months=4),
]
bucket_rows = [_bucket_row(1, "1", units_sold=20, sqm_sold=900.0)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=bucket_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
# db.execute вызывается 3 раза: comp / sales / bucket. SQL берём из call_args.
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
# comp-query НЕ трогает mapping; sales (idx 1) и bucket (idx 2) — должны иметь gate.
mapping_queries = [s for s in executed_sql if "objective_complex_mapping" in s]
assert len(mapping_queries) >= 2, "ожидаются sales + bucket запросы с mapping"
for sql in mapping_queries:
assert "cm.is_reviewed = TRUE" in sql
assert "cm.match_method = 'manual'" in sql
assert "cm.match_score >= 0.85" in sql
def test_fallback_constant_is_conservative():
"""SF#1871 P2 Fix1: fallback-медиана понижена 4500→750 (audit-verified 593-766)."""
assert _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH == pytest.approx(750.0)
def test_ekb_median_proxy_fraction_surfaced_on_objective_path():
"""Audit #2445 A4: когда _get_ekb_median сигнализирует, что часть строк
city-wide бенчмарка (COALESCE(deals_total_vol_m2, count*45.0)) фабриковалась,
эта доля должна долететь до VelocityResult.ekb_median_proxy_fraction —
а не проглатываться молча (проверяем и dataclass-поле, и as_dict()).
"""
n = 6
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
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)
# 40% строк city-wide медианы — proxy (count*45.0), не реальный deals_total_vol_m2.
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.4),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.velocity_source == "objective"
assert result.ekb_median_proxy_fraction == pytest.approx(0.4)
assert result.as_dict()["ekb_median_proxy_fraction"] == pytest.approx(0.4)
def test_ekb_median_proxy_fraction_surfaced_on_rosreestr_fallback():
"""Audit #2445 A4: proxy_fraction бенчмарка пробрасывается и через
rosreestr_fallback путь (ekb_median посчитан ДО вызова fallback, contamination
в denominator остаётся релевантной вне зависимости от источника velocity)."""
comp_rows = [_comp_row(i) for i in range(1, 3)]
# Ниже _OBJECTIVE_COVERAGE_MIN_RATIO -> forces rosreestr_fallback path.
sales_rows = [_sales_row(1, total_sqm=0.0, months=0, has_mapping=False)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
rr_row = MagicMock()
start_d = datetime.date.fromisoformat("2025-01-01")
end_d = datetime.date.fromisoformat("2025-06-01")
rr_row.__getitem__ = lambda self, k: {
"total_deals": 60,
"period_start": start_d,
"period_end": end_d,
}[k]
rr_result_mock = MagicMock()
rr_result_mock.mappings.return_value.first.return_value = rr_row
# db.execute called: comp / sales / (rosreestr) — patch execute to append rr result.
original_side_effect = list(db.execute.side_effect)
db.execute.side_effect = [*original_side_effect, rr_result_mock]
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.6),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT, cad_quarter="66:41:0702048")
assert result is not None
assert result.velocity_source == "rosreestr_fallback"
assert result.ekb_median_proxy_fraction == pytest.approx(0.6)
assert result.as_dict()["ekb_median_proxy_fraction"] == pytest.approx(0.6)
def test_objective_path_surfaces_coverage_no_proxy():
"""SF#1871 P2 Fix2: objective-путь → proxy_used=False, coverage% посчитан, proxy_sqm=None."""
n = 6
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
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, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.velocity_source == "objective"
assert result.proxy_used is False
assert result.proxy_sqm_per_deal is None
# все 6 конкурентов mapped + с данными → coverage 100%
assert result.objective_coverage_pct == pytest.approx(100.0)
d = result.as_dict()
assert d["objective_coverage_pct"] == pytest.approx(100.0)
assert d["proxy_used"] is False
assert d["proxy_sqm_per_deal"] is None
def test_rosreestr_fallback_marks_proxy_disclosure():
"""SF#1871 P2 Fix2: rosreestr_fallback → proxy_used=True, proxy_sqm_per_deal=45.0,
coverage% пробрасывается из вызывающего кода."""
row = MagicMock()
start_d = datetime.date.fromisoformat("2025-01-01")
end_d = datetime.date.fromisoformat("2025-06-01")
row.__getitem__ = lambda self, k: {
"total_deals": 60,
"period_start": start_d,
"period_end": end_d,
}[k]
result_mock = MagicMock()
result_mock.mappings.return_value.first.return_value = row
db = MagicMock()
db.execute.return_value = result_mock
result = _compute_rosreestr_fallback(
db=db,
cad_quarter="66:41:0702048",
months_window=6,
n_comps=8,
ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
sample_competitors=[],
objective_coverage_pct=25.0,
)
assert result is not None
assert result.velocity_source == "rosreestr_fallback"
assert result.proxy_used is True
assert result.proxy_sqm_per_deal == pytest.approx(45.0)
assert result.objective_coverage_pct == pytest.approx(25.0)
d = result.as_dict()
assert d["proxy_used"] is True
assert d["proxy_sqm_per_deal"] == pytest.approx(45.0)
assert d["objective_coverage_pct"] == pytest.approx(25.0)
def test_objective_path_sets_project_absorption_per_competitor():
"""RANK 1: objective-путь выставляет project_absorption = monthly_velocity /
n_with_sales (темп ОДНОГО типового конкурента), а НЕ суммарную скорость района."""
n = 6
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
# Каждый ЖК: 4500 м² за 6 мес = 750 м²/мес. 6 ЖК → суммарно 4500 м²/мес района.
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, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
# monthly_velocity_sqm = 6 × 750 = 4500 (суммарно по району).
assert result.monthly_velocity_sqm == pytest.approx(4500.0)
# project_absorption = 4500 / 6 = 750 (один типовой конкурент) — НЕ 4500.
assert result.project_absorption_sqm_per_month == pytest.approx(750.0)
assert result.as_dict()["project_absorption_sqm_per_month"] == pytest.approx(750.0)
def test_objective_path_project_absorption_none_when_no_sales():
"""RANK 1: если активных продаж нет (monthly_velocity=0) → absorption None."""
comp_rows = [_comp_row(1)]
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, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.project_absorption_sqm_per_month is None
assert result.as_dict()["project_absorption_sqm_per_month"] is None
def test_rosreestr_fallback_project_absorption_is_none():
"""RANK 1: rosreestr_fallback — квартальный count БЕЗ per-project декомпозиции →
project_absorption None (per-project absorption там ill-defined, НЕ драйвит DCF)."""
row = MagicMock()
start_d = datetime.date.fromisoformat("2025-01-01")
end_d = datetime.date.fromisoformat("2025-06-01")
row.__getitem__ = lambda self, k: {
"total_deals": 60,
"period_start": start_d,
"period_end": end_d,
}[k]
result_mock = MagicMock()
result_mock.mappings.return_value.first.return_value = row
db = MagicMock()
db.execute.return_value = result_mock
result = _compute_rosreestr_fallback(
db=db,
cad_quarter="66:41:0702048",
months_window=6,
n_comps=8,
ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
sample_competitors=[],
objective_coverage_pct=25.0,
)
assert result is not None
assert result.velocity_source == "rosreestr_fallback"
assert result.project_absorption_sqm_per_month is None
assert result.as_dict()["project_absorption_sqm_per_month"] is None
def test_sample_competitors_include_by_room_bucket():
"""sample_competitors каждого элемента содержит by_room_bucket."""
comp_rows = [_comp_row(1), _comp_row(2)]
sales_rows = [
_sales_row(1, total_sqm=6000.0, months=4),
_sales_row(2, total_sqm=4000.0, months=4),
]
bucket_rows = [
_bucket_row(1, "2", units_sold=30, sqm_sold=1800.0),
_bucket_row(2, "2", units_sold=20, sqm_sold=1200.0),
]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=bucket_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
for comp in result.sample_competitors:
assert "by_room_bucket" in comp
# obj_id=1 had bucket data
top = result.sample_competitors[0]
assert top["obj_id"] == 1
assert top["by_room_bucket"].get("2") == 30