Когда demand_normalization honesty-gate отбивает β rate-sensitivity (`sensitivity.confidence=='low'`, ЕКБ-регрессия не проходит n≥30/R²≥0.1/slope<0), все 393 прод-отчёта получают coefficient=1.0 для всех трёх сценариев conservative/base/aggressive. По дизайну backend честно деградирует, но фронт рисует 3 разноцветные карточки/линии одинаковых чисел без caveat. Backend (scenarios.py, report.py, report_assembler.py, orchestrator.py): - compute_scenarios теперь возвращает (list, collapsed: bool, reason: str|None) - _detect_collapsed(): math.isclose(rel_tol=1e-9, abs_tol=1e-6) сравнивает projected_demand_units И deficit_index на всех горизонтах между conservative и aggressive — расхождение в любой метрике на любом h → False - _COLLAPSE_REASON_LOW_BETA — единственный источник истины каноничного русского предложения - ReportScenarios получает поля scenarios_collapsed + scenarios_collapse_reason - demand_normalization.py НЕ ТРОНУТ — поведение корректно по контракту Backend экспортёры (report_pdf.py, excel.py): - PDF: при collapse — тёмная плашка-параграф вместо таблицы трёх столбцов - Excel: ОДНА строка «base» + caveat-ячейка вместо трёх идентичных столбцов Frontend (forecast.ts, ScenariosBlock, ScenarioCards, ScenarioCompareTable, ForecastChart, Section6Forecast, ptica.module.css): - Тип ReportScenarios расширен двумя optional-полями - ScenariosBlock (light): headline-bar + одна grid-карточка base + caveat - ScenarioCards (ptica dark): sub-component ScenarioBaseCard, одна карточка + collapse-note, ScenarioCompareTable return null - ForecastChart: effectiveScenarios filter — одна линия viz-1 «Базовый» вместо трёх перекрывающихся - Section6Forecast: caveat выше графика, читается ТОЛЬКО из scenarios_collapse_reason (источник истины — backend constant) Тесты: +18 новых (TestMetricsEqual×6, TestDetectCollapsed×7, TestComputeScenariosCollapseDetection×4 + 1 проверка graceful + corner). 102 forecasting passed, 88 cross-module passed. Refs #1871
373 lines
20 KiB
Python
373 lines
20 KiB
Python
"""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"]),
|
||
# #1871 P1: compute_scenarios теперь возвращает кортеж
|
||
# `(scenarios, collapsed, collapse_reason)`. Sentinel: scenarios=["SC"], не схлопнуто.
|
||
"compute_scenarios": MagicMock(return_value=(["SC"], False, None)),
|
||
"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
|