309 lines
14 KiB
Python
309 lines
14 KiB
Python
"""Unit-тесты §7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (#981, ADVISORY).
|
||
|
||
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
|
||
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n−1)); rate=0 →
|
||
principal/months (без /0); None principal/rate → None; months ≤0 → None.
|
||
• compute_affordability через MagicMock-сессию + @patch _current_mortgage_rate /
|
||
build_sales_series: payment_to_income ВСЕГДА None; rate_kind=='subsidized_weighted';
|
||
confidence=='low'; degraded_reason непуст; payment_at_scenario per-горизонт;
|
||
цена из аргумента vs сегментная (reuse); graceful нет ставки/цены → платёж None.
|
||
|
||
Детерминированно, без LLM. Мокаем reused-сервисы + db (нет живой БД).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.services.forecasting.affordability import (
|
||
_ANNUITY_TERM_MONTHS,
|
||
_DEGRADED_REASON,
|
||
_RATE_KIND_SUBSIDIZED,
|
||
_REF_AREA_M2,
|
||
MortgageAffordabilityIndex,
|
||
_annuity,
|
||
_round_or_none,
|
||
_segment_avg_price,
|
||
compute_affordability,
|
||
)
|
||
from app.services.forecasting.sales_series import SegmentSpec
|
||
|
||
# Пути патча reused-сервисов (импортированы в модуль affordability).
|
||
_RATE = "app.services.forecasting.affordability._current_mortgage_rate"
|
||
_SERIES = "app.services.forecasting.affordability.build_sales_series"
|
||
|
||
|
||
# ── pure: _annuity ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAnnuity:
|
||
def test_textbook_case_matches_formula(self) -> None:
|
||
# Тело 2 500 000 ₽, 12% годовых, 240 мес. Сверяем с явной формулой
|
||
# P·i·(1+i)^n / ((1+i)^n − 1), i = rate/100/12.
|
||
principal = 2_500_000.0
|
||
rate = 12.0
|
||
months = 240
|
||
i = rate / 100.0 / 12.0
|
||
growth = (1.0 + i) ** months
|
||
expected = principal * i * growth / (growth - 1.0)
|
||
assert _annuity(principal, rate, months) == pytest.approx(expected)
|
||
|
||
def test_textbook_case_known_value(self) -> None:
|
||
# Та же вводная — захардкоженное hand-verified значение (~27 527.15 ₽/мес).
|
||
assert _annuity(2_500_000.0, 12.0, 240) == pytest.approx(27_527.15, abs=0.5)
|
||
|
||
def test_zero_rate_is_principal_over_months(self) -> None:
|
||
# Беспроцентный кредит: формула вырождается (i=0 → 0/0) → равномерное тело.
|
||
assert _annuity(2_400_000.0, 0.0, 240) == pytest.approx(10_000.0)
|
||
|
||
def test_negative_rate_degrades_to_principal_over_months(self) -> None:
|
||
# Артефактно-отрицательная ставка → та же беспроцентная деградация (не /0).
|
||
assert _annuity(1_200_000.0, -3.0, 120) == pytest.approx(10_000.0)
|
||
|
||
def test_none_principal_returns_none(self) -> None:
|
||
assert _annuity(None, 12.0, 240) is None
|
||
|
||
def test_none_rate_returns_none(self) -> None:
|
||
assert _annuity(2_500_000.0, None, 240) is None
|
||
|
||
def test_zero_or_negative_months_returns_none(self) -> None:
|
||
assert _annuity(2_500_000.0, 12.0, 0) is None
|
||
assert _annuity(2_500_000.0, 12.0, -12) is None
|
||
|
||
def test_higher_rate_means_higher_payment(self) -> None:
|
||
# Монотонность: выше ставка → выше платёж (при равном теле/сроке).
|
||
low = _annuity(2_500_000.0, 7.0, 240)
|
||
high = _annuity(2_500_000.0, 20.0, 240)
|
||
assert low is not None and high is not None
|
||
assert high > low
|
||
|
||
|
||
# ── pure: _round_or_none ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestRoundOrNone:
|
||
def test_rounds(self) -> None:
|
||
assert _round_or_none(27_522.16, 0) == 27_522.0
|
||
|
||
def test_none_passthrough(self) -> None:
|
||
assert _round_or_none(None, 0) is None
|
||
|
||
|
||
# ── MortgageAffordabilityIndex.as_dict ────────────────────────────────────────
|
||
|
||
|
||
def _make_index(**over: object) -> MortgageAffordabilityIndex:
|
||
base: dict[str, object] = {
|
||
"segment": {
|
||
"obj_class": "комфорт",
|
||
"room_bucket": "2-к 45-60",
|
||
"district": "X",
|
||
"price_bucket": None,
|
||
},
|
||
"monthly_payment_rub": 27_522.16,
|
||
"rate_used": 7.5,
|
||
"rate_kind": _RATE_KIND_SUBSIDIZED,
|
||
"rate_period": "2026-01",
|
||
"ref_area_m2": _REF_AREA_M2,
|
||
"price_per_m2": 120_000.0,
|
||
"annuity_term_months": _ANNUITY_TERM_MONTHS,
|
||
"payment_to_income": None,
|
||
"payment_at_scenario": {6: 27_000.0, 12: 28_000.0},
|
||
"degraded_reason": _DEGRADED_REASON,
|
||
"confidence": "low",
|
||
}
|
||
base.update(over)
|
||
return MortgageAffordabilityIndex(**base) # type: ignore[arg-type]
|
||
|
||
|
||
class TestAsDict:
|
||
def test_rounds_money_fields(self) -> None:
|
||
d = _make_index().as_dict()
|
||
assert d["monthly_payment_rub"] == 27_522.0
|
||
assert d["price_per_m2"] == 120_000.0
|
||
assert d["payment_at_scenario"] == {6: 27_000.0, 12: 28_000.0}
|
||
|
||
def test_honesty_fields_preserved(self) -> None:
|
||
d = _make_index().as_dict()
|
||
assert d["payment_to_income"] is None
|
||
assert d["rate_kind"] == "subsidized_weighted"
|
||
assert d["confidence"] == "low"
|
||
assert d["degraded_reason"]
|
||
|
||
def test_none_fields_survive(self) -> None:
|
||
d = _make_index(
|
||
monthly_payment_rub=None, price_per_m2=None, payment_at_scenario=None
|
||
).as_dict()
|
||
assert d["monthly_payment_rub"] is None
|
||
assert d["price_per_m2"] is None
|
||
assert d["payment_at_scenario"] is None
|
||
|
||
|
||
# ── orchestrator helpers (стабы reused-сервисов) ──────────────────────────────
|
||
|
||
|
||
def _series_stub(prices: list[float | None]) -> MagicMock:
|
||
"""Стаб build_sales_series: несёт avg_price_per_m2-список (для _segment_avg_price)."""
|
||
m = MagicMock()
|
||
m.avg_price_per_m2 = prices
|
||
return m
|
||
|
||
|
||
def _run(**over: object) -> MortgageAffordabilityIndex:
|
||
spec = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
|
||
return compute_affordability(MagicMock(), spec=spec, **over) # type: ignore[arg-type]
|
||
|
||
|
||
# ── orchestrator: honesty-контракт (КРИТИЧНО) ─────────────────────────────────
|
||
|
||
|
||
class TestHonestyContract:
|
||
def test_payment_to_income_always_none(self) -> None:
|
||
# Дохода в данных нет (#946) → payment_to_income ВСЕГДА None.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.payment_to_income is None
|
||
|
||
def test_rate_kind_is_subsidized_weighted(self) -> None:
|
||
# Рыночной ставки (~20%) нет → метка всегда subsidized_weighted.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.rate_kind == "subsidized_weighted"
|
||
|
||
def test_confidence_always_low(self) -> None:
|
||
# Деградированный прокси → confidence ВСЕГДА low (даже при полных входах).
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.confidence == "low"
|
||
|
||
def test_degraded_reason_explains_both_gaps(self) -> None:
|
||
# degraded_reason упоминает ОБА пробела: рыночную ставку И доход.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.degraded_reason
|
||
lower = res.degraded_reason.lower()
|
||
assert "ставк" in lower # упоминает ставку
|
||
assert "доход" in lower # упоминает доход
|
||
|
||
|
||
# ── orchestrator: расчёт платежа ──────────────────────────────────────────────
|
||
|
||
|
||
class TestPaymentComputation:
|
||
def test_monthly_payment_uses_annuity(self) -> None:
|
||
# price 120k × ref 50 м² = 6 000 000 ₽ тело; ставка 7.5%, 240 мес.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
principal = 120_000.0 * _REF_AREA_M2
|
||
expected = _annuity(principal, 7.5, _ANNUITY_TERM_MONTHS)
|
||
assert res.monthly_payment_rub == pytest.approx(expected)
|
||
assert res.rate_used == 7.5
|
||
assert res.rate_period == "2026-01"
|
||
assert res.price_per_m2 == 120_000.0
|
||
assert res.ref_area_m2 == _REF_AREA_M2
|
||
|
||
def test_explicit_price_overrides_segment(self) -> None:
|
||
# Явная цена в аргументе → build_sales_series НЕ вызывается.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
with patch(_SERIES) as series_mock:
|
||
res = _run(price_per_m2=150_000.0)
|
||
series_mock.assert_not_called()
|
||
assert res.price_per_m2 == 150_000.0
|
||
|
||
def test_segment_price_used_when_arg_none(self) -> None:
|
||
# Без price_per_m2 → сегментная средняя из build_sales_series (reuse).
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
with patch(_SERIES, return_value=_series_stub([100_000.0, None, 140_000.0])):
|
||
res = _run()
|
||
# средняя НЕнулевых: (100k + 140k) / 2 = 120k.
|
||
assert res.price_per_m2 == pytest.approx(120_000.0)
|
||
|
||
|
||
# ── orchestrator: payment_at_scenario per-горизонт ────────────────────────────
|
||
|
||
|
||
class TestPaymentAtScenario:
|
||
def test_scenario_payment_per_horizon(self) -> None:
|
||
# rate_path: разные ставки на горизонтах → платёж считается на каждой.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||
assert res.payment_at_scenario is not None
|
||
principal = 120_000.0 * _REF_AREA_M2
|
||
assert res.payment_at_scenario[6] == pytest.approx(
|
||
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
|
||
)
|
||
assert res.payment_at_scenario[12] == pytest.approx(
|
||
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
|
||
)
|
||
# Выше ставка → выше платёж на этом горизонте.
|
||
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||
|
||
def test_no_rate_path_yields_none_scenario(self) -> None:
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.payment_at_scenario is None
|
||
|
||
def test_scenario_drops_uncomputable_horizon(self) -> None:
|
||
# Нет цены → principal None → платёж на горизонте None → ключ выпадает.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
with patch(_SERIES, return_value=_series_stub([None])):
|
||
res = _run(rate_path={6: 8.0})
|
||
assert res.payment_at_scenario == {}
|
||
|
||
|
||
# ── orchestrator: graceful (нет ставки / нет цены) ────────────────────────────
|
||
|
||
|
||
class TestGraceful:
|
||
def test_no_rate_yields_none_payment(self) -> None:
|
||
# Ставки нет в БД → платёж None, но индекс возвращается (не crash).
|
||
with patch(_RATE, return_value=(None, None)):
|
||
res = _run(price_per_m2=120_000.0)
|
||
assert res.monthly_payment_rub is None
|
||
assert res.rate_used is None
|
||
assert res.confidence == "low"
|
||
|
||
def test_no_price_yields_none_payment(self) -> None:
|
||
# Тонкий сегмент (нет цены) → principal None → платёж None.
|
||
with patch(_RATE, return_value=(7.5, "2026-01")):
|
||
with patch(_SERIES, return_value=_series_stub([None, None])):
|
||
res = _run()
|
||
assert res.price_per_m2 is None
|
||
assert res.monthly_payment_rub is None
|
||
|
||
def test_returns_index_always(self) -> None:
|
||
with patch(_RATE, return_value=(None, None)):
|
||
with patch(_SERIES, return_value=_series_stub([])):
|
||
res = _run()
|
||
assert isinstance(res, MortgageAffordabilityIndex)
|
||
assert res.payment_to_income is None
|
||
assert res.confidence == "low"
|
||
|
||
|
||
# ── pure-ish: _segment_avg_price ──────────────────────────────────────────────
|
||
|
||
|
||
class TestSegmentAvgPrice:
|
||
def test_averages_nonnull_months(self) -> None:
|
||
spec = SegmentSpec(obj_class="комфорт")
|
||
with patch(_SERIES, return_value=_series_stub([110_000.0, None, 130_000.0])):
|
||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||
assert price == pytest.approx(120_000.0)
|
||
|
||
def test_all_none_yields_none(self) -> None:
|
||
spec = SegmentSpec(obj_class="комфорт")
|
||
with patch(_SERIES, return_value=_series_stub([None, None])):
|
||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||
assert price is None
|
||
|
||
def test_empty_series_yields_none(self) -> None:
|
||
spec = SegmentSpec(obj_class="комфорт")
|
||
with patch(_SERIES, return_value=_series_stub([])):
|
||
price = _segment_avg_price(MagicMock(), spec=spec, source="objective_lots")
|
||
assert price is None
|