"""Unit-тесты §9.x → §22 оркестратора (`build_site_finder_report`, step 3a, 955-A2). Оркестратор — ШОВ: считает §9.x-слои и раскладывает их в ЧИСТЫЙ §22-сборщик (`assemble_report`). Тесты пинят ТРИ контракта шва, БЕЗ живой БД (db = sentinel, §9.x-вызовы запатчены): • МОДАЛЬНЫЙ дефолт класса (audit Q1): competitors [Комфорт, Комфорт, Бизнес] → obj_class="Комфорт"; пустые competitors → None (ЕКБ-wide). Явный obj_class имеет приоритет над выводом. • ПРОВОДКА/СИГНАТУРЫ (ловушки audit): патчим §9.x на sentinel'ы, ассертим что `assemble_report` получил КАЖДЫЙ вход в правильном слоте, а §9.x вызваны с ПРАВИЛЬНОЙ ГЕТЕРОГЕННОЙ сигнатурой (forecasts/scenarios/special_indices ← `horizons=`; score_card/future_supply ← `horizon_months=`; overlay ← `target_class=`). Регрессия (не тот kwarg) → тест падает. • GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается (его слот None), исключение НЕ всплывает. Детерминированно, без сети, без LLM. DATABASE_URL до импорта app-модулей (зеркало соседних тестов — на случай side-effect'ов импорта пакета forecasting). """ from __future__ import annotations import contextlib import os from collections.abc import Iterator, Mapping from typing import Any from unittest.mock import MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.forecasting.orchestrator import ( _modal_obj_class, _normalize_district, _primary_horizon, _summarize_supply_layers, build_site_finder_report, ) from app.services.forecasting.report import SiteFinderReport # Полностью-квалифицированный путь к каждому §9.x-символу как он ИМПОРТИРОВАН в # orchestrator (патчим там, где используется, не там, где определён). _MOD = "app.services.forecasting.orchestrator" def _analyze(competitors: list[dict[str, Any]] | None = None) -> dict[str, Any]: """Минимальный analyze-dict (только то, что читает оркестратор/сборщик).""" return { "cad_num": "66:41:0702048:27", "district": {"district_name": "Ленинский"}, "competitors": competitors if competitors is not None else [], } # ────────────────────────────────────────────────────────────────────────────── # Модальный дефолт класса (audit Q1). # ────────────────────────────────────────────────────────────────────────────── class TestModalObjClass: def test_modal_picks_most_common(self) -> None: comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}] assert _modal_obj_class(comps) == "Комфорт" def test_empty_competitors_none(self) -> None: assert _modal_obj_class([]) is None def test_competitors_without_class_none(self) -> None: # есть конкуренты, но ни у одного нет класса → None (честно «не уточнён»). assert _modal_obj_class([{"obj_id": 1}, {"comm_name": "ЖК Альфа"}]) is None def test_legacy_class_key_supported(self) -> None: # legacy-ключ `class` (а не `obj_class`) тоже читается. assert _modal_obj_class([{"class": "Стандарт"}, {"class": "Стандарт"}]) == "Стандарт" def test_obj_class_preferred_over_legacy(self) -> None: comps = [{"obj_class": "Бизнес", "class": "Комфорт"}] assert _modal_obj_class(comps) == "Бизнес" def test_non_list_none(self) -> None: assert _modal_obj_class(None) is None assert _modal_obj_class("Комфорт") is None # type: ignore[arg-type] class TestPrimaryHorizon: def test_prefers_12_when_present(self) -> None: assert _primary_horizon([6, 12, 18, 24]) == 12 def test_falls_back_to_first_when_no_12(self) -> None: assert _primary_horizon([6, 18, 24]) == 6 def test_empty_defaults_to_12(self) -> None: assert _primary_horizon([]) == 12 # ────────────────────────────────────────────────────────────────────────────── # Хелпер сводки supply-слоёв (#950 → слот supply_layers сборщика). # ────────────────────────────────────────────────────────────────────────────── class TestSummarizeSupplyLayers: def test_none_or_empty_none(self) -> None: assert _summarize_supply_layers(None) is None assert _summarize_supply_layers([]) is None def test_aggregates_units_by_layer(self) -> None: rows = [ MagicMock(layer=1, units_estimate=1200, as_dict=lambda: {"layer": 1}), MagicMock(layer=2, units_estimate=800, as_dict=lambda: {"layer": 2}), MagicMock(layer=3, units_estimate=500, as_dict=lambda: {"layer": 3}), MagicMock(layer=1, units_estimate=None, as_dict=lambda: {"layer": 1}), # None skip ] summary = _summarize_supply_layers(rows) assert summary is not None assert summary["open_units"] == 1200 assert summary["hidden_units"] == 800 assert summary["future_units"] == 500 assert summary["n_rows"] == 4 # ────────────────────────────────────────────────────────────────────────────── # ПРОВОДКА + СИГНАТУРНЫЕ ловушки (audit): §9.x → sentinel, ассерт слот+kwarg. # ────────────────────────────────────────────────────────────────────────────── def _patched_layers() -> dict[str, Any]: """Контекст-менеджеры-патчи всех §9.x на распознаваемые sentinel-возвраты.""" # supply_layers возвращает СПИСОК строк (оркестратор свернёт в сводку-dict). supply_row = MagicMock(layer=1, units_estimate=10, as_dict=lambda: {"layer": 1}) return { "compute_market_metrics": MagicMock(return_value="MM"), "compute_all_layers": MagicMock(return_value=[supply_row]), "compute_future_supply_pressure": MagicMock(return_value="FSP"), "compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]), "compute_scenarios": MagicMock(return_value=["SC"]), "compute_score_card": MagicMock(return_value="PSC"), "compute_special_indices": MagicMock(return_value="SI"), "build_forecast_overlay": MagicMock(return_value="OVL"), "get_monthly_macro": MagicMock(return_value=[]), } @contextlib.contextmanager def _patch_layers(mocks: Mapping[str, Any], assemble: Any) -> Iterator[None]: """Запатчить ВСЕ §9.x-символы + assemble_report (по `mocks`-словарю). Компактно. Патчим там, где символы ИМПОРТИРОВАНЫ в orchestrator (`_MOD`), не там, где определены. """ with contextlib.ExitStack() as stack: for name, mock in mocks.items(): stack.enter_context(patch(f"{_MOD}.{name}", mock)) stack.enter_context(patch(f"{_MOD}.assemble_report", side_effect=assemble)) yield class TestWiringAndSignatures: """Патчим §9.x на sentinel'ы + перехватываем assemble_report → ассертим слоты+kwargs.""" def _run( self, *, obj_class: str | None = None, competitors: list[dict[str, Any]] | None = None ) -> tuple[SiteFinderReport, dict[str, Any], dict[str, Any]]: mocks = _patched_layers() captured: dict[str, Any] = {} def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport: captured["analyze"] = analyze captured.update(kwargs) return SiteFinderReport() with _patch_layers(mocks, _fake_assemble): report = build_site_finder_report( MagicMock(name="db"), analyze=_analyze(competitors), cad_num="66:41:0702048:27", district="Ленинский", obj_class=obj_class, horizons=[6, 12, 18], ) return report, captured, mocks def test_each_layer_lands_in_correct_slot(self) -> None: _report, captured, _mocks = self._run(obj_class="Комфорт") # assemble_report вызван (captured заполнен) — каждый sentinel в СВОЁМ слоте. assert captured["market_metrics"] == "MM" assert captured["future_supply"] == "FSP" assert captured["forecasts"] == ["F6", "F12"] assert captured["scenarios"] == ["SC"] assert captured["product_scores"] == "PSC" assert captured["special_indices"] == "SI" # overlay идёт именно в recommendation_overlay (НЕ product_tz/product_scores). assert captured["recommendation_overlay"] == "OVL" # supply_layers свёрнут в сводку-dict (не сырой список строк). assert isinstance(captured["supply_layers"], dict) assert captured["supply_layers"]["open_units"] == 10 # meta-проводка. assert captured["cad_num"] == "66:41:0702048:27" assert captured["district"] == "Ленинский" assert captured["horizons"] == [6, 12, 18] assert captured["advisory"] is True assert captured["segment"] == { "obj_class": "Комфорт", "room_bucket": None, "district": "Ленинский", "price_bucket": None, } def test_horizons_services_get_horizons_kwarg(self) -> None: """forecasts/scenarios/special_indices ДОЛЖНЫ получить `horizons=` (Sequence).""" _report, _captured, mocks = self._run(obj_class="Комфорт") horizons_services = ( "compute_demand_supply_forecast", "compute_scenarios", "compute_special_indices", ) for name in horizons_services: _args, kwargs = mocks[name].call_args assert kwargs.get("horizons") == [6, 12, 18], f"{name} must take horizons=" assert "horizon_months" not in kwargs, f"{name} must NOT take horizon_months" assert kwargs.get("cad_num") == "66:41:0702048:27" assert kwargs.get("district") == "Ленинский" # spec прокинут как SegmentSpec. assert kwargs["spec"].obj_class == "Комфорт" def test_horizon_months_services_get_int_kwarg(self) -> None: """score_card/future_supply ДОЛЖНЫ получить `horizon_months=` (int=primary=12).""" _report, _captured, mocks = self._run(obj_class="Комфорт") _args, sc_kwargs = mocks["compute_score_card"].call_args assert sc_kwargs.get("horizon_months") == 12 # 12 в сетке [6,12,18] → primary assert "horizons" not in sc_kwargs assert sc_kwargs["spec"].obj_class == "Комфорт" _args, fsp_kwargs = mocks["compute_future_supply_pressure"].call_args assert fsp_kwargs.get("horizon_months") == 12 assert "horizons" not in fsp_kwargs def test_overlay_gets_target_class_not_spec(self) -> None: """overlay ДОЛЖЕН получить `target_class=` (str) + `horizon_months=`, НЕ spec.""" _report, _captured, mocks = self._run(obj_class="Бизнес") _args, kwargs = mocks["build_forecast_overlay"].call_args assert kwargs.get("target_class") == "Бизнес" assert kwargs.get("horizon_months") == 12 assert "spec" not in kwargs assert "horizons" not in kwargs def test_market_metrics_and_supply_use_premise_kind(self) -> None: """§9.2/§9.3 ходят по premise_kind, без spec/horizons.""" _report, _captured, mocks = self._run(obj_class="Комфорт") _args, mm_kwargs = mocks["compute_market_metrics"].call_args assert mm_kwargs.get("premise_kind") == "квартира" assert mm_kwargs.get("district") == "Ленинский" _args, al_kwargs = mocks["compute_all_layers"].call_args assert al_kwargs.get("premise_kind") == "квартира" def test_modal_class_default_flows_into_spec_and_overlay(self) -> None: """obj_class=None + конкуренты [Комфорт×2, Бизнес] → модальный 'Комфорт' всюду.""" comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}] _report, captured, mocks = self._run(obj_class=None, competitors=comps) assert captured["segment"]["obj_class"] == "Комфорт" _args, ovl_kwargs = mocks["build_forecast_overlay"].call_args assert ovl_kwargs.get("target_class") == "Комфорт" _args, f_kwargs = mocks["compute_demand_supply_forecast"].call_args assert f_kwargs["spec"].obj_class == "Комфорт" def test_empty_competitors_default_class_none(self) -> None: """obj_class=None + пустые конкуренты → None (ЕКБ-wide all-class).""" _report, captured, mocks = self._run(obj_class=None, competitors=[]) assert captured["segment"]["obj_class"] is None _args, ovl_kwargs = mocks["build_forecast_overlay"].call_args assert ovl_kwargs.get("target_class") is None # ────────────────────────────────────────────────────────────────────────────── # GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается, слот None. # ────────────────────────────────────────────────────────────────────────────── class TestGracefulDegradation: def test_one_layer_raises_report_still_assembles(self) -> None: mocks = _patched_layers() # forecasts-слой бросает — отчёт ДОЛЖЕН собраться с forecasts=None. mocks["compute_demand_supply_forecast"] = MagicMock(side_effect=RuntimeError("boom")) captured: dict[str, Any] = {} def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport: captured.update(kwargs) return SiteFinderReport() with _patch_layers(mocks, _fake_assemble): # НЕ должно бросить, несмотря на падение слоя. report = build_site_finder_report( MagicMock(name="db"), analyze=_analyze([{"obj_class": "Комфорт"}]), cad_num="66:41:0702048:27", district="Ленинский", horizons=[6, 12, 18], ) assert isinstance(report, SiteFinderReport) # упавший слой → None в слоте; остальные сохранены. assert captured["forecasts"] is None assert captured["market_metrics"] == "MM" assert captured["scenarios"] == ["SC"] def test_all_layers_raise_report_still_assembles(self) -> None: """Даже если ВСЕ §9.x-слои бросают — отчёт собирается (все слоты None).""" boom = MagicMock(side_effect=RuntimeError("boom")) # Каждый §9.x-символ → бросающий mock (имена зеркалят _patched_layers). mocks = {name: boom for name in _patched_layers()} captured: dict[str, Any] = {} def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport: captured.update(kwargs) return SiteFinderReport() with _patch_layers(mocks, _fake_assemble): report = build_site_finder_report( MagicMock(name="db"), analyze=_analyze([{"obj_class": "Комфорт"}]), cad_num="66:41:0702048:27", district="Ленинский", ) assert isinstance(report, SiteFinderReport) for slot in ( "market_metrics", "supply_layers", "future_supply", "forecasts", "scenarios", "product_scores", "special_indices", "recommendation_overlay", ): assert captured[slot] is None, f"{slot} should degrade to None" # сегмент всё равно проброшен (он PURE, не от §9.x). assert captured["segment"]["obj_class"] == "Комфорт" # ────────────────────────────────────────────────────────────────────────────── # Defensive district normalization (#1130 follow-up — silent-degrade guard). # ────────────────────────────────────────────────────────────────────────────── class TestNormalizeDistrict: def test_str_passes_through(self) -> None: assert _normalize_district("Ленинский") == "Ленинский" def test_none_passes_through(self) -> None: assert _normalize_district(None) is None def test_dict_with_district_name_extracted(self) -> None: d = { "district_name": "Верх-Исетский", "dist_to_center": 0.0, "median_price_per_m2": 139908.25, } assert _normalize_district(d) == "Верх-Исетский" def test_dict_without_district_name_returns_none(self) -> None: # Защитный fallback: dict без ожидаемого поля → None + warning. # Не падаем (silent-degrade лучше чем TypeError в forecast_cache.wrapper). assert _normalize_district({"dist_to_center": 0.0}) is None def test_dict_with_empty_district_name_returns_none(self) -> None: assert _normalize_district({"district_name": ""}) is None def test_dict_with_non_string_district_name_returns_none(self) -> None: # «district_name»: 42 — не строка, защищаемся. assert _normalize_district({"district_name": 42}) is None def test_unsupported_type_returns_none(self) -> None: # list / int / прочие неподдерживаемые типы → None + warning. assert _normalize_district([1, 2, 3]) is None assert _normalize_district(42) is None