gendesign/backend/tests/services/site_finder/test_future_supply.py
lekss361 a10592847d
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m20s
Deploy / build-worker (push) Successful in 2m23s
Deploy / deploy (push) Successful in 1m5s
feat(site_finder): future-supply-pressure index (#950 Step 6) (#1006)
2026-06-03 05:10:08 +00:00

530 lines
23 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-тесты 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