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