gendesign/backend/tests/services/test_recommend_mix_velocity.py
Light1YT a184d38aa6
Some checks failed
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 1m0s
CI / openapi-codegen-check (pull_request) Failing after 1m51s
CI / backend-tests (pull_request) Successful in 13m47s
fix(analytics): recommend_mix success-boost regression from view 172 (#1955)
Миграция 172 убрала english 'Comfort' из v_bucket_success_score (NULL-class
→ 'не указан'), но _bucket_success_ranking в analytics_queries.py матчил
COALESCE(:cls, 'Comfort') — после 172 ни одна строка не равна 'Comfort' →
recommend_mix success-boost (#25, /analytics/recommend квартирография) тихо
возвращал [] (silent degradation). LIVE на проде с момента ручного применения
172 (graceful empty, без краша).

Fix:
- call site (recommend_mix): передаём target_class_db (уже переведённый через
  _class_to_db_vocab english→русский), а не сырой english target_class.
  Чинит и латентный pre-existing баг: Business/Elite никогда не матчили
  русский источник.
- SQL default 'Comfort' → 'Комфорт' (массовый класс ЕКБ, 723 объекта).
  NULL-class ('не указан') в default-путь намеренно не попадают
  (документировано в docstring).

Prod-verified: OLD 'Comfort' default → 0 строк; NEW 'Комфорт' default → 5.

Tests:
- _bucket_success_ranking: реальный запрос (не замокан) с русским default —
  SQL содержит 'Комфорт', не 'Comfort'; ranking непустой.
- recommend_mix: english 'Business' переводится в 'Бизнес' перед ranking
  (раньше тест патчил _bucket_success_ranking→[] и баг не ловил;
  helper теперь умеет patch_success_ranking=False).
- #1960: позитивный тест выбора quarter_rosreestr basis (deals≥5, все
  higher fallback'и None) → median_price_basis='quarter_rosreestr'.
2026-06-27 15:49:40 +05:00

722 lines
31 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 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)"