gendesign/tradein-mvp/backend/tests/test_estimator_manual_review.py
bot-backend d22e0e00ae
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970) (#2475)
2026-07-12 12:46:18 +00:00

372 lines
12 KiB
Python
Raw Permalink 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.

"""#2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена).
Покрывает чистый helper `_manual_review`:
- каждый триггер в изоляции (премиальный дом / высокая стоимость / низкая
уверенность / широкий диапазон);
- комбинированный случай (несколько причин сразу);
- all-clear (нет причин → recommended False);
- граничные значения порогов (>= high_value, >= wide_ratio);
- div-guard на диапазон (range_low None/0 → без деления на ноль).
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
без изменений, когда ручная оценка не рекомендована).
"""
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from app.services.estimator import _manual_review
PREMIUM_REASON = "премиальный дом — премия зависит от отделки/вида (не в данных сделок)"
HIGH_VALUE_REASON = "высокая стоимость (≥20 млн ₽)"
LOW_CONF_REASON = "низкая уверенность оценки"
WIDE_RANGE_REASON = "широкий диапазон цены"
ELITE_REASON = (
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
)
def _settings(
*,
high_value: int = 20_000_000,
wide_ratio: float = 1.9,
elite_ppm2: int = 250_000,
) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти поля."""
return SimpleNamespace(
estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio,
estimate_manual_review_elite_ppm2=elite_ppm2,
)
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
def test_premium_building_alone() -> None:
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=8_000_000,
confidence="high",
range_low=7_500_000,
range_high=8_500_000,
settings=_settings(),
)
assert rec is True
assert reasons == [PREMIUM_REASON]
def test_high_value_alone() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=25_000_000,
confidence="high",
range_low=24_000_000,
range_high=26_000_000,
settings=_settings(),
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
def test_low_confidence_alone() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=5_000_000,
confidence="low",
range_low=4_500_000,
range_high=5_500_000,
settings=_settings(),
)
assert rec is True
assert reasons == [LOW_CONF_REASON]
def test_wide_range_alone() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=5_000_000,
range_high=10_000_000, # ratio 2.0 ≥ 1.9
settings=_settings(),
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
def test_elite_ppm2_alone() -> None:
"""asking_ppm2 ≥ elite_ppm2 → только элит-причина, даже когда оценка < 20 млн.
Юнит НЕ в premium-доме, expected_sold < high_value, confidence не low,
диапазон узкий — без asking-триггера остался бы без предупреждения.
"""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000, # ratio ~1.18 < 1.9
settings=_settings(),
asking_ppm2=300_000, # ≥ 250_000
)
assert rec is True
assert reasons == [ELITE_REASON]
def test_elite_ppm2_boundary_inclusive() -> None:
"""asking_ppm2 == elite_ppm2 → триггерит (>=)."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=250_000,
)
assert rec is True
assert reasons == [ELITE_REASON]
def test_elite_ppm2_just_below_threshold() -> None:
"""asking_ppm2 на 1 ₽ ниже порога → без элит-причины."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=249_999,
)
assert rec is False
assert reasons == []
def test_elite_ppm2_none_safe() -> None:
"""asking_ppm2 None (дефолт) → элит-триггер не срабатывает, без ошибок."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=None,
)
assert rec is False
assert reasons == []
assert ELITE_REASON not in reasons
def test_elite_ppm2_zero_safe() -> None:
"""asking_ppm2 == 0 (falsy) → без деления/триггера, без элит-причины."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=12_000_000,
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=0,
)
assert rec is False
assert ELITE_REASON not in reasons
def test_elite_ppm2_combined_with_other_triggers() -> None:
"""Элит-причина аддитивна: premium + high_value + low_conf + wide + elite."""
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=30_000_000,
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
asking_ppm2=400_000,
)
assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range → elite
assert reasons == [
PREMIUM_REASON,
HIGH_VALUE_REASON,
LOW_CONF_REASON,
WIDE_RANGE_REASON,
ELITE_REASON,
]
# ── Комбинированный / all-clear ──────────────────────────────────────────────
def test_multiple_reasons_combined() -> None:
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=30_000_000,
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
)
assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range
assert reasons == [PREMIUM_REASON, HIGH_VALUE_REASON, LOW_CONF_REASON, WIDE_RANGE_REASON]
def test_all_clear_no_reasons() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=10_000_000,
confidence="medium",
range_low=9_000_000,
range_high=11_000_000, # ratio ~1.22 < 1.9
settings=_settings(),
)
assert rec is False
assert reasons == []
# ── Граничные значения порогов ───────────────────────────────────────────────
def test_high_value_boundary_inclusive() -> None:
"""expected_sold_price == high_value → триггерит (>=)."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=20_000_000,
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
settings=_settings(),
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
def test_high_value_just_below_threshold() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=19_999_999,
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
settings=_settings(),
)
assert rec is False
assert reasons == []
def test_wide_range_boundary_inclusive() -> None:
"""range_high/range_low == wide_ratio → триггерит (>=)."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=10_000_000,
range_high=19_000_000, # ratio == 1.9
settings=_settings(),
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
def test_wide_range_just_below_threshold() -> None:
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=10_000_000,
range_high=18_900_000, # ratio 1.89 < 1.9
settings=_settings(),
)
assert rec is False
assert reasons == []
# ── Div-guard на диапазон ────────────────────────────────────────────────────
@pytest.mark.parametrize(
("range_low", "range_high"),
[
(None, 10_000_000),
(0, 10_000_000),
(5_000_000, None),
(None, None),
],
)
def test_wide_range_div_guard(range_low: int | None, range_high: int | None) -> None:
"""range_low None/0 (или range_high None) → без ZeroDivisionError и без причины."""
rec, reasons = _manual_review(
premium_building=False,
expected_sold_price=8_000_000,
confidence="high",
range_low=range_low,
range_high=range_high,
settings=_settings(),
)
assert rec is False
assert WIDE_RANGE_REASON not in reasons
# ── invariant recommended == bool(reasons) ───────────────────────────────────
def test_recommended_equals_bool_reasons_invariant() -> None:
for premium, esp, conf in [
(False, None, "medium"),
(True, 5_000_000, "medium"),
(False, 25_000_000, "low"),
]:
rec, reasons = _manual_review(
premium_building=premium,
expected_sold_price=esp,
confidence=conf,
range_low=9_000_000,
range_high=11_000_000,
settings=_settings(),
)
assert rec is bool(reasons)
# ── AggregatedEstimate default serialization smoke ───────────────────────────
def test_response_model_defaults_no_manual_review() -> None:
"""AggregatedEstimate по умолчанию: recommended=False, reasons=[]."""
from datetime import UTC, datetime
from uuid import uuid4
from app.schemas.trade_in import AggregatedEstimate
est = AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=10_000_000,
range_low_rub=9_000_000,
range_high_rub=11_000_000,
median_price_per_m2=200_000,
confidence="medium",
n_analogs=12,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC),
)
assert est.manual_review_recommended is False
assert est.manual_review_reasons == []
dumped = est.model_dump()
assert dumped["manual_review_recommended"] is False
assert dumped["manual_review_reasons"] == []
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))