"""Tests for recommend_mix per-bucket velocity (fix #574). Проверяет: 1. Velocity varies per bucket based on MARKET rosreestr deals (static mix bug fixed). 2. Срок продажи реалистичный (12-24 мес) при rosreestr fallback — нормировка по district+class competitors, НЕ region-wide ЖК. 3. Per-bucket velocities are independent constants (не производные от share_pct). 4. Rosreestr fallback uses district+class competitors_weighted (NOT ~442 region-wide). 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" # ── Константы тестовых данных ──────────────────────────────────────────────── # Примерные district+class 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 helper-функций ─────────────────────────────────── 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 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_comp (district+class competitors).""" def _compute_expected_bucket_v( self, bucket_id: str, months: int = 24, n_comp: int = 10 ) -> float: deals = _CITY_BUCKET_DEALS[bucket_id] return deals / months / n_comp def test_studio_velocity_correct(self) -> None: """Студии: 710 сделок / 24 мес / 10 ЖК = 2.96 кв/мес.""" expected = self._compute_expected_bucket_v("1-Студия", n_comp=10) assert expected == pytest.approx(710 / 24 / 10, rel=0.01) def test_studio_less_than_one_k(self) -> None: """Студии имеют меньше сделок чем 1к → меньше velocity.""" v_studio = self._compute_expected_bucket_v("1-Студия", n_comp=10) v_one_k = self._compute_expected_bucket_v("2-1-к", n_comp=10) assert v_studio < v_one_k def test_velocity_independent_of_share(self) -> None: """Velocity бакета НЕ зависит от share_pct пользователя (static-mix fix). Суть fix'а #574: per-bucket velocity = MARKET bucket_deals / months / n_comp. bucket_deals берётся из рыночного распределения (rosreestr), а НЕ из доли бакета в миксе пользователя. Поэтому сдвиг слайдера mix меняет units_planned бакета, но НЕ его velocity → aggregate срок реально меняется при изменении mix. """ months, n_comp = 24, 10 v_studio = 710 / months / n_comp v_one_k = 1306 / months / n_comp # v_studio/v_one_k == deals_studio/deals_one_k (market ratio, не user share) assert v_studio / v_one_k == pytest.approx(710 / 1306, rel=0.01) def test_velocity_scales_with_competitor_count(self) -> None: """При большем n_comp velocity одного проекта меньше (делим рынок на больше ЖК).""" v_few = 710 / 24 / 5 # 5 конкурентов v_many = 710 / 24 / 15 # 15 конкурентов assert v_few > v_many assert v_few == pytest.approx(710 / 24 / 5, rel=0.001) assert v_many == pytest.approx(710 / 24 / 15, 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_competitors: float = 10.0, sale_graph_vel_pm: float | None = None, area_total_m2: float = 12_000.0, horizon_months: int | None = None, cad_num: str | None = None, ) -> dict[str, Any]: """Запускает recommend_mix с правильным набором моков. n_competitors — district+class competitors_weighted, который возвращает _competitors_two_dim и используется как знаменатель в rosreestr fallback. horizon_months/cad_num — #982 forecast-overlay opt-in (по умолчанию None → живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты). """ 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}._elasticity_coef", return_value={"elasticity": -1.5, "r2": 0.0, "n": 0, "source": "fallback"}, ), patch(f"{_MOD}._elasticity_per_bucket_coef", return_value={}), # competitors_weighted = n_competitors → знаменатель rosreestr fallback patch( f"{_MOD}._competitors_two_dim", return_value=(int(n_competitors), 5, float(n_competitors), "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], ): return recommend_mix( db, district_name="Ленинский", area_total_m2=area_total_m2, target_class=None, months_window=24, region_code=66, horizon_months=horizon_months, cad_num=cad_num, ) class TestRealisticSrokFallback: """Bug #574 Bug_Velocity_Unrealistic: rosreestr fallback даёт реалистичный срок.""" def test_market_vel_pm_normalized_by_competitors(self) -> None: """scope.market_velocity_per_month = total_deals / months / n_comp. n_comp — district+class competitors_weighted (~5-15), НЕ region-wide ЖК (~442). """ n_comp = 10 months = 24 result = _run_recommend_mix_full( objective_per_bucket=None, n_competitors=n_comp, 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_comp assert actual_vel == pytest.approx(expected_vel, rel=0.02), ( f"market_vel_pm={actual_vel:.4f}, ожидалось {expected_vel:.4f}. " "Fallback должен делить на n_comp (district+class competitors)." ) def test_headline_srok_is_realistic_12_to_24_months(self) -> None: """Headline срок продажи лежит в реалистичном диапазоне 12-24 мес. Это ядро fix'а #574: делим market velocity на district+class competitors (5-15 ЖК), а НЕ на region-wide ~442 ЖК. Старый (region-wide) знаменатель давал срок 379-1180 мес — заведомо нереалистично для одного проекта. Setup: area_total=12000 м², n_comp=10, 3800 рыночных сделок за 24 мес. Ожидаемый срок ≈ 18 мес. """ result = _run_recommend_mix_full( objective_per_bucket=None, n_competitors=10, sale_graph_vel_pm=None, area_total_m2=12_000.0, ) srok = result["summary"]["months_to_sellout_total"] assert srok is not None, "months_to_sellout_total не должен быть None" assert 12 <= srok <= 24, ( f"Срок продажи {srok:.1f} мес вне реалистичного диапазона 12-24. " "При region-wide знаменателе (~442 ЖК) срок был бы ~379+ мес (баг #574)." ) def test_srok_realistic_across_competitor_range(self) -> None: """Срок остаётся в реалистичном диапазоне при n_comp 5-15.""" # n_comp=5 (мало конкурентов) → срок ниже; n_comp=15 → выше. # Подбираем area так чтобы оба укладывались в реалистичный коридор. for n_comp, area, lo, hi in [ (5, 24_000.0, 12, 24), (15, 8_000.0, 12, 24), ]: result = _run_recommend_mix_full( objective_per_bucket=None, n_competitors=n_comp, sale_graph_vel_pm=None, area_total_m2=area, ) srok = result["summary"]["months_to_sellout_total"] assert srok is not None assert lo <= srok <= hi, ( f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]" ) def test_scope_has_n_competitors(self) -> None: """scope.n_competitors присутствует и равен district+class competitors.""" result = _run_recommend_mix_full( objective_per_bucket=None, n_competitors=12, sale_graph_vel_pm=None, ) assert "n_competitors" in result["scope"] assert result["scope"]["n_competitors"] == 12 def test_velocity_source_is_rosreestr_fallback(self) -> None: """velocity_source = rosreestr_fallback когда нет objective данных.""" result = _run_recommend_mix_full( objective_per_bucket=None, n_competitors=10, 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_market_deals(self) -> None: """Velocity бакета пропорциональна числу РЫНОЧНЫХ сделок в этом бакете. Студии (710 сделок) < 1к (1306 сделок) по velocity. """ result = _run_recommend_mix_full( objective_per_bucket=None, n_competitors=10, sale_graph_vel_pm=None, area_total_m2=12_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_competitors=10, sale_graph_vel_pm=None, area_total_m2=12_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_competitors=10, 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_competitors=10, 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_competitors=10, 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 одинаковые — ошибка маппинга" _FORECAST_PER_BUCKET = { "1-Студия": 2.0, "2-1-к": 6.0, "3-2-к": 4.5, "4-3-к": 3.0, "5-80+ м²": 1.5, } class TestForecastOverlayOptIn: """#982 (953-A): forecast-overlay — ADDITIVE OPT-IN + live-endpoint safety.""" def test_invariance_no_horizon_no_forecast_key(self) -> None: """horizon_months=None → НЕТ scope['forecast'] (живой микс не тронут).""" result = _run_recommend_mix_full( objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0, ) assert "forecast" not in result["scope"] def test_invariance_top_level_keys_unchanged(self) -> None: """Топ-уровневые ключи ответа неизменны (4 поля контракта) без horizon.""" result = _run_recommend_mix_full( objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0, ) assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"} def test_invariance_byte_identical_scope_when_off(self) -> None: """scope БАЙТ-в-БАЙТ совпадает с/без передачи horizon_months=None.""" base = _run_recommend_mix_full( objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0 ) again = _run_recommend_mix_full( objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0, horizon_months=None, ) assert base["scope"] == again["scope"] assert base["buckets"] == again["buckets"] assert base["summary"] == again["summary"] def test_opt_in_attaches_overlay_under_scope(self) -> None: """horizon_months задан → scope['forecast'] = результат build_forecast_overlay.""" sentinel = {"horizon_months": 12, "mode": "demand_only", "advisory": True} with patch( "app.services.forecasting.recommendation.build_forecast_overlay", return_value=sentinel, ) as mock_overlay: result = _run_recommend_mix_full( objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0, horizon_months=12, ) assert result["scope"]["forecast"] is sentinel assert mock_overlay.call_count == 1 # district проброшен (resolved district_name), horizon/cad — из payload. assert mock_overlay.call_args.kwargs["horizon_months"] == 12 assert mock_overlay.call_args.kwargs["cad_num"] is None def test_crash_isolation_overlay_failure_does_not_break_live_mix(self) -> None: """build_forecast_overlay бросает → живой микс ВСЁ РАВНО возвращается.""" with patch( "app.services.forecasting.recommendation.build_forecast_overlay", side_effect=RuntimeError("boom"), ): result = _run_recommend_mix_full( objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0, horizon_months=12, ) # Микс жив; overlay = {error, advisory}, остальные поля на месте. assert result["buckets"] assert result["scope"]["forecast"]["advisory"] is True assert "boom" in result["scope"]["forecast"]["error"] assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}