472 lines
20 KiB
Python
472 lines
20 KiB
Python
"""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
|