"""#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 = "широкий диапазон цены" ELITE_REASON = ( "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" ) def _settings( *, enabled: bool = True, 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_enabled=enabled, 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 # ── Выключенный флаг ───────────────────────────────────────────────────────── 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"]))