gendesign/backend/tests/services/forecasting/test_what_to_build.py
bot-backend 489e380f1e
All checks were successful
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m9s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
feat(forecasting): what-to-build ranker + MAI proxy (#981, 952-B) (#1013)
2026-06-03 07:07:17 +00:00

472 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.7 ранкера «что строить» (#981, ADVISORY).
Чистые тесты — БЕЗ живой БД (мок #980 compute_demand_supply_forecast → ячейки с
известным deficit_index):
• pure _rank_key / _segment_key / _build_grid (декартова сетка, размер, порядок).
• rank_segments через MagicMock-сессию + @patch #980: сортировка по deficit_index
УБЫВАНИЕ; отбрасывание ячеек с deficit_index None (тонкие данные); tie-break
(равный индекс → выше confidence → стабильный segment-ключ); generated_advisory
ВСЕГДА True; graceful (вся сетка None → пустой ранкинг; сбой #980 на ячейке →
ячейка выпадает, не crash); n_cells_scanned/ranked корректны.
Детерминированно, без LLM. Мокаем #980 + db (нет живой БД).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from app.services.forecasting.what_to_build import (
_DEFAULT_CLASSES,
_DEFAULT_PRICE_BUCKETS,
_DEFAULT_ROOM_BUCKETS,
RankedSegment,
WhatToBuildRanking,
_build_grid,
_rank_key,
_round_or_none,
_segment_key,
rank_segments,
)
# Путь патча #980 (импортирован в модуль what_to_build).
_FORECAST = "app.services.forecasting.what_to_build.compute_demand_supply_forecast"
# ── pure: _segment_key ────────────────────────────────────────────────────────
class TestSegmentKey:
def test_fixed_axis_order(self) -> None:
seg = {
"obj_class": "комфорт",
"room_bucket": "2-к 45-60",
"district": "X",
"price_bucket": "бизнес",
}
assert _segment_key(seg) == "комфорт|2-к 45-60|X|бизнес"
def test_none_axes_become_empty(self) -> None:
seg = {"obj_class": "эконом", "room_bucket": None, "district": None, "price_bucket": None}
assert _segment_key(seg) == "эконом|||"
def test_deterministic(self) -> None:
seg = {"obj_class": "a", "room_bucket": "b", "district": "c", "price_bucket": "d"}
assert _segment_key(seg) == _segment_key(dict(seg))
# ── pure: _rank_key (сорт-ключ для DESC + tie-break) ──────────────────────────
def _cell(
*,
deficit_index: float,
confidence: str = "medium",
obj_class: str = "комфорт",
room_bucket: str = "2-к 45-60",
balance_units: float | None = None,
) -> RankedSegment:
return RankedSegment(
segment={
"obj_class": obj_class,
"room_bucket": room_bucket,
"district": "X",
"price_bucket": None,
},
deficit_index=deficit_index,
balance_units=balance_units,
confidence=confidence, # type: ignore[arg-type]
)
class TestRankKey:
def test_higher_deficit_sorts_first(self) -> None:
# sorted(key=_rank_key) БЕЗ reverse: больший deficit_index → меньший ключ → выше.
cells = [_cell(deficit_index=0.2), _cell(deficit_index=0.9), _cell(deficit_index=-0.5)]
ranked = sorted(cells, key=_rank_key)
assert [c.deficit_index for c in ranked] == [0.9, 0.2, -0.5]
def test_tie_break_by_confidence(self) -> None:
# Равный deficit_index → выше confidence идёт первой.
low = _cell(deficit_index=0.5, confidence="low", obj_class="a")
high = _cell(deficit_index=0.5, confidence="high", obj_class="b")
med = _cell(deficit_index=0.5, confidence="medium", obj_class="c")
ranked = sorted([low, high, med], key=_rank_key)
assert [c.confidence for c in ranked] == ["high", "medium", "low"]
def test_tie_break_stable_segment_key(self) -> None:
# Равный index И confidence → стабильный лексикографический segment-ключ.
b = _cell(deficit_index=0.5, confidence="medium", obj_class="бизнес")
a = _cell(deficit_index=0.5, confidence="medium", obj_class="комфорт")
# 'бизнес' < 'комфорт' лексикографически → b первой.
ranked = sorted([a, b], key=_rank_key)
assert ranked[0].segment["obj_class"] == "бизнес"
assert ranked[1].segment["obj_class"] == "комфорт"
def test_key_shape(self) -> None:
key = _rank_key(_cell(deficit_index=0.7, confidence="high"))
assert key[0] == pytest.approx(-0.7) # negate для DESC
assert key[1] == -2 # high rank=2, negate
assert isinstance(key[2], str)
# ── pure: _build_grid ─────────────────────────────────────────────────────────
class TestBuildGrid:
def test_cartesian_size(self) -> None:
grid = _build_grid(
district="X",
classes=["эконом", "комфорт"],
room_buckets=["студ", "1-к", "2-к"],
price_buckets=[None],
)
assert len(grid) == 2 * 3 * 1
def test_price_axis_multiplies(self) -> None:
grid = _build_grid(
district="X",
classes=["комфорт"],
room_buckets=["2-к"],
price_buckets=["эконом", "комфорт", "бизнес", "премиум"],
)
assert len(grid) == 4
def test_district_propagated(self) -> None:
grid = _build_grid(
district="Академический",
classes=["комфорт"],
room_buckets=["2-к"],
price_buckets=[None],
)
assert all(spec.district == "Академический" for spec in grid)
def test_axes_assigned(self) -> None:
grid = _build_grid(
district="X", classes=["бизнес"], room_buckets=["3-к"], price_buckets=["премиум"]
)
spec = grid[0]
assert spec.obj_class == "бизнес"
assert spec.room_bucket == "3-к"
assert spec.price_bucket == "премиум"
def test_deterministic_order(self) -> None:
# class-внешний, room-средний, price-внутренний.
grid = _build_grid(
district="X", classes=["a", "b"], room_buckets=["r1", "r2"], price_buckets=[None]
)
order = [(s.obj_class, s.room_bucket) for s in grid]
assert order == [("a", "r1"), ("a", "r2"), ("b", "r1"), ("b", "r2")]
def test_default_grid_cell_count(self) -> None:
# Дефолтная сетка: 3 класса × 5 room × 1 price = 15 ячеек.
grid = _build_grid(
district="X",
classes=_DEFAULT_CLASSES,
room_buckets=_DEFAULT_ROOM_BUCKETS,
price_buckets=_DEFAULT_PRICE_BUCKETS,
)
assert len(grid) == 15
# ── pure: _round_or_none ──────────────────────────────────────────────────────
class TestRoundOrNone:
def test_rounds(self) -> None:
assert _round_or_none(0.123456, 3) == 0.123
def test_none_passthrough(self) -> None:
assert _round_or_none(None, 3) is None
# ── dataclass as_dict ─────────────────────────────────────────────────────────
class TestAsDict:
def test_ranked_segment_as_dict(self) -> None:
d = _cell(deficit_index=0.789, balance_units=42.49).as_dict()
assert d["deficit_index"] == 0.789
assert d["balance_units"] == 42.5
assert d["confidence"] == "medium"
def test_ranking_as_dict(self) -> None:
ranking = WhatToBuildRanking(
district="X",
cad_num="66:41:1:1",
horizon_months=12,
ranked=[_cell(deficit_index=0.5)],
n_cells_scanned=15,
n_cells_ranked=1,
generated_advisory=True,
)
d = ranking.as_dict()
assert d["horizon_months"] == 12
assert d["n_cells_scanned"] == 15
assert d["n_cells_ranked"] == 1
assert d["generated_advisory"] is True
assert len(d["ranked"]) == 1
# ── orchestrator helpers (стаб #980) ──────────────────────────────────────────
def _forecast_stub(deficit_index: float | None, *, confidence: str = "medium") -> MagicMock:
"""Одиночный DemandSupplyForecast-стаб с заданным deficit_index/confidence."""
f = MagicMock()
f.deficit_index = deficit_index
f.balance_units = None if deficit_index is None else deficit_index * 100.0
f.confidence = confidence
return f
def _segment_indexed_side_effect(
index_by_room: dict[str, float | None],
*,
confidence_by_room: dict[str, str] | None = None,
) -> Any:
"""side_effect для #980: deficit_index/confidence зависят от room_bucket spec.
Позволяет задать разный сигнал по ячейкам сетки (по room_bucket), чтобы
проверить сортировку/отбрасывание. Возвращает [forecast] (список на 1 горизонт);
forecast.segment = spec.as_dict() (как делает настоящий #980).
"""
conf_map = confidence_by_room or {}
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
room = spec.room_bucket
deficit = index_by_room.get(room)
confidence = conf_map.get(room, "medium")
f = _forecast_stub(deficit, confidence=confidence)
f.segment = spec.as_dict()
return [f]
return _side
def _run(**over: object) -> WhatToBuildRanking:
kwargs: dict[str, object] = {
"district": "Академический",
"cad_num": "66:41:0303161:123",
"horizon_months": 12,
}
kwargs.update(over)
return rank_segments(MagicMock(), **kwargs) # type: ignore[arg-type]
# ── orchestrator: сортировка DESC по deficit_index ────────────────────────────
class TestRankingOrder:
def test_sorted_descending_by_deficit_index(self) -> None:
# Сетка из 3 room (1 класс, 1 price) с разным deficit_index → DESC-порядок.
side = _segment_indexed_side_effect(
{"студия": 0.1, "1-к": 0.9, "2-к": -0.4},
)
with patch(_FORECAST, side_effect=side):
res = _run(
classes=["комфорт"],
room_buckets=["студия", "1-к", "2-к"],
price_buckets=[None],
)
indices = [r.deficit_index for r in res.ranked]
assert indices == [0.9, 0.1, -0.4]
# Топ-ячейка = сильнейший build-сигнал (room_bucket '1-к').
assert res.ranked[0].segment["room_bucket"] == "1-к"
def test_balance_units_carried(self) -> None:
side = _segment_indexed_side_effect({"2-к": 0.5})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.ranked[0].balance_units == pytest.approx(50.0)
# ── orchestrator: отбрасывание None-ячеек (тонкие данные) ─────────────────────
class TestNoneDrop:
def test_none_deficit_cells_dropped(self) -> None:
# 2-к имеет deficit_index None (тонкие данные) → НЕ в ранкинге.
side = _segment_indexed_side_effect(
{"студия": 0.3, "1-к": None, "2-к": 0.7},
)
with patch(_FORECAST, side_effect=side):
res = _run(
classes=["комфорт"],
room_buckets=["студия", "1-к", "2-к"],
price_buckets=[None],
)
rooms = [r.segment["room_bucket"] for r in res.ranked]
assert "1-к" not in rooms # None-ячейка отброшена
assert rooms == ["2-к", "студия"] # DESC по 0.7, 0.3
assert res.n_cells_scanned == 3
assert res.n_cells_ranked == 2
def test_all_none_yields_empty_ranking(self) -> None:
# Вся сетка тонкая → пустой ранкинг (graceful, не crash).
side = _segment_indexed_side_effect(
{"студия": None, "1-к": None},
)
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["студия", "1-к"], price_buckets=[None])
assert res.ranked == []
assert res.n_cells_ranked == 0
assert res.n_cells_scanned == 2
assert res.generated_advisory is True
# ── orchestrator: tie-break ───────────────────────────────────────────────────
class TestTieBreak:
def test_equal_deficit_higher_confidence_first(self) -> None:
# Равный deficit_index по двум room → выше confidence первой.
side = _segment_indexed_side_effect(
{"студия": 0.5, "1-к": 0.5},
confidence_by_room={"студия": "low", "1-к": "medium"},
)
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["студия", "1-к"], price_buckets=[None])
assert res.ranked[0].segment["room_bucket"] == "1-к" # medium > low
assert res.ranked[1].segment["room_bucket"] == "студия"
def test_equal_deficit_and_confidence_stable_segment_key(self) -> None:
# Равные index+confidence по двум классам → стабильный segment-ключ (ASC).
side = _segment_indexed_side_effect({"2-к": 0.5}) # одинаковый index по обоим классам
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт", "бизнес"], room_buckets=["2-к"], price_buckets=[None])
# 'бизнес' < 'комфорт' лексикографически → первым.
assert res.ranked[0].segment["obj_class"] == "бизнес"
assert res.ranked[1].segment["obj_class"] == "комфорт"
# ── orchestrator: generated_advisory ВСЕГДА True ──────────────────────────────
class TestAdvisoryFlag:
def test_advisory_always_true(self) -> None:
side = _segment_indexed_side_effect({"2-к": 0.5})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.generated_advisory is True
def test_advisory_true_even_when_empty(self) -> None:
side = _segment_indexed_side_effect({"2-к": None})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.generated_advisory is True
def test_cells_inherit_capped_confidence(self) -> None:
# confidence ячейки = то, что вернул #980 (он сам ≤ medium); ранкер не поднимает.
side = _segment_indexed_side_effect({"2-к": 0.5}, confidence_by_room={"2-к": "medium"})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert res.ranked[0].confidence == "medium"
# ── orchestrator: graceful (сбой #980 на ячейке / пустая сетка) ───────────────
class TestGraceful:
def test_forecast_exception_on_cell_skips_it(self) -> None:
# #980 кидает на 1-к → ячейка выпадает, остальные ранжируются (не crash).
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
if spec.room_bucket == "1-к":
raise ValueError("boom")
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
res = _run(
classes=["комфорт"],
room_buckets=["студия", "1-к", "2-к"],
price_buckets=[None],
)
rooms = [r.segment["room_bucket"] for r in res.ranked]
assert "1-к" not in rooms
assert set(rooms) == {"студия", "2-к"}
assert res.n_cells_scanned == 3 # сетка прогнана целиком
assert res.n_cells_ranked == 2
def test_empty_forecast_list_skips_cell(self) -> None:
# #980 вернул [] для ячейки → ячейка выпадает.
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
if spec.room_bucket == "студия":
return []
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
res = _run(classes=["комфорт"], room_buckets=["студия", "2-к"], price_buckets=[None])
rooms = [r.segment["room_bucket"] for r in res.ranked]
assert rooms == ["2-к"]
def test_empty_grid_yields_empty_ranking(self) -> None:
# Пустые оси → пустая сетка → пустой ранкинг (не вызываем #980 вовсе).
with patch(_FORECAST) as fc:
res = _run(classes=[], room_buckets=[], price_buckets=[None])
fc.assert_not_called()
assert res.ranked == []
assert res.n_cells_scanned == 0
assert res.n_cells_ranked == 0
def test_returns_ranking_always(self) -> None:
side = _segment_indexed_side_effect({"2-к": 0.5})
with patch(_FORECAST, side_effect=side):
res = _run(classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert isinstance(res, WhatToBuildRanking)
assert all(isinstance(r, RankedSegment) for r in res.ranked)
# ── orchestrator: горизонт пробрасывается в #980 ──────────────────────────────
class TestHorizonAndRatePath:
def test_horizon_passed_to_forecast(self) -> None:
captured: dict[str, Any] = {}
def _side(db: Any, *, spec: Any, horizons: Any, **_: Any) -> list[MagicMock]:
captured["horizons"] = horizons
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
_run(horizon_months=24, classes=["комфорт"], room_buckets=["2-к"], price_buckets=[None])
assert captured["horizons"] == [24]
def test_rate_path_passed_to_forecast(self) -> None:
captured: dict[str, Any] = {}
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
captured["rate_path"] = rate_path
f = _forecast_stub(0.5)
f.segment = spec.as_dict()
return [f]
with patch(_FORECAST, side_effect=_side):
_run(
rate_path={12: 18.0},
classes=["комфорт"],
room_buckets=["2-к"],
price_buckets=[None],
)
assert captured["rate_path"] == {12: 18.0}
def test_default_grid_scans_fifteen_cells(self) -> None:
# Дефолтная сетка (без override осей) = 15 ячеек прогона #980.
side = _segment_indexed_side_effect(
{r: 0.5 for r in _DEFAULT_ROOM_BUCKETS},
)
with patch(_FORECAST, side_effect=side):
res = _run()
assert res.n_cells_scanned == 15