gendesign/backend/tests/services/forecasting/test_recommendation.py
Light1YT 574ee43577 feat(forecasting): recommendation overlay module + schema/route (#982, 953-A)
Recreate the recommendation.py bridge module + RecommendMixInput
horizon_months/cad_num fields + RecommendForecast{Segment,Overlay} models +
endpoint passthrough + test_recommendation.py — completing the #982 opt-in
whose analytics_queries.recommend_mix wire-in landed in the prior commit.

build_forecast_overlay(db,*,district,cad_num,horizon_months,target_class,
market_buckets) matches the committed call. demand_supply via rank_segments
(cad_num) / demand_only pace-proxy (no cad_num, no fabricated supply, warning).
Pure room/class bridges drift-guarded vs _BUCKET_PRETTY; local §9.x imports
avoid the analytics_queries↔forecasting cycle. 55 tests green.
2026-06-03 12:40:03 +05:00

481 lines
19 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.

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