"""Unit-тесты future-supply-pressure service (#950 EPIC 6 Step6, ТЗ §9.3). Чистые тесты (без живой БД): • pure-арифметика: _horizon_weight (вкл. NULL-дату → нейтраль, вне/в окне), _months_between, _months_of_pressure (guard div-by-zero → None), _saturating_index (монотонность + clamp [0,1] + named-saturation), _monthly_absorption_units (оба пути из market_metrics), _min_confidence (худшая тянет вниз, whitelisted). • compute_future_supply_pressure через MagicMock-сессию + mock compute_market_metrics: форма SQL (читает VIEW v_supply_layers_latest, НЕ базовую таблицу / recompute; CAST(:x AS type) не :x::type), агрегация по слоям, горизонт-взвешивание L3, graceful empty/thin → index None + confidence 'low', confidence=MIN. Детерминированно, без LLM. Мокаем compute_market_metrics + db (нет живой БД). """ from __future__ import annotations import os from datetime import date from unittest.mock import MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from app.services.site_finder.future_supply import ( _NULL_HORIZON_WEIGHT, _PRESSURE_SATURATION_MONTHS, FutureSupplyPressure, _horizon_weight, _min_confidence, _monthly_absorption_units, _months_between, _months_of_pressure, _round_or_none, _saturating_index, compute_future_supply_pressure, ) # Путь патча reused-сервиса (импортирован в модуль future_supply). _MARKET = "app.services.site_finder.future_supply.compute_market_metrics" # Разрешённый vocab confidence (зеркало CHECK м.125 / market_metrics). _ALLOWED_CONFIDENCE = {"high", "medium", "low"} # ── pure: _months_between ───────────────────────────────────────────────────── class TestMonthsBetween: def test_same_month_zero(self) -> None: assert _months_between(date(2026, 6, 1), date(2026, 6, 30)) == 0 def test_forward_months(self) -> None: assert _months_between(date(2026, 6, 1), date(2026, 12, 1)) == 6 def test_across_year(self) -> None: assert _months_between(date(2026, 6, 1), date(2027, 6, 1)) == 12 def test_past_is_negative(self) -> None: assert _months_between(date(2026, 6, 1), date(2026, 1, 1)) == -5 # ── pure: _horizon_weight (инверсия stage_at_horizon) ───────────────────────── class TestHorizonWeight: _TODAY = date(2026, 6, 1) def test_within_horizon_full_weight(self) -> None: # выход через 6 мес, горизонт 12 → в окне → полностью давит (1.0). assert _horizon_weight(date(2026, 12, 1), self._TODAY, 12) == 1.0 def test_exactly_at_horizon_edge_included(self) -> None: # ровно на границе горизонта (12 мес) → включаем (≤). assert _horizon_weight(date(2027, 6, 1), self._TODAY, 12) == 1.0 def test_beyond_horizon_zero_weight(self) -> None: # выход через 24 мес, горизонт 12 → вне окна → 0.0. assert _horizon_weight(date(2028, 6, 1), self._TODAY, 12) == 0.0 def test_already_online_full_weight(self) -> None: # дата в прошлом (уже онлайн) → давит полностью. assert _horizon_weight(date(2025, 1, 1), self._TODAY, 12) == 1.0 def test_null_date_neutral_weight(self) -> None: # тайминг неизвестен → нейтральный вес (не игнор, не полный счёт). assert _horizon_weight(None, self._TODAY, 12) == _NULL_HORIZON_WEIGHT assert _NULL_HORIZON_WEIGHT == 0.5 def test_shorter_horizon_excludes_more(self) -> None: # тот же объект (выход через 9 мес): горизонт 12 включает, горизонт 6 — нет. d = date(2027, 3, 1) # +9 мес assert _horizon_weight(d, self._TODAY, 12) == 1.0 assert _horizon_weight(d, self._TODAY, 6) == 0.0 # ── pure: _months_of_pressure ───────────────────────────────────────────────── class TestMonthsOfPressure: def test_basic(self) -> None: # (200 hidden + 100 future) / 50 ед/мес = 6 мес давления. assert _months_of_pressure(200, 100.0, 50.0) == pytest.approx(6.0) def test_zero_absorption_returns_none(self) -> None: # нет поглощения → давление неизмеримо (None, не ∞ и не 0). assert _months_of_pressure(200, 100.0, 0.0) is None def test_none_absorption_returns_none(self) -> None: assert _months_of_pressure(200, 100.0, None) is None def test_negative_absorption_returns_none(self) -> None: assert _months_of_pressure(200, 100.0, -5.0) is None def test_zero_queue_is_zero_not_none(self) -> None: # рынок измерим, очереди нет → 0.0 мес давления (валидно, НЕ None). assert _months_of_pressure(0, 0.0, 50.0) == 0.0 def test_none_layers_treated_as_zero(self) -> None: # отсутствие слоя ≠ нет данных о рынке: None-объёмы → 0 в сумме. assert _months_of_pressure(None, None, 50.0) == 0.0 assert _months_of_pressure(None, 100.0, 50.0) == pytest.approx(2.0) # ── pure: _saturating_index (нормировка [0,1]) ──────────────────────────────── class TestSaturatingIndex: def test_zero_pressure_zero_index(self) -> None: assert _saturating_index(0.0) == 0.0 def test_at_saturation_is_one(self) -> None: assert _saturating_index(_PRESSURE_SATURATION_MONTHS) == 1.0 def test_half_saturation_is_half(self) -> None: assert _saturating_index(_PRESSURE_SATURATION_MONTHS / 2.0) == pytest.approx(0.5) def test_beyond_saturation_clamps_to_one(self) -> None: # давление выше насыщения → clamp 1.0 (не >1). assert _saturating_index(_PRESSURE_SATURATION_MONTHS * 3.0) == 1.0 def test_negative_clamps_to_zero(self) -> None: # артефактно-отрицательное давление → clamp 0.0 (не negative). assert _saturating_index(-5.0) == 0.0 def test_none_passthrough(self) -> None: assert _saturating_index(None) is None def test_monotonic_non_decreasing(self) -> None: # ключевое свойство: больше месяцев давления → не меньший индекс, всё в [0,1]. prev = -1.0 for months in [0.0, 1.0, 3.0, 6.0, 9.0, 12.0, 18.0, 30.0, 100.0]: idx = _saturating_index(months) assert idx is not None assert 0.0 <= idx <= 1.0 assert idx >= prev prev = idx # ── pure: _monthly_absorption_units (вывод из market_metrics) ───────────────── class TestMonthlyAbsorptionUnits: def test_from_months_of_supply(self) -> None: # available 120, months_of_supply 6 → 20 ед/мес. assert _monthly_absorption_units(None, 6.0, 120) == pytest.approx(20.0) def test_prefers_months_of_supply_over_rate(self) -> None: # оба заданы → берём прямой путь available/mos (детерминированно). # available 120, mos 6 → 20 (а не absorption_rate*available). assert _monthly_absorption_units(0.5, 6.0, 120) == pytest.approx(20.0) def test_falls_back_to_absorption_rate(self) -> None: # mos нет → absorption_rate(0.1) * available(200) = 20 ед/мес. assert _monthly_absorption_units(0.1, None, 200) == pytest.approx(20.0) def test_no_available_returns_none(self) -> None: assert _monthly_absorption_units(0.1, 6.0, 0) is None assert _monthly_absorption_units(0.1, 6.0, None) is None def test_no_metrics_returns_none(self) -> None: # есть сток, но ни mos, ни rate → поглощение неизмеримо (None). assert _monthly_absorption_units(None, None, 120) is None def test_zero_metrics_returns_none(self) -> None: assert _monthly_absorption_units(0.0, 0.0, 120) is None # ── pure: _min_confidence ───────────────────────────────────────────────────── class TestMinConfidence: def test_all_high(self) -> None: assert _min_confidence(["high", "high"]) == "high" def test_low_drags_down(self) -> None: # тонкий L3 (low) тянет общий в low даже при high остальных. assert _min_confidence(["high", "medium", "low"]) == "low" def test_medium_floor(self) -> None: assert _min_confidence(["high", "medium"]) == "medium" def test_none_ignored(self) -> None: # None-компонент (нет сигнала) не учитывается — берём min из остальных. assert _min_confidence(["high", None, "medium"]) == "medium" def test_all_none_low(self) -> None: assert _min_confidence([None, None]) == "low" def test_empty_low(self) -> None: assert _min_confidence([]) == "low" def test_only_whitelisted(self) -> None: for vals in (["high"], ["medium"], ["low"], ["high", "low"], []): assert _min_confidence(vals) in _ALLOWED_CONFIDENCE # type: ignore[arg-type] # ── pure: _round_or_none ────────────────────────────────────────────────────── class TestRoundOrNone: def test_rounds(self) -> None: assert _round_or_none(1.23456, 3) == 1.235 def test_none_passthrough(self) -> None: assert _round_or_none(None, 3) is None # ── FutureSupplyPressure.as_dict / breakdown ────────────────────────────────── def _make_pressure(**over: object) -> FutureSupplyPressure: base: dict[str, object] = { "district": "Автовокзал", "horizon_months": 12, "premise_kind": "квартира", "confidence": "medium", "open_units": 300, "hidden_units": 200, "future_units_by_horizon": 100.0, "monthly_absorption_units": 50.0, "months_of_pressure": 6.0, "index": 0.3333, } base.update(over) return FutureSupplyPressure(**base) # type: ignore[arg-type] class TestAsDictBreakdown: def test_breakdown_has_all_components_rounded(self) -> None: d = _make_pressure().as_dict() assert d["index"] == 0.333 bd = d["breakdown"] assert bd["open_units"] == 300 assert bd["hidden_units"] == 200 assert bd["future_units_by_horizon"] == 100.0 assert bd["monthly_absorption_units"] == 50.0 assert bd["months_of_pressure"] == 6.0 assert bd["index"] == 0.333 def test_none_index_survives(self) -> None: d = _make_pressure(index=None, months_of_pressure=None).as_dict() assert d["index"] is None assert d["breakdown"]["months_of_pressure"] is None def test_confidence_whitelisted(self) -> None: assert _make_pressure().as_dict()["confidence"] in _ALLOWED_CONFIDENCE # ── MagicMock-сессия helpers (зеркало test_supply_layers) ───────────────────── def _mock_db(rows: list[dict]) -> MagicMock: """Сессия с одним execute → .mappings().all() == rows.""" db = MagicMock() result = MagicMock() result.mappings.return_value.all.return_value = rows db.execute.return_value = result return db def _executed_sql(db: MagicMock, call_index: int = 0) -> str: args, _kwargs = db.execute.call_args_list[call_index] return str(args[0]) def _executed_params(db: MagicMock, call_index: int = 0) -> dict: args, _kwargs = db.execute.call_args_list[call_index] return args[1] def _assert_no_double_colon_cast(sql: str) -> None: """psycopg v3: запрещён :name::type (SQLAlchemy его молча роняет).""" import re assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}" def _metrics_stub( *, absorption_rate: float | None = 0.1, months_of_supply: float | None = 6.0, n_available: int = 120, confidence: str = "high", ) -> MagicMock: """Заглушка результата compute_market_metrics (нужные поля для absorption).""" m = MagicMock() m.absorption_rate = absorption_rate m.months_of_supply = months_of_supply m.n_available = n_available m.confidence = confidence return m # ── compute_future_supply_pressure: SQL форма (читает VIEW) ──────────────────── class TestComputeReadsView: def test_reads_latest_view_not_base_table(self) -> None: # КРИТИЧНО (ТЗ §9.3 / м.125): читаем VIEW v_supply_layers_latest, которая # включает невыводимые L3 graddoc_stub(#956)/insider_manual. НЕ базовую # таблицу supply_layers напрямую и НЕ пересчёт compute_all_layers. db = _mock_db([]) with patch(_MARKET, return_value=_metrics_stub()): compute_future_supply_pressure(db, district="Автовокзал") sql = _executed_sql(db).lower() assert "v_supply_layers_latest" in sql # НЕ читаем источники recompute (objective_lots/domrf_kn_objects) — это была бы # пересборка слоёв, теряющая stub/insider строки. assert "objective_lots" not in sql assert "domrf_kn_objects" not in sql def test_sql_uses_cast_not_double_colon(self) -> None: db = _mock_db([]) with patch(_MARKET, return_value=_metrics_stub()): compute_future_supply_pressure(db, district="Автовокзал") sql = _executed_sql(db) assert "CAST(:district AS text)" in sql _assert_no_double_colon_cast(sql) def test_params_pass_district(self) -> None: db = _mock_db([]) with patch(_MARKET, return_value=_metrics_stub()) as mm: compute_future_supply_pressure( db, district="Академический", horizon_months=24, premise_kind="квартира" ) assert _executed_params(db)["district"] == "Академический" # district пробрасывается и в reused market_metrics (единый район). assert mm.call_args.kwargs["district"] == "Академический" # ── compute_future_supply_pressure: агрегация + композит ─────────────────────── class TestComputeAggregation: def test_aggregates_layers_and_weights_future(self) -> None: # L1=300 open, L2=200 hidden, L3 две строки: 100 в горизонте (вес 1.0) + # 80 за горизонтом (вес 0.0) + 40 с NULL-датой (вес 0.5) = 100+0+20 = 120. rows = [ {"layer": 1, "units_estimate": 300, "expected_online_date": None, "confidence": "high"}, { "layer": 2, "units_estimate": 200, "expected_online_date": None, "confidence": "medium", }, { "layer": 3, "units_estimate": 100, "expected_online_date": date(date.today().year + 1, 1, 1), "confidence": "low", }, { "layer": 3, "units_estimate": 80, "expected_online_date": date(date.today().year + 5, 1, 1), "confidence": "low", }, {"layer": 3, "units_estimate": 40, "expected_online_date": None, "confidence": "low"}, ] db = _mock_db(rows) # absorption: available 120, mos 6 → 20 ед/мес. with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="X", horizon_months=12) assert res.open_units == 300 assert res.hidden_units == 200 # 100*1.0 + 80*0.0 + 40*0.5 = 120.0 assert res.future_units_by_horizon == pytest.approx(120.0) assert res.monthly_absorption_units == pytest.approx(20.0) # (200 + 120) / 20 = 16 мес давления. assert res.months_of_pressure == pytest.approx(16.0) # index = 16 / 18 saturation. assert res.index == pytest.approx(16.0 / _PRESSURE_SATURATION_MONTHS) assert 0.0 <= res.index <= 1.0 # confidence = MIN(high L1, medium L2, low L3×3, high market) = low. assert res.confidence == "low" def test_open_units_excluded_from_pressure(self) -> None: # Только hidden+future формируют давление; open (L1) — лишь контекст. rows = [ { "layer": 1, "units_estimate": 9999, "expected_online_date": None, "confidence": "high", }, {"layer": 2, "units_estimate": 100, "expected_online_date": None, "confidence": "high"}, ] db = _mock_db(rows) with patch(_MARKET, return_value=_metrics_stub(months_of_supply=5.0, n_available=100)): res = compute_future_supply_pressure(db, district="X") # absorption 100/5=20; давление только от hidden 100 → 100/20=5 мес. assert res.monthly_absorption_units == pytest.approx(20.0) assert res.months_of_pressure == pytest.approx(5.0) assert res.open_units == 9999 # учтён в контексте, но не в давлении def test_confidence_is_min_of_components(self) -> None: # все supply high, но market_metrics low → итог low. rows = [ {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"}, ] db = _mock_db(rows) with patch(_MARKET, return_value=_metrics_stub(confidence="low")): res = compute_future_supply_pressure(db, district="X") assert res.confidence == "low" def test_all_high_yields_high(self) -> None: rows = [ {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"}, ] db = _mock_db(rows) with patch(_MARKET, return_value=_metrics_stub(confidence="high")): res = compute_future_supply_pressure(db, district="X") assert res.confidence == "high" assert res.confidence in _ALLOWED_CONFIDENCE # ── compute_future_supply_pressure: graceful (empty / thin / div-by-zero) ────── class TestComputeGraceful: def test_empty_supply_and_no_absorption_none_low(self) -> None: # Пустой склад (worker ещё не наполнил м.125 на prod) + нет поглощения → # index None, months_of_pressure None, confidence low. НЕ crash. db = _mock_db([]) with patch( _MARKET, return_value=_metrics_stub( absorption_rate=None, months_of_supply=None, n_available=0, confidence="low" ), ): res = compute_future_supply_pressure(db, district="Пусто") assert res.index is None assert res.months_of_pressure is None assert res.monthly_absorption_units is None assert res.confidence == "low" assert res.open_units == 0 assert res.hidden_units == 0 assert res.future_units_by_horizon == 0.0 def test_empty_supply_but_market_measurable_zero_pressure(self) -> None: # Склад пуст, НО рынок измерим → очереди нет → давление 0.0, index 0.0 # (валидное «нет будущего давления», НЕ None). db = _mock_db([]) with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="X") assert res.months_of_pressure == 0.0 assert res.index == 0.0 def test_supply_present_but_no_absorption_index_none(self) -> None: # Есть hidden/future, но рынок неизмерим (нет поглощения) → div-by-zero # guarded → months_of_pressure None → index None (НЕ crash, НЕ ∞). rows = [ { "layer": 2, "units_estimate": 500, "expected_online_date": None, "confidence": "medium", }, ] db = _mock_db(rows) with patch( _MARKET, return_value=_metrics_stub( absorption_rate=None, months_of_supply=None, n_available=0, confidence="medium" ), ): res = compute_future_supply_pressure(db, district="X") assert res.hidden_units == 500 assert res.monthly_absorption_units is None assert res.months_of_pressure is None assert res.index is None # confidence всё равно whitelisted (MIN medium supply + medium market). assert res.confidence in _ALLOWED_CONFIDENCE def test_view_query_exception_graceful(self) -> None: # Сбой чтения view → [] (graceful), index по absorption (нет очереди → 0.0). db = MagicMock() db.execute.side_effect = RuntimeError("view gone") with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="Сбой") assert res.hidden_units == 0 assert res.future_units_by_horizon == 0.0 assert res.months_of_pressure == 0.0 assert res.index == 0.0 def test_null_units_estimate_treated_as_zero(self) -> None: # L3-строка только с датой (units_estimate None — частый случай supply_layers) → # 0 в объёме, но строка не валит расчёт. rows = [ { "layer": 3, "units_estimate": None, "expected_online_date": date(date.today().year + 1, 1, 1), "confidence": "low", }, ] db = _mock_db(rows) with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="X") assert res.future_units_by_horizon == 0.0 assert res.months_of_pressure == 0.0 def test_returns_dataclass_always(self) -> None: db = _mock_db([]) with patch(_MARKET, return_value=_metrics_stub()): res = compute_future_supply_pressure(db, district=None) assert isinstance(res, FutureSupplyPressure) assert res.district is None