gendesign/backend/tests/services/forecasting/test_orchestrator.py
Light1YT a0e61a38b4
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m25s
Deploy / deploy (push) Successful in 1m7s
feat(forecasting): §9.x→§22 orchestrator + fix supply-side district resolution (3a)
Add build_site_finder_report (orchestrator.py): computes the §9.x layers (market
metrics, supply layers, future-supply pressure, demand/supply forecast, scenarios,
score card, special indices, recommendation overlay) with their heterogeneous
signatures and feeds the PURE assemble_report → §22 SiteFinderReport. Default segment
= modal competitor class; each §9.x call _safe_call-wrapped (graceful). Standalone —
no endpoint/Celery/persistence (that is 3b).

Prod ground-truth of the orchestrator surfaced a false-BUY bug: future_supply
(compute_future_supply_pressure) read the mixed-vocab persisted view
v_supply_layers_latest by a SCALAR admin district_name, missing all Layer-1
micro-keyed rows → admin parcel (Кировский) got supply=0 → false +1.0 deficit →
'Строить: недонасыщен' headline despite ~45k competing units. Fix: resolve
admin→micros, filter district_name = ANY(CAST(:names AS text[])) where names =
micros (L1) + admin (L2/L3), with :has_district EKB-wide guard (extends PR #1054's
resolver to the persisted-view path it missed). future_supply is the only
v_supply_layers_latest consumer on the forecast path (verified).

Prod after: Кировский supply 0→~42953, deficit +1.0→−1.0 (honest oversupply),
MOI 0→116.6, false-BUY headline gone, overall 0.734→0.42. 80 module tests pass
(signature-trap + resolver-regression guards genuine); ruff clean. Refs #961 #969.
2026-06-05 08:21:04 +05:00

332 lines
18 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,
_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"] == "Комфорт"