gendesign/backend/tests/services/forecasting/test_affordability.py
bot-backend 489e380f1e
All checks were successful
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m9s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
feat(forecasting): what-to-build ranker + MAI proxy (#981, 952-B) (#1013)
2026-06-03 07:07:17 +00:00

309 lines
14 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-тесты §7.9 MAI — ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (#981, ADVISORY).
Чистые тесты — БЕЗ живой БД (чистая аннуитет-математика + мок reused-сервисов):
• pure _annuity: textbook-кейс (формула P·i·(1+i)^n/((1+i)^n1)); 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