gendesign/backend/tests/services/forecasting/test_orchestrator.py
Light1YT dcccebf2c2
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 10m19s
fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)
Когда 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
2026-06-22 19:06:22 +05:00

373 lines
20 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.

"""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