All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 15m25s
Convert two bare db.rollback() calls on the shared per-request session (best_layouts _fetch_supply_only_layouts + Step 2b core-attribution) to db.begin_nested() — a bare rollback there discards ALL uncommitted work of the request and orphans the outer SessionTransaction. Also wrap the swallow sites in market_metrics (_query_stock, _query_sales_window, _price_sensitivity, compute_offer_price_trend), best_layouts step-5 supply, future_supply _query_supply_rows, own_portfolio _query_current/_query_future, and custom_pois get_overlaps_for_scoring so a failed statement no longer poisons the shared tx for later steps. Per-site regression tests assert fallback preserved, begin_nested used, bare rollback NOT called, and session usable after failure. Refs #2464 (cluster A session-poisoning, Wave 2). EOF2 echo "HANDLED"
696 lines
33 KiB
Python
696 lines
33 KiB
Python
"""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,
|
||
_query_supply_rows,
|
||
_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
|
||
|
||
|
||
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
|
||
#
|
||
# db shared с caller (compute_future_supply_pressure в цепочке §22-форсайта /
|
||
# analyze). Раньше _query_supply_rows ловил db.execute-сбой в bare
|
||
# ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляет
|
||
# db shared с caller: следующий db.execute на этой же сессии (напр.
|
||
# compute_market_metrics, вызываемый следом в compute_future_supply_pressure)
|
||
# падает "current transaction is aborted". Фикс — db.execute обёрнут в
|
||
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
|
||
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
|
||
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
|
||
# на той же mock-сессии.
|
||
|
||
|
||
class TestQuerySupplyRowsSavepoint:
|
||
def test_uses_begin_nested_savepoint(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||
_query_supply_rows(db, {"district": "X", "names": ["X"]})
|
||
assert db.begin_nested.call_count == 1
|
||
|
||
def test_query_failure_degrades_to_empty_list(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
|
||
|
||
def test_session_usable_after_failure(self) -> None:
|
||
"""db.execute сбоит один раз (supply-layers view), но db остаётся usable
|
||
для следующего вызова на этой же сессии (имитирует
|
||
_query_supply_rows → compute_market_metrics в compute_future_supply_pressure)."""
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("boom")
|
||
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
|
||
|
||
db.execute.side_effect = None
|
||
next_result = MagicMock()
|
||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||
db.execute.return_value = next_result
|
||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||
|
||
|
||
class TestComputeFutureSupplyPressureSessionUsableAfterFailure:
|
||
"""End-to-end: view-запрос сбоит → SAVEPOINT (не bare rollback) → reused
|
||
compute_market_metrics (отдельный db.execute на той же сессии) не задет."""
|
||
|
||
def test_view_failure_does_not_poison_market_metrics_call(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("view gone")
|
||
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)) as (mm, _rs):
|
||
res = compute_future_supply_pressure(db, district="Сбой")
|
||
assert db.begin_nested.call_count >= 1
|
||
# compute_market_metrics (reused) вызван несмотря на сбой view-запроса —
|
||
# сессия не отравлена (в реальном Postgres это была бы "aborted transaction").
|
||
assert mm.called
|
||
assert res.hidden_units == 0
|
||
assert res.index == 0.0
|