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