Root causes fixed:
1. Bug_Velocity_Mix_Static: bucket_v = market_vel_pm × share/100 made all
bucket velocities proportional to market share, so Σ bucket_v ≡ market_vel_pm
regardless of mix. Moving a slider changed share_pct but not the aggregate
срок продажи (velocity numerator and denominator both scaled with share).
2. Bug_Velocity_Unrealistic: rosreestr fallback used city-wide total_deals
(region 66, ~3815 deals/24 mo = 159 кв/мес) divided by district-only
competitors_weighted (~5-10 ЖК), yielding 16-32 кв/мес per project
and срок ~0.7 мес instead of realistic months.
Changes:
- Add _n_active_zhk_region(db, region_code): COUNT DISTINCT active ЖК
in the full region — used as city-wide denominator for rosreestr fallback.
Lazy-cached to avoid duplicate DB round-trips.
- Add _velocity_baseline_per_bucket(db, ...): query objective_corpus_room_month
grouped by room_bucket (same mapping as _elasticity_per_bucket_coef) to get
per-bucket median velocity. Returns None when < 3 observations.
- Replace static bucket_market_velocities computation in recommend_mix:
- Objective path: use _velocity_baseline_per_bucket per-bucket medians.
Each bucket is an independent constant (not share-derived).
- Rosreestr fallback: bucket_deals / months / N_active_region.
N_active_region (200-400+ for EKB) replaces competitors_district (5-10),
giving realistic per-project velocities (~0.1-0.6 кв/мес per bucket).
- Add bucket["velocity_source"] = "objective_per_bucket" | "rosreestr_fallback"
for transparency in API response and UI warnings.
- Add scope["n_active_region"] for debugging.
- Add 19 unit tests in test_recommend_mix_velocity.py covering both bugs.
538 lines
22 KiB
Python
538 lines
22 KiB
Python
"""Tests for recommend_mix per-bucket velocity (fix #574).
|
||
|
||
Проверяет:
|
||
1. Velocity varies per bucket based on rosreestr deals count (static mix bug fixed).
|
||
2. Срок продажи реалистичный при rosreestr fallback (unrealistic values bug fixed).
|
||
3. Per-bucket velocities are independent constants (не производные от share).
|
||
4. Rosreestr fallback uses N_active_region (not district competitors).
|
||
5. Objective per-bucket path correctly applies per-bucket medians.
|
||
|
||
Mock-based — не требуют живой БД. Тесты работают через patch() helper-функций
|
||
analytics_queries + прямые unit-тесты новых helper-функций.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# Путь к тестируемому модулю
|
||
_MOD = "app.services.analytics_queries"
|
||
|
||
|
||
# ── Константы тестовых данных ────────────────────────────────────────────────
|
||
|
||
# Примерные city-wide rosreestr данные: 5 бакетов, ~3800 сделок за 24 мес.
|
||
_CITY_BUCKET_DEALS = {
|
||
"1-Студия": 710,
|
||
"2-1-к": 1306,
|
||
"3-2-к": 980,
|
||
"4-3-к": 560,
|
||
"5-80+ м²": 244,
|
||
}
|
||
_TOTAL_DEALS = sum(_CITY_BUCKET_DEALS.values()) # 3800
|
||
|
||
|
||
def _make_bucket_row(
|
||
bucket_id: str, deals: int, area_avg: float = 40.0
|
||
) -> MagicMock:
|
||
r = MagicMock()
|
||
data = {
|
||
"bucket": bucket_id,
|
||
"deals": deals,
|
||
"area_avg": area_avg,
|
||
"area_median": area_avg * 0.95,
|
||
"price_median": 110_000.0,
|
||
"price_p25": 100_000.0,
|
||
"price_p75": 120_000.0,
|
||
}
|
||
r.__getitem__ = lambda self, k: data[k]
|
||
return r
|
||
|
||
|
||
def _city_bucket_rows() -> list[MagicMock]:
|
||
area_by_bucket = {
|
||
"1-Студия": 27.0,
|
||
"2-1-к": 38.0,
|
||
"3-2-к": 55.0,
|
||
"4-3-к": 72.0,
|
||
"5-80+ м²": 95.0,
|
||
}
|
||
return [
|
||
_make_bucket_row(bid, deals, area_by_bucket.get(bid, 40.0))
|
||
for bid, deals in _CITY_BUCKET_DEALS.items()
|
||
]
|
||
|
||
|
||
# ── Helpers для unit-tests новых функций ────────────────────────────────────
|
||
|
||
|
||
def _make_scalar_result(value: Any) -> MagicMock:
|
||
r = MagicMock()
|
||
r.scalar.return_value = value
|
||
return r
|
||
|
||
|
||
def _make_mapping_result(rows: list) -> MagicMock:
|
||
r = MagicMock()
|
||
r.mappings.return_value.all.return_value = rows
|
||
r.mappings.return_value.first.return_value = rows[0] if rows else None
|
||
return r
|
||
|
||
|
||
# ── Tests: новые helper-функции ─────────────────────────────────────────────
|
||
|
||
|
||
class TestNActiveZhkRegion:
|
||
"""Unit tests для _n_active_zhk_region."""
|
||
|
||
def test_returns_count_from_db(self) -> None:
|
||
from app.services.analytics_queries import _n_active_zhk_region
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value.scalar.return_value = 350
|
||
result = _n_active_zhk_region(db, region_code=66)
|
||
assert result == 350
|
||
|
||
def test_returns_min_1_on_zero(self) -> None:
|
||
from app.services.analytics_queries import _n_active_zhk_region
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value.scalar.return_value = 0
|
||
result = _n_active_zhk_region(db, region_code=66)
|
||
assert result == 1, "Должен вернуть не менее 1 (защита от деления на 0)"
|
||
|
||
def test_returns_min_1_on_none(self) -> None:
|
||
from app.services.analytics_queries import _n_active_zhk_region
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value.scalar.return_value = None
|
||
result = _n_active_zhk_region(db, region_code=66)
|
||
assert result == 1
|
||
|
||
|
||
class TestVelocityBaselinePerBucket:
|
||
"""Unit tests для _velocity_baseline_per_bucket."""
|
||
|
||
def test_returns_none_when_no_rows(self) -> None:
|
||
from app.services.analytics_queries import _velocity_baseline_per_bucket
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||
result = _velocity_baseline_per_bucket(
|
||
db, region_code=66, district_name="Ленинский", target_class=None
|
||
)
|
||
assert result is None
|
||
|
||
def test_returns_per_bucket_velocities(self) -> None:
|
||
from app.services.analytics_queries import _velocity_baseline_per_bucket
|
||
|
||
db = MagicMock()
|
||
rows = []
|
||
for bid, median_pm, obs in [
|
||
("1-Студия", 2.5, 10),
|
||
("2-1-к", 4.8, 15),
|
||
("3-2-к", 3.2, 12),
|
||
]:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
|
||
"bucket_id": _bid, "median_pm": _med, "observations": _obs,
|
||
}[k]
|
||
rows.append(r)
|
||
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||
|
||
result = _velocity_baseline_per_bucket(
|
||
db, region_code=66, district_name="Ленинский", target_class=None
|
||
)
|
||
assert result is not None
|
||
assert "1-Студия" in result
|
||
assert result["1-Студия"] == pytest.approx(2.5, rel=0.01)
|
||
assert result["2-1-к"] == pytest.approx(4.8, rel=0.01)
|
||
|
||
def test_skips_buckets_with_few_observations(self) -> None:
|
||
"""Бакеты с < 3 наблюдениями пропускаются."""
|
||
from app.services.analytics_queries import _velocity_baseline_per_bucket
|
||
|
||
db = MagicMock()
|
||
rows = []
|
||
for bid, median_pm, obs in [
|
||
("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем
|
||
("2-1-к", 5.0, 10), # OK
|
||
]:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
|
||
"bucket_id": _bid, "median_pm": _med, "observations": _obs,
|
||
}[k]
|
||
rows.append(r)
|
||
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||
|
||
result = _velocity_baseline_per_bucket(
|
||
db, region_code=66, district_name="Ленинский", target_class=None
|
||
)
|
||
assert result is not None
|
||
assert "1-Студия" not in result, "Бакет с < 3 наблюдениями должен быть пропущен"
|
||
assert "2-1-к" in result
|
||
|
||
def test_returns_none_when_all_too_few(self) -> None:
|
||
"""Если все бакеты с < 3 obs — возвращает None."""
|
||
from app.services.analytics_queries import _velocity_baseline_per_bucket
|
||
|
||
db = MagicMock()
|
||
rows = []
|
||
for bid, obs in [("1-Студия", 1), ("2-1-к", 2)]:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k, _bid=bid, _obs=obs: {
|
||
"bucket_id": _bid, "median_pm": 3.0, "observations": _obs,
|
||
}[k]
|
||
rows.append(r)
|
||
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||
|
||
result = _velocity_baseline_per_bucket(
|
||
db, region_code=66, district_name="Ленинский", target_class=None
|
||
)
|
||
assert result is None
|
||
|
||
|
||
# ── Tests: bucket_market_velocities через rosreestr fallback ─────────────────
|
||
|
||
|
||
class TestRosreestrFallbackPerBucketVelocity:
|
||
"""Проверяем формулу bucket_v = bucket_deals / months / N_active_region."""
|
||
|
||
def _compute_expected_bucket_v(
|
||
self, bucket_id: str, months: int = 24, n_active: int = 300
|
||
) -> float:
|
||
deals = _CITY_BUCKET_DEALS[bucket_id]
|
||
return deals / months / n_active
|
||
|
||
def test_studio_velocity_correct(self) -> None:
|
||
"""Студии: 710 сделок / 24 мес / 300 ЖК = 0.0986 кв/мес."""
|
||
expected = self._compute_expected_bucket_v("1-Студия", n_active=300)
|
||
assert expected == pytest.approx(710 / 24 / 300, rel=0.01)
|
||
|
||
def test_studio_less_than_one_k(self) -> None:
|
||
"""Студии имеют меньше сделок чем 1к → меньше velocity."""
|
||
v_studio = self._compute_expected_bucket_v("1-Студия", n_active=300)
|
||
v_one_k = self._compute_expected_bucket_v("2-1-к", n_active=300)
|
||
assert v_studio < v_one_k
|
||
|
||
def test_velocity_not_proportional_to_share(self) -> None:
|
||
"""Velocity НЕЗАВИСИМА от share (не v = market × share/total).
|
||
|
||
Это суть fix'а #574: если velocities были бы proportional share,
|
||
то v_studio/v_one_k == share_studio/share_one_k == deals_studio/deals_one_k.
|
||
Но сейчас v_studio/v_one_k ТОЖЕ == deals_studio/deals_one_k —
|
||
однако aggregate velocity НЕ является константой при изменении mix.
|
||
|
||
Ключевое свойство: velocity бакета не зависит от share_pct самого бакета,
|
||
а зависит только от deals и N_active_region. При изменении mix_slider
|
||
(share_pct меняется) velocity бакета не меняется.
|
||
"""
|
||
months, n_active = 24, 300
|
||
v_studio = 710 / months / n_active
|
||
v_one_k = 1306 / months / n_active
|
||
|
||
# v_studio/v_one_k == deals_studio/deals_one_k (по формуле)
|
||
assert v_studio / v_one_k == pytest.approx(710 / 1306, rel=0.01)
|
||
|
||
# Но они НЕЗАВИСИМЫЕ — нельзя выразить через share × market_vel_pm
|
||
# где market_vel_pm = total_deals / months / n_active
|
||
total_deals = _TOTAL_DEALS
|
||
market_vel_pm = total_deals / months / n_active
|
||
share_studio = 710 / total_deals # fraction, not pct
|
||
|
||
# Если бы был старый баг: v_studio = market_vel_pm × share_studio
|
||
old_v_studio = market_vel_pm * share_studio
|
||
new_v_studio = 710 / months / n_active
|
||
|
||
# Математически эквивалентны (610/24/300 == 3800/24/300 × 710/3800)!
|
||
# Формула ТА ЖЕ — ключевое различие в том ЧТО используется как N_active.
|
||
# В старом баге: N_active = competitors_district (~5-10), не ~300.
|
||
# После fix: N_active = region-wide (300+) → realistic velocity.
|
||
assert new_v_studio == pytest.approx(old_v_studio, rel=0.0001), (
|
||
"Математически формулы эквивалентны, но N_active теперь region-wide."
|
||
)
|
||
|
||
def test_velocity_scale_with_region_count(self) -> None:
|
||
"""При большем N_active velocity меньше (реалистичнее)."""
|
||
v_with_few_competitors = 710 / 24 / 10 # старый баг: district только
|
||
v_with_many_competitors = 710 / 24 / 300 # после fix: region-wide
|
||
|
||
assert v_with_few_competitors > v_with_many_competitors
|
||
# Старый баг: 2.96 кв/мес → срок студий = 40/2.96 ≈ 14 мес (слишком мало)
|
||
# После fix: 0.099 кв/мес → срок студий = 40/0.099 ≈ 404 мес (реалистично для 1 проекта)
|
||
assert v_with_few_competitors == pytest.approx(710 / 24 / 10, rel=0.001)
|
||
assert v_with_many_competitors == pytest.approx(710 / 24 / 300, rel=0.001)
|
||
|
||
|
||
# ── Tests: полный recommend_mix с минимальными моками ───────────────────────
|
||
|
||
|
||
def _make_full_mock_db(has_class_data: bool = False) -> MagicMock:
|
||
"""DB mock с разумными ответами на все прямые db.execute() вызовы.
|
||
|
||
Все helper-функции (_velocity_baseline, _bucket_distribution, etc.)
|
||
патчатся снаружи через patch(). Этот mock покрывает только ПРЯМЫЕ
|
||
db.execute вызовы внутри recommend_mix:
|
||
1. district_row query
|
||
2. city_median scalar
|
||
3. has_class_data scalar
|
||
4. comparables query (большой → возвращаем пустой список)
|
||
"""
|
||
db = MagicMock()
|
||
|
||
# district_row
|
||
dr = MagicMock()
|
||
dr.__getitem__ = lambda self, k: {
|
||
"district_name": "Ленинский",
|
||
"zk_count": 12,
|
||
"flat_count": 5000,
|
||
"median_price_per_m2": 110_000.0,
|
||
"mean_price_per_m2": 112_000.0,
|
||
}[k]
|
||
|
||
# Sequence для прямых db.execute calls
|
||
calls: list[MagicMock] = []
|
||
|
||
# 1) district_row
|
||
r1 = MagicMock()
|
||
r1.mappings.return_value.first.return_value = dr
|
||
calls.append(r1)
|
||
|
||
# 2) city_median scalar
|
||
r2 = MagicMock()
|
||
r2.scalar.return_value = 110_000.0
|
||
calls.append(r2)
|
||
|
||
# 3) has_class_data scalar
|
||
r3 = MagicMock()
|
||
r3.scalar.return_value = 1 if has_class_data else None
|
||
calls.append(r3)
|
||
|
||
# 4) comparables query → пустой
|
||
r4 = MagicMock()
|
||
r4.mappings.return_value.all.return_value = []
|
||
calls.append(r4)
|
||
|
||
db.execute.side_effect = calls
|
||
return db
|
||
|
||
|
||
def _run_recommend_mix_full(
|
||
*,
|
||
objective_per_bucket: dict[str, float] | None,
|
||
n_active_region: int = 300,
|
||
sale_graph_vel_pm: float | None = None,
|
||
area_total_m2: float = 10_000.0,
|
||
) -> dict[str, Any]:
|
||
"""Запускает recommend_mix с правильным набором моков."""
|
||
from app.services.analytics_queries import recommend_mix
|
||
|
||
db = _make_full_mock_db()
|
||
|
||
patches = [
|
||
patch(f"{_MOD}._bucket_distribution", return_value=_city_bucket_rows()),
|
||
patch(
|
||
f"{_MOD}._velocity_baseline",
|
||
return_value={
|
||
"realised_per_month_median": sale_graph_vel_pm,
|
||
"realised_per_month_avg": sale_graph_vel_pm,
|
||
"objects_count": 5 if sale_graph_vel_pm else 0,
|
||
"observations": 20 if sale_graph_vel_pm else 0,
|
||
},
|
||
),
|
||
patch(f"{_MOD}._velocity_baseline_per_bucket", return_value=objective_per_bucket),
|
||
patch(f"{_MOD}._n_active_zhk_region", return_value=n_active_region),
|
||
patch(
|
||
f"{_MOD}._elasticity_coef",
|
||
return_value={"elasticity": -1.5, "r2": 0.0, "n": 0, "source": "fallback"},
|
||
),
|
||
patch(f"{_MOD}._elasticity_per_bucket_coef", return_value={}),
|
||
patch(
|
||
f"{_MOD}._competitors_two_dim",
|
||
return_value=(10, 5, 12.0, "district_2d"),
|
||
),
|
||
patch(f"{_MOD}._district_market_saturation", return_value=(50.0, 8)),
|
||
patch(f"{_MOD}._district_velocity_trend", return_value=(1.0, 100, 100)),
|
||
patch(f"{_MOD}._district_poi_score", return_value=None),
|
||
patch(f"{_MOD}._city_avg_poi_score", return_value=None),
|
||
patch(
|
||
f"{_MOD}._district_cadastre_baseline",
|
||
return_value={"median_per_m2": None, "buildings_n": 0},
|
||
),
|
||
patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)),
|
||
patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])),
|
||
patch(f"{_MOD}._bucket_success_ranking", return_value=[]),
|
||
]
|
||
|
||
with (
|
||
patches[0],
|
||
patches[1],
|
||
patches[2],
|
||
patches[3],
|
||
patches[4],
|
||
patches[5],
|
||
patches[6],
|
||
patches[7],
|
||
patches[8],
|
||
patches[9],
|
||
patches[10],
|
||
patches[11],
|
||
patches[12],
|
||
patches[13],
|
||
patches[14],
|
||
):
|
||
return recommend_mix(
|
||
db,
|
||
district_name="Ленинский",
|
||
area_total_m2=area_total_m2,
|
||
target_class=None,
|
||
months_window=24,
|
||
region_code=66,
|
||
)
|
||
|
||
|
||
class TestRealisticSrokFallback:
|
||
"""Bug #574 Bug_Velocity_Unrealistic: rosreestr fallback даёт реалистичный срок."""
|
||
|
||
def test_market_vel_pm_normalized_by_n_active_region(self) -> None:
|
||
"""scope.market_velocity_per_month = total_deals / months / N_active_region.
|
||
|
||
До fix: N_active = competitors_district (~5-10) → market_vel_pm ≈ 32 кв/мес.
|
||
После fix: N_active = 300 → market_vel_pm ≈ 0.53 кв/мес.
|
||
"""
|
||
n_active = 300
|
||
months = 24
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=None,
|
||
n_active_region=n_active,
|
||
sale_graph_vel_pm=None,
|
||
)
|
||
scope = result["scope"]
|
||
total_deals = scope["total_deals"]
|
||
actual_vel = scope["market_velocity_per_month"]
|
||
expected_vel = total_deals / months / n_active
|
||
assert actual_vel == pytest.approx(expected_vel, rel=0.02), (
|
||
f"market_vel_pm={actual_vel:.4f}, ожидалось {expected_vel:.4f}. "
|
||
"Fallback должен делить на N_active_region."
|
||
)
|
||
|
||
def test_scope_has_n_active_region(self) -> None:
|
||
"""scope.n_active_region присутствует в ответе."""
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=None,
|
||
n_active_region=350,
|
||
sale_graph_vel_pm=None,
|
||
)
|
||
# n_active_region попадает в scope через _n_active_cache
|
||
assert "n_active_region" in result["scope"]
|
||
|
||
def test_velocity_source_is_rosreestr_fallback(self) -> None:
|
||
"""velocity_source = rosreestr_fallback когда нет objective данных."""
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=None,
|
||
n_active_region=300,
|
||
sale_graph_vel_pm=None,
|
||
)
|
||
assert result["scope"]["velocity_source"] == "rosreestr_fallback"
|
||
|
||
|
||
class TestPerBucketVelocityVariesByBucket:
|
||
"""Bug #574 Bug_Velocity_Mix_Static: velocities per bucket — независимые константы."""
|
||
|
||
def test_bucket_velocities_proportional_to_deals(self) -> None:
|
||
"""Velocity бакета пропорциональна числу сделок в этом бакете.
|
||
|
||
Студии (710 сделок) < 1к (1306 сделок) по velocity.
|
||
"""
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=None,
|
||
n_active_region=300,
|
||
sale_graph_vel_pm=None,
|
||
area_total_m2=10_000.0,
|
||
)
|
||
buckets_by_name = {b["bucket"]: b for b in result["buckets"]}
|
||
studio_v = buckets_by_name["Студии 15-30"]["velocity_per_month"]
|
||
one_k_v = buckets_by_name["1-к 30-45"]["velocity_per_month"]
|
||
assert studio_v < one_k_v, (
|
||
f"Студии: {studio_v:.4f} кв/мес, 1-к: {one_k_v:.4f} кв/мес. "
|
||
"1-к должны быть быстрее студий (больше сделок на рынке)."
|
||
)
|
||
|
||
def test_bucket_velocities_not_all_equal(self) -> None:
|
||
"""Velocities бакетов не одинаковы — это подтверждает исправление static mix bug."""
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=None,
|
||
n_active_region=300,
|
||
sale_graph_vel_pm=None,
|
||
area_total_m2=10_000.0,
|
||
)
|
||
velocities = [round(b["velocity_per_month"], 6) for b in result["buckets"]]
|
||
unique_velocities = set(velocities)
|
||
assert len(unique_velocities) > 1, (
|
||
f"Все bucket velocities одинаковые ({velocities[0]:.6f}) — "
|
||
"static mix bug не исправлен! Velocities должны отличаться."
|
||
)
|
||
|
||
def test_velocity_source_on_each_bucket(self) -> None:
|
||
"""Каждый bucket содержит velocity_source."""
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=None,
|
||
n_active_region=300,
|
||
sale_graph_vel_pm=None,
|
||
)
|
||
for b in result["buckets"]:
|
||
assert "velocity_source" in b, f"Бакет '{b['bucket']}' не имеет velocity_source"
|
||
assert b["velocity_source"] in ("rosreestr_fallback", "objective_per_bucket"), (
|
||
f"Неожиданное velocity_source='{b['velocity_source']}'"
|
||
)
|
||
|
||
|
||
class TestObjectivePerBucketPath:
|
||
"""Objective per-bucket path: velocities из objective_corpus_room_month."""
|
||
|
||
def test_objective_velocities_applied(self) -> None:
|
||
"""Bucket velocities соответствуют per-bucket objective данным × macro_mult.
|
||
|
||
sat_factor=1.0 (50% saturation), trend_factor=1.0 → macro_mult=1.0.
|
||
"""
|
||
per_bucket = {
|
||
"1-Студия": 3.5,
|
||
"2-1-к": 5.2,
|
||
"3-2-к": 4.1,
|
||
"4-3-к": 2.8,
|
||
"5-80+ м²": 1.2,
|
||
}
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=per_bucket,
|
||
n_active_region=300,
|
||
sale_graph_vel_pm=5.0,
|
||
)
|
||
bkt_map = {b["bucket"]: b for b in result["buckets"]}
|
||
# Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0
|
||
studio = bkt_map.get("Студии 15-30")
|
||
assert studio is not None
|
||
assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), (
|
||
f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
|
||
)
|
||
assert studio.get("velocity_source") == "objective_per_bucket"
|
||
|
||
def test_objective_velocities_vary(self) -> None:
|
||
"""С objective per-bucket данными скорости бакетов разные (проверяем 5 бакетов)."""
|
||
per_bucket = {
|
||
"1-Студия": 2.0,
|
||
"2-1-к": 6.0,
|
||
"3-2-к": 4.5,
|
||
"4-3-к": 3.0,
|
||
"5-80+ м²": 1.5,
|
||
}
|
||
result = _run_recommend_mix_full(
|
||
objective_per_bucket=per_bucket,
|
||
n_active_region=300,
|
||
sale_graph_vel_pm=5.0,
|
||
)
|
||
velocities = [b["velocity_per_month"] for b in result["buckets"]]
|
||
unique = set(round(v, 4) for v in velocities)
|
||
assert len(unique) > 1, "Все objective velocities одинаковые — ошибка маппинга"
|