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
284 lines
9.5 KiB
Python
284 lines
9.5 KiB
Python
"""#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"]))
|