gendesign/backend/tests/services/site_finder/test_future_supply.py
Light1YT a0e61a38b4
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m25s
Deploy / deploy (push) Successful in 1m7s
feat(forecasting): §9.x→§22 orchestrator + fix supply-side district resolution (3a)
Add build_site_finder_report (orchestrator.py): computes the §9.x layers (market
metrics, supply layers, future-supply pressure, demand/supply forecast, scenarios,
score card, special indices, recommendation overlay) with their heterogeneous
signatures and feeds the PURE assemble_report → §22 SiteFinderReport. Default segment
= modal competitor class; each §9.x call _safe_call-wrapped (graceful). Standalone —
no endpoint/Celery/persistence (that is 3b).

Prod ground-truth of the orchestrator surfaced a false-BUY bug: future_supply
(compute_future_supply_pressure) read the mixed-vocab persisted view
v_supply_layers_latest by a SCALAR admin district_name, missing all Layer-1
micro-keyed rows → admin parcel (Кировский) got supply=0 → false +1.0 deficit →
'Строить: недонасыщен' headline despite ~45k competing units. Fix: resolve
admin→micros, filter district_name = ANY(CAST(:names AS text[])) where names =
micros (L1) + admin (L2/L3), with :has_district EKB-wide guard (extends PR #1054's
resolver to the persisted-view path it missed). future_supply is the only
v_supply_layers_latest consumer on the forecast path (verified).

Prod after: Кировский supply 0→~42953, deficit +1.0→−1.0 (honest oversupply),
MOI 0→116.6, false-BUY headline gone, overall 0.734→0.42. 80 module tests pass
(signature-trap + resolver-regression guards genuine); ruff clean. Refs #961 #969.
2026-06-05 08:21:04 +05:00

637 lines
30 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
+ mock resolve_objective_districts: форма SQL (читает VIEW v_supply_layers_latest,
НЕ базовую таблицу / recompute; district-фильтр через ANY(CAST(:names AS text[])),
НЕ скаляр :district::type), resolver-wiring (админ → микро + сам админ, дедуп;
None → EKB-wide без фильтра), агрегация по слоям, горизонт-взвешивание L3,
graceful empty/thin → index None + confidence 'low', confidence=MIN.
Детерминированно, без LLM. Мокаем compute_market_metrics + resolve_objective_districts
+ db (нет живой БД).
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from contextlib import contextmanager
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"
# Путь патча district-резолвера (импортирован в модуль future_supply). Резолвер бьёт
# живую БД (ekb_districts / ekb_district_alias) — в unit-тестах всегда мокаем.
_RESOLVE = "app.services.site_finder.future_supply.resolve_objective_districts"
def _resolve_passthrough(_db: object, district: str | None) -> list[str] | None:
"""Дефолтная заглушка резолвера: raw-микро pass-through (зеркало legacy-ветки).
district — raw-микро («X»/«Автовокзал» в большинстве тестов) → `[district]` (фильтр
по самому имени, как было до фикса); None → None (EKB-wide). Тесты, проверяющие
admin→micro разворот, передают свой side_effect через `_patched(resolve=…)`.
"""
return [district] if district is not None else None
# Разрешённый 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
@contextmanager
def _patched(
*, metrics: MagicMock | None = None, resolve: object = _resolve_passthrough
) -> Iterator[tuple[MagicMock, MagicMock]]:
"""Патчит compute_market_metrics + resolve_objective_districts разом.
`resolve`: либо callable (side_effect — резолвит вход в набор микро), либо значение
(return_value). По умолчанию pass-through `[district]` — legacy фильтр-по-микро, под
который написано большинство compute-тестов (district там — raw-микро «X»/«Автовокзал»).
"""
m = metrics if metrics is not None else _metrics_stub()
with patch(_MARKET, return_value=m) as mm, patch(_RESOLVE) as rs:
if callable(resolve):
rs.side_effect = resolve
else:
rs.return_value = resolve
yield mm, rs
# ── 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 _patched():
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_array_bind_not_double_colon(self) -> None:
# psycopg v3: district-фильтр через ANY(CAST(:names AS text[])) — массивный bind,
# НЕ :x::type (SQLAlchemy его молча роняет → syntax error).
db = _mock_db([])
with _patched():
compute_future_supply_pressure(db, district="Автовокзал")
sql = _executed_sql(db)
assert "ANY(CAST(:names AS text[]))" in sql
assert "CAST(:has_district AS boolean)" in sql
_assert_no_double_colon_cast(sql)
def test_district_not_a_bare_scalar_filter(self) -> None:
# Regression guard: МИКС-вокабулярный view нельзя фильтровать скаляром по одному
# имени (промахивается мимо L1 микро ИЛИ L2/3 админ строк → ложный дефицит, баг
# парцеля 66:41:0702048:27 / Кировский). Фильтр обязан идти по НАБОРУ имён.
db = _mock_db([])
with _patched():
compute_future_supply_pressure(db, district="Автовокзал")
sql = _executed_sql(db)
# ни один из старых скалярных вариантов фильтра по district не должен вернуться.
assert "district_name = CAST(:district AS text)" not in sql
assert "district_name = :district" not in sql
def test_params_pass_district_to_market_metrics(self) -> None:
# district (админ-имя) пробрасывается как есть в reused market_metrics — там свой
# резолвер развернёт его в микро (единый район для absorption).
db = _mock_db([])
with _patched() as (mm, _rs):
compute_future_supply_pressure(
db, district="Академический", horizon_months=24, premise_kind="квартира"
)
assert mm.call_args.kwargs["district"] == "Академический"
# ── compute_future_supply_pressure: resolver-wiring (admin→micro+admin) ────────
class TestComputeResolverWiring:
def test_admin_resolves_to_micros_plus_admin(self) -> None:
# Баг-репро: «Кировский» (АДМИН) → резолвер даёт микро [Втузгородок, ЖБИ]. Фильтр
# :names обязан быть микро + сам админ (для L2/3 admin-named строк) и дедупнут.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: ["Втузгородок", "ЖБИ"]) as (_mm, rs):
compute_future_supply_pressure(db, district="Кировский")
params = _executed_params(db)
assert params["names"] == ["Втузгородок", "ЖБИ", "Кировский"]
assert params["has_district"] is True
# резолвер вызван именно с входным district.
assert rs.call_args.args[1] == "Кировский"
def test_admin_micros_already_containing_admin_deduped(self) -> None:
# Если резолвер вернул набор, уже содержащий сам админ, — не дублируем имя.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: ["Кировский", "Втузгородок"]):
compute_future_supply_pressure(db, district="Кировский")
assert _executed_params(db)["names"] == ["Кировский", "Втузгородок"]
def test_raw_micro_input_filters_by_itself(self) -> None:
# raw-микро вход (legacy внутренний вызов): резолвер вернул [micro] → names=[micro]
# (сам админ == вход → дедуп оставляет одно имя). Legacy фильтр-по-микро сохранён.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: ["Втузгородок"]):
compute_future_supply_pressure(db, district="Втузгородок")
assert _executed_params(db)["names"] == ["Втузгородок"]
assert _executed_params(db)["has_district"] is True
def test_none_district_no_filter(self) -> None:
# district None → резолвер None → has_district False, names=[] (EKB-wide, без
# district-фильтра). Зеркало market_metrics EKB-wide ветки.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: None):
compute_future_supply_pressure(db, district=None)
params = _executed_params(db)
assert params["has_district"] is False
assert params["names"] == []
def test_admin_without_aliases_ekb_wide(self) -> None:
# Админ-имя, но резолвер вернул None (дыра в словаре) → EKB-wide fallback (НЕ
# пустой фильтр-«0 строк гарантированно»). has_district False.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: None):
compute_future_supply_pressure(db, district="Кировский")
params = _executed_params(db)
assert params["has_district"] is False
assert params["names"] == []
# ── 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 _patched(metrics=_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 _patched(metrics=_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 _patched(metrics=_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 _patched(metrics=_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 _patched(
metrics=_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 _patched(metrics=_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 _patched(
metrics=_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).
# Резолвер мокаем отдельно (иначе RuntimeError-side_effect db.execute пробил бы
# и резолвер) — изолируем именно сбой view-запроса.
db = MagicMock()
db.execute.side_effect = RuntimeError("view gone")
with _patched(metrics=_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 _patched(metrics=_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 _patched(resolve=lambda _db, _d: None):
res = compute_future_supply_pressure(db, district=None)
assert isinstance(res, FutureSupplyPressure)
assert res.district is None