"""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 import contextlib 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] # Сигнатурный диспетчер вместо позиционной последовательности — устойчив к # доп. запросам class-пути (#1955: class_multiplier yandex_realty_zk # выполняется ТОЛЬКО когда target_class задан → лишний db.execute, который # ломал бы жёсткую нумерацию). def _dispatch(stmt: Any, params: Any = None) -> MagicMock: sql = " ".join(str(stmt).split()) r = MagicMock() # district_row if "FROM ekb_districts" in sql and "district_name ILIKE" in sql: r.mappings.return_value.first.return_value = dr # has_class_data elif "FROM domrf_kn_objects" in sql and "obj_class_fallback" in sql: r.scalar.return_value = 1 if has_class_data else None # class_multiplier (yandex_realty_zk) — только class-путь elif "comfort_avg" in sql or "yandex_realty_zk" in sql: cr = MagicMock() cr.__getitem__ = lambda self, k: {"class_avg": None, "comfort_avg": None}[k] r.mappings.return_value.first.return_value = cr # comparables → пусто elif "latest_agg" in sql or "domrf_kn_sales_agg" in sql: r.mappings.return_value.all.return_value = [] # city_median (любой оставшийся scalar) else: r.scalar.return_value = 110_000.0 r.mappings.return_value.all.return_value = [] r.mappings.return_value.first.return_value = None return r db.execute.side_effect = _dispatch 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, target_class: str | None = None, patch_success_ranking: bool = True, ) -> 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 → живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты). target_class — #1955: проверка перевода english→русский перед success-ranking. has_class_data=True когда target_class задан, чтобы class-путь вернул строки. patch_success_ranking — если False, НЕ патчим _bucket_success_ranking (даём реальной функции/внешнему spy отработать; #1955 translation-тест). """ from app.services.analytics_queries import recommend_mix db = _make_full_mock_db(has_class_data=target_class is not None) 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}._recommend_data_last_updated", return_value=None), ] if patch_success_ranking: patches.append(patch(f"{_MOD}._bucket_success_ranking", return_value=[])) with contextlib.ExitStack() as stack: for p in patches: stack.enter_context(p) return recommend_mix( db, district_name="Ленинский", area_total_m2=area_total_m2, target_class=target_class, 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"} # ── #1955: _bucket_success_ranking использует русский default (НЕ english 'Comfort') def _make_ranking_row(bucket: str, score: float, n: int) -> MagicMock: r = MagicMock() data = { "bucket": bucket, "success_score": score, "n_deals": n, "velocity_z": 0.1, "price_z": 0.2, "area_z": -0.1, } r.__getitem__ = lambda self, k: data[k] return r class TestBucketSuccessRankingDefault: """#1955: после миграции 172 view хранит только русские классы — default в _bucket_success_ranking ДОЛЖЕН быть 'Комфорт', не english 'Comfort' (иначе obj_class = 'Comfort' матчит 0 строк → success-boost тихо пустеет).""" def test_default_class_is_russian_komfort_in_sql(self) -> None: """SQL использует COALESCE(:cls, 'Комфорт'), НЕ 'Comfort'.""" from app.services.analytics_queries import _bucket_success_ranking captured: dict[str, Any] = {} def _exec(stmt: Any, params: Any) -> MagicMock: captured["sql"] = " ".join(str(stmt).split()) captured["params"] = params # Реальный запрос отрабатывает и возвращает строки (как было бы с # русским default-классом в обновлённом view). return _make_mapping_result([_make_ranking_row("1-к 30-45", 1.2, 1029)]) db = MagicMock() db.execute.side_effect = _exec # target_class=None → должен сработать default-путь. result = _bucket_success_ranking(db, "Железнодорожный", None) assert "'Комфорт'" in captured["sql"], captured["sql"] assert "'Comfort'" not in captured["sql"], "english 'Comfort' default — баг #1955" # Реальный запрос (не замокан в []) вернул непустой ranking. assert result, "success_ranking пуст — default-класс не матчит view (#1955)" assert result[0]["bucket"] == "1-к 30-45" assert result[0]["n_deals"] == 1029 def test_explicit_russian_class_passed_through(self) -> None: """Явный русский класс уходит в bind как есть (caller уже перевёл).""" from app.services.analytics_queries import _bucket_success_ranking captured: dict[str, Any] = {} def _exec(stmt: Any, params: Any) -> MagicMock: captured["params"] = params return _make_mapping_result([_make_ranking_row("Студии 15-30", 0.9, 4857)]) db = MagicMock() db.execute.side_effect = _exec result = _bucket_success_ranking(db, "Академический", "Комфорт") assert captured["params"]["cls"] == "Комфорт" assert result and result[0]["n_deals"] == 4857 class TestRecommendMixTranslatesClassForSuccessRanking: """#1955: recommend_mix ДОЛЖЕН передавать переведённый русский класс (target_class_db) в _bucket_success_ranking, а не сырой english target_class из UI ('Comfort' != 'Комфорт' → 0 строк).""" def test_english_ui_class_translated_before_ranking_query(self) -> None: from app.services.analytics_queries import _class_to_db_vocab captured: dict[str, Any] = {} def _spy_ranking(db: Any, dn: str | None, cls: str | None) -> list[dict]: captured["cls"] = cls return [] with patch(f"{_MOD}._bucket_success_ranking", side_effect=_spy_ranking): # UI шлёт english 'Business' → recommend_mix должен передать русский 'Бизнес'. # patch_success_ranking=False → helper НЕ перепатчивает наш spy на []. _run_recommend_mix_full( objective_per_bucket=None, target_class="Business", patch_success_ranking=False, ) assert captured["cls"] == _class_to_db_vocab("Business") == "Бизнес" assert captured["cls"] != "Business", "сырой english класс утёк в ranking-запрос (#1955)"