"""Tests for #estimate-zero-analogs: honest refusal instead of a point price when n_analogs == 0 and the engine itself already trusts the number no more than "почти нет сигнала" (reliability == 'very_low'). Repro this guards against (prod, 2026-09-16): "Красногорск, Янтарная" returned a confident 236 766 руб/м² with n_analogs=0 — 3.8% of ЕКБ estimates and 4.8% of region 77/50 estimates hit this shape. Same isolation harness as test_estimator_expected_sold.py: `estimate_quality` with every I/O dependency stubbed (no DB, no network). """ from __future__ import annotations import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import anyio # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") def _make_fake_geo(): from app.services.geocoder import GeocodeResult return GeocodeResult( lat=55.831, lon=37.330, full_address="Московская обл., Красногорск, ул. Янтарная, 5", provider="nominatim", ) def _make_payload(): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="Красногорск, ул. Янтарная, 5", area_m2=40.0, rooms=1, floor=4, total_floors=16, ) def _run_estimate( *, require_analogs: bool, dkp_raw: dict[str, Any] | None = None, ): """estimate_quality: пустой радиус (listings=[]), нет anchor — только опциональный ДКП-коридор (`dkp_raw`) через `_fetch_dkp_corridor`.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() async def _run(): with ( patch( "app.services.estimator.settings.estimate_require_local_evidence", require_analogs ), patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), # Ничего не нашли в радиусе — listings_clean=[] → median_price=0 до фолбэков. patch("app.services.estimator._fetch_analogs", return_value=([], False, None)), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), # Ни same-building, ни micro-radius anchor. patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), patch( "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) ), patch( "app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None), ), patch( "app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None), ), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): return await estimate_quality(payload, db) return anyio.run(_run) def test_true_zero_data_suppresses_price_when_flag_on() -> None: """Ни listings, ни anchor, ни ДКП-коридора вообще → n_analogs=0, reliability='very_low' изначально (без ДКП-фолбэка median остаётся 0 и без флага) — но проверяем именно flag=True ветку на honest-explanation.""" est = _run_estimate(require_analogs=True, dkp_raw=None) assert est.n_analogs == 0 assert est.median_price_rub == 0 assert est.median_price_per_m2 == 0 assert est.range_low_rub == 0 assert est.range_high_rub == 0 assert est.expected_sold_price_rub is None assert est.confidence_explanation is not None assert "Недостаточно данных" in est.confidence_explanation def test_city_wide_corridor_never_becomes_headline() -> None: """Прод-репро «Красногорск, Янтарная»: у улицы одна сделка, поэтому коридор расширился до всего города (scope='city_wide', 4770 сделок, медиана 200 676 ₽/м²) и выдал её как оценку конкретной квартиры. Средняя цена города — не оценка адреса, поэтому цены быть не должно.""" dkp_raw = { "count": 4770, "median_ppm2": 200_676.0, "low_ppm2": 150_000.0, "high_ppm2": 260_000.0, "period_months": 12, "scope": "city_wide", } est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw) assert est.n_analogs == 0 assert est.median_price_rub == 0, "медиана по городу не может быть ценой квартиры" assert est.median_price_per_m2 == 0 assert est.range_low_rub == 0 assert est.range_high_rub == 0 assert est.expected_sold_price_rub is None assert est.expected_sold_range_low_rub is None assert est.expected_sold_range_high_rub is None assert est.insufficient_data is True assert "Недостаточно данных" in (est.confidence_explanation or "") # Адрес/гео — НЕ ценовые поля — сохраняются, чтобы фронт показал адрес. assert est.target_address is not None def test_street_scoped_corridor_still_headlines() -> None: """Обратная сторона того же правила: уличный коридор — настоящий сигнал и цену даёт. Это защита #oblast-D (Нижний Тагил), без которой туда протекал екатеринбургский asking вшестеро выше.""" dkp_raw = { "count": 12, "median_ppm2": 85_911.0, "low_ppm2": 70_000.0, "high_ppm2": 100_000.0, "period_months": 12, "scope": "street", } est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw) assert est.n_analogs == 0 assert est.median_price_per_m2 == 85_911, "уличный коридор обязан остаться headline" assert est.insufficient_data is False def test_city_wide_corridor_kept_when_flag_off() -> None: """Флаг False → прежнее поведение целиком: общегородской коридор снова становится headline. Откат без релиза.""" dkp_raw = { "count": 4770, "median_ppm2": 200_676.0, "low_ppm2": 150_000.0, "high_ppm2": 260_000.0, "period_months": 12, "scope": "city_wide", } est = _run_estimate(require_analogs=False, dkp_raw=dkp_raw) assert est.n_analogs == 0 assert est.median_price_rub > 0, "flag=False должен возвращать прежнее поведение" assert est.median_price_per_m2 == 200_676