gendesign/tradein-mvp/backend/tests/test_estimator_manual_review.py
lekss361 275e7e9618
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 49s
feat(tradein/estimator): manual_review recommendation flag (#2002)
2026-06-27 18:17:24 +00:00

284 lines
9.5 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.

"""#2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена).
Покрывает чистый helper `_manual_review`:
- каждый триггер в изоляции (премиальный дом / высокая стоимость / низкая
уверенность / широкий диапазон);
- комбинированный случай (несколько причин сразу);
- all-clear (нет причин → recommended False);
- граничные значения порогов (>= high_value, >= wide_ratio);
- div-guard на диапазон (range_low None/0 → без деления на ноль);
- выключенный флаг estimate_manual_review_enabled → (False, []).
Плюс 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 = "широкий диапазон цены"
def _settings(
*,
enabled: bool = True,
high_value: int = 20_000_000,
wide_ratio: float = 1.9,
) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти три поля."""
return SimpleNamespace(
estimate_manual_review_enabled=enabled,
estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio,
)
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
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]
# ── Комбинированный / 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
# ── Выключенный флаг ─────────────────────────────────────────────────────────
def test_disabled_flag_suppresses_all() -> None:
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=99_000_000,
confidence="low",
range_low=10_000_000,
range_high=99_000_000,
settings=_settings(enabled=False),
)
assert rec is False
assert 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"]))