"""Tests для §9.7 forecast-overlay моста (#982, 953-A) recommendation.py. Покрывает: • PURE bridge-таблицы map_room_bucket / map_room_bucket_inverse — обе стороны + unknown→None + DRIFT-GUARD (сверка _FORECAST_TO_LIVE_BUCKET с инверсом analytics_queries._BUCKET_PRETTY — таблицы НЕ должны разъехаться). • map_class — сворачивание классов + None/unknown→None. • build_forecast_overlay через @patch: - demand_supply (cad_num задан): rank_segments замокан → маппинг RankedSegment → segment-dict, advisory True, DESC-порядок сохранён, unknown room-bucket отброшен. - demand_only (cad_num=None): market_metrics/demand_norm/macro замоканы → mode flips, обязательный warning, balance_units None, supply НЕ фабрикуется. - graceful: rank_segments бросает ValueError → пустой ranked_segments + warning, НЕ исключение. Mock-based — живой БД не требуют (db = MagicMock; §9.x вызовы патчатся по месту ЛОКАЛЬНОГО импорта внутри функций recommendation.py). """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch import pytest from app.services.forecasting.recommendation import ( _FORECAST_TO_LIVE_BUCKET, build_forecast_overlay, map_class, map_room_bucket, map_room_bucket_inverse, ) from app.services.forecasting.what_to_build import RankedSegment, WhatToBuildRanking # Точки ЛОКАЛЬНОГО импорта внутри функций recommendation.py — патчим источник, # чтобы перехватить вызов независимо от того, где импорт исполнится. _RANK = "app.services.forecasting.what_to_build.rank_segments" _METRICS = "app.services.site_finder.market_metrics.compute_market_metrics" _NORM = "app.services.forecasting.demand_normalization.compute_demand_normalization" _MACRO = "app.services.forecasting.macro_coefficient.compute_macro_coefficient" _GET_MACRO = "app.services.forecasting.macro_series.get_monthly_macro" _HOLD = "app.services.forecasting.demand_supply_forecast.hold_last_rate" # ── PURE: map_room_bucket / map_room_bucket_inverse ─────────────────────────── class TestMapRoomBucket: def test_forward_all_five(self) -> None: assert map_room_bucket("Студии 15-30") == "1-Студия" assert map_room_bucket("1-к 30-45") == "2-1-к" assert map_room_bucket("2-к 45-60") == "3-2-к" assert map_room_bucket("3-к 60-80") == "4-3-к" assert map_room_bucket("80+ м²") == "5-80+ м²" def test_inverse_all_five(self) -> None: assert map_room_bucket_inverse("1-Студия") == "Студии 15-30" assert map_room_bucket_inverse("2-1-к") == "1-к 30-45" assert map_room_bucket_inverse("3-2-к") == "2-к 45-60" assert map_room_bucket_inverse("4-3-к") == "3-к 60-80" assert map_room_bucket_inverse("5-80+ м²") == "80+ м²" def test_round_trip_forward_then_inverse(self) -> None: for forecast in _FORECAST_TO_LIVE_BUCKET: assert map_room_bucket_inverse(map_room_bucket(forecast)) == forecast def test_unknown_forward_none(self) -> None: assert map_room_bucket("чердак") is None def test_unknown_inverse_none(self) -> None: assert map_room_bucket_inverse("9-чердак") is None def test_none_passthrough(self) -> None: assert map_room_bucket(None) is None assert map_room_bucket_inverse(None) is None class TestBucketTableDriftGuard: """_FORECAST_TO_LIVE_BUCKET ДОЛЖНА быть точным инверсом analytics_queries._BUCKET_PRETTY. Если кто-то поменяет один словарь и забудет другой (дублируем 5 литералов ради разрыва import-цикла) — этот тест падает первым. """ def test_is_exact_inverse_of_bucket_pretty(self) -> None: from app.services.analytics_queries import _BUCKET_PRETTY expected_inverse = {pretty: bid for bid, pretty in _BUCKET_PRETTY.items()} assert _FORECAST_TO_LIVE_BUCKET == expected_inverse def test_same_cardinality(self) -> None: from app.services.analytics_queries import _BUCKET_PRETTY assert len(_FORECAST_TO_LIVE_BUCKET) == len(_BUCKET_PRETTY) == 5 # ── PURE: map_class ─────────────────────────────────────────────────────────── class TestMapClass: @pytest.mark.parametrize( ("live", "expected"), [ ("Comfort", "комфорт"), ("Comfort+", "комфорт"), ("Business", "бизнес"), ("Elite", "бизнес"), ("Economy", "эконом"), ], ) def test_folding(self, live: str, expected: str) -> None: assert map_class(live) == expected def test_none_to_none(self) -> None: assert map_class(None) is None def test_unknown_to_none(self) -> None: assert map_class("Luxury++") is None # ── Helpers для мок-сегментов demand_supply ─────────────────────────────────── def _ranked( *, room_bucket: str | None, deficit_index: float, obj_class: str = "комфорт", balance_units: float | None = 12.0, confidence: str = "medium", ) -> RankedSegment: return RankedSegment( segment={ "obj_class": obj_class, "room_bucket": room_bucket, "district": "Ленинский", "price_bucket": None, }, deficit_index=deficit_index, balance_units=balance_units, confidence=confidence, # type: ignore[arg-type] ) def _ranking(ranked: list[RankedSegment]) -> WhatToBuildRanking: return WhatToBuildRanking( district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, ranked=ranked, n_cells_scanned=len(ranked), n_cells_ranked=len(ranked), generated_advisory=True, ) # ── build_forecast_overlay: demand_supply (cad_num задан) ────────────────────── class TestDemandSupplyOverlay: def test_mode_and_advisory(self) -> None: ranking = _ranking([_ranked(room_bucket="2-к 45-60", deficit_index=0.5)]) with patch(_RANK, return_value=ranking): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class="Comfort", ) assert out["mode"] == "demand_supply" assert out["advisory"] is True assert out["horizon_months"] == 12 def test_maps_segment_buckets_to_live(self) -> None: ranking = _ranking( [ _ranked(room_bucket="Студии 15-30", deficit_index=0.9), _ranked(room_bucket="2-к 45-60", deficit_index=0.3), ] ) with patch(_RANK, return_value=ranking): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class=None, ) buckets = [s["bucket"] for s in out["ranked_segments"]] assert buckets == ["1-Студия", "3-2-к"] def test_desc_order_preserved(self) -> None: # rank_segments уже отдаёт DESC; overlay не переупорядочивает. ranking = _ranking( [ _ranked(room_bucket="80+ м²", deficit_index=0.8), _ranked(room_bucket="1-к 30-45", deficit_index=0.2), _ranked(room_bucket="3-к 60-80", deficit_index=-0.4), ] ) with patch(_RANK, return_value=ranking): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class=None, ) indices = [s["deficit_index"] for s in out["ranked_segments"]] assert indices == [0.8, 0.2, -0.4] def test_fields_passed_through(self) -> None: ranking = _ranking( [ _ranked( room_bucket="2-к 45-60", deficit_index=0.55, obj_class="бизнес", balance_units=33.0, confidence="medium", ) ] ) with patch(_RANK, return_value=ranking): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=9, target_class="Business", ) seg = out["ranked_segments"][0] assert seg["obj_class"] == "бизнес" assert seg["deficit_index"] == 0.55 assert seg["balance_units"] == 33.0 assert seg["confidence"] == "medium" def test_unknown_room_bucket_dropped(self) -> None: ranking = _ranking( [ _ranked(room_bucket="2-к 45-60", deficit_index=0.5), _ranked(room_bucket="мансарда", deficit_index=0.9), # неизвестный → drop ] ) with patch(_RANK, return_value=ranking): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class=None, ) buckets = [s["bucket"] for s in out["ranked_segments"]] assert buckets == ["3-2-к"] def test_class_mapped_into_rank_kwargs(self) -> None: ranking = _ranking([_ranked(room_bucket="2-к 45-60", deficit_index=0.5)]) with patch(_RANK, return_value=ranking) as mock_rank: build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class="Elite", ) # Elite → бизнес, передаётся ранкеру как classes=["бизнес"]. assert mock_rank.call_args.kwargs["classes"] == ["бизнес"] def test_no_class_omits_classes_kwarg(self) -> None: # target_class=None → classes НЕ передаётся (движковый дефолт _DEFAULT_CLASSES). ranking = _ranking([_ranked(room_bucket="2-к 45-60", deficit_index=0.5)]) with patch(_RANK, return_value=ranking) as mock_rank: build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class=None, ) assert "classes" not in mock_rank.call_args.kwargs def test_empty_ranking_yields_warning(self) -> None: with patch(_RANK, return_value=_ranking([])): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class=None, ) assert out["ranked_segments"] == [] assert out["warnings"] # ── build_forecast_overlay: graceful (rank_segments бросает) ─────────────────── class TestDemandSupplyGraceful: def test_value_error_yields_empty_plus_warning_no_raise(self) -> None: with patch(_RANK, side_effect=ValueError("нет геометрии участка")): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num="66:41:0000000:1", horizon_months=12, target_class=None, ) assert out["mode"] == "demand_supply" assert out["advisory"] is True assert out["ranked_segments"] == [] assert out["warnings"] # ── build_forecast_overlay: demand_only (cad_num=None) ───────────────────────── def _mk_metrics(unit_velocity: float | None) -> MagicMock: m = MagicMock() m.unit_velocity = unit_velocity return m def _mk_coef(coefficient: float) -> MagicMock: c = MagicMock() c.coefficient = coefficient return c class TestDemandOnlyOverlay: def test_mode_flips_when_no_cad_num(self) -> None: with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, return_value=_mk_coef(1.0)), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) assert out["mode"] == "demand_only" assert out["advisory"] is True def test_mandatory_supply_warning_present(self) -> None: with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, return_value=_mk_coef(1.0)), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) assert any("supply/конкуренты НЕ учтены" in w for w in out["warnings"]) def test_balance_units_always_none_no_fabricated_supply(self) -> None: with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, return_value=_mk_coef(1.0)), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) assert out["ranked_segments"], "ожидали 5 ранжированных форматов" assert all(s["balance_units"] is None for s in out["ranked_segments"]) def test_confidence_low_for_all_segments(self) -> None: with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, return_value=_mk_coef(1.0)), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) assert all(s["confidence"] == "low" for s in out["ranked_segments"]) def test_deficit_index_is_pace_proxy_zero_to_one(self) -> None: # Разные §9.5-коэффициенты per вызов → разные pace → deficit_index = pace/max ∈ (0,1]. macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)]) with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) indices = [s["deficit_index"] for s in out["ranked_segments"]] assert max(indices) == pytest.approx(1.0) # топ нормирован к 1.0 assert all(0.0 < i <= 1.0 for i in indices) # DESC по pace. assert indices == sorted(indices, reverse=True) def test_no_velocity_yields_empty_plus_warning(self) -> None: # base_pace None (нет §9.2 темпа) → НЕ фабрикуем сигнал, пустой ранкинг + warning. with ( patch(_METRICS, return_value=_mk_metrics(None)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) assert out["mode"] == "demand_only" assert out["ranked_segments"] == [] assert out["warnings"] def test_five_default_room_buckets_ranked(self) -> None: with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, return_value=_mk_coef(1.0)), ): out = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class=None, ) buckets = {s["bucket"] for s in out["ranked_segments"]} assert buckets == {"1-Студия", "2-1-к", "3-2-к", "4-3-к", "5-80+ м²"} def test_overlay_validates_against_schema(self) -> None: # Sanity: demand_only-выход проходит RecommendForecastOverlay-валидацию. from app.schemas.recommend import RecommendForecastOverlay with ( patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_GET_MACRO, return_value=[]), patch(_HOLD, return_value={12: 18.0}), patch(_NORM, return_value=_mk_coef(1.0)), patch(_MACRO, return_value=_mk_coef(1.0)), ): out: dict[str, Any] = build_forecast_overlay( MagicMock(), district="Ленинский", cad_num=None, horizon_months=12, target_class="Comfort", ) model = RecommendForecastOverlay.model_validate(out) assert model.mode == "demand_only" assert model.advisory is True