"""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 одинаковые — ошибка маппинга"