"""Tests for Fix 1: asking_to_sold ratio tier resolved from FINAL headline ppm². Проверяем, что _get_asking_sold_ratio вызывается ПОСЛЕ всех headline-мутаций (anchor/IMV-blend/quarter-index/corridor-clamp) с финальным median_ppm2, а не с pre-anchor радиусной медианой. Это устраняет tier-несовпадение (#928 audit fix). Два слоя: 1. Стохастический: _get_asking_sold_ratio не вызывается в ранней части estimate_quality (только после corridor-clamp/radius-floor). 2. Функциональный: когда anchor поднял headline в другой tier, ratio берётся по финальному ppm² (из нового tier), а не по исходной радиусной медиане. Graceful: нет ratio → expected_sold_* == None, headline не изменён. """ from __future__ import annotations import os from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import anyio os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # ── helpers ────────────────────────────────────────────────────────────────── def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: return { "source": "cian", "source_url": "https://cian.ru/offer/1", "address": "ЕКБ, ул. Учителей, 18", "lat": 56.838, "lon": 60.595, "rooms": 2, "area_m2": area_m2, "floor": 5, "total_floors": 16, "price_rub": price_per_m2 * area_m2, "price_per_m2": price_per_m2, "listing_date": datetime(2026, 5, 1), "days_on_market": 10, "photo_urls": [], "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), "distance_m": 150.0, "relevance_score": 0.1, } _ANALOGS_LOW: list[dict[str, Any]] = [ _make_listing(price_per_m2=100_000.0), _make_listing(price_per_m2=110_000.0), _make_listing(price_per_m2=105_000.0), ] _ANALOGS_HIGH: list[dict[str, Any]] = [ _make_listing(price_per_m2=300_000.0), _make_listing(price_per_m2=310_000.0), _make_listing(price_per_m2=305_000.0), ] def _make_geo(): from app.services.geocoder import GeocodeResult return GeocodeResult( lat=56.838, lon=60.595, full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", provider="nominatim", ) def _make_payload(rooms: int = 2): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="ЕКБ, ул. Учителей, 18", area_m2=50.0, rooms=rooms, floor=5, total_floors=16, ) def _run_estimate_with_ratio_spy( analogs: list[dict[str, Any]], ratio_return: tuple[float | None, str | None], ) -> tuple[Any, list[Any]]: """Запускает estimate_quality с отслеживанием вызовов _get_asking_sold_ratio. Возвращает (estimate_result, list_of_call_args). """ from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() ratio_calls: list[Any] = [] def _spy_ratio(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> Any: ratio_calls.append({"rooms": rooms, "anchor_ppm2": anchor_ppm2}) return ratio_return async def _run() -> Any: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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)), patch( "app.services.estimator._fetch_analogs", return_value=(list(analogs), False, "S"), ), patch("app.services.estimator._fetch_deals", return_value=[]), 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._fetch_dkp_corridor", return_value=None), patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy_ratio), ): return await estimate_quality(payload, db) est = anyio.run(_run) return est, ratio_calls # ── тест 1: ratio вызывается РОВНО ОДИН РАЗ (не дважды — до и после headline) ── def test_ratio_called_exactly_once() -> None: """_get_asking_sold_ratio должен вызываться ровно один раз — после headline.""" _est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms")) assert ( len(calls) == 1 ), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз" # ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ──── def test_ratio_tier_uses_final_headline_after_anchor() -> None: """Когда anchor поднял median_ppm2 с ~105k до ~300k, ratio вызывается с ~300k.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() # Симулируем: radius median = 105k, anchor поднимает до 300k. # _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k). captured_anchor_ppm2: list[float | None] = [] def _spy(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> tuple: captured_anchor_ppm2.append(anchor_ppm2) return (0.78, "per_rooms") async def _run() -> Any: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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)), # Radius analogs дают медиану ~105k. patch( "app.services.estimator._fetch_analogs", return_value=(list(_ANALOGS_LOW), False, "S"), ), patch("app.services.estimator._fetch_deals", return_value=[]), 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._fetch_dkp_corridor", return_value=None), # Same-building anchor поднимает headline до ~300k. patch( "app.services.estimator._fetch_anchor_comps", return_value=( [ { "price_per_m2": 300_000, "area_m2": 50.0, "rooms": 2, "source": "cian", "source_url": "u", "address": "a", "lat": 56.838, "lon": 60.595, "floor": 5, "total_floors": 16, "listing_date": datetime(2026, 5, 1), "photo_urls": [], "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), "distance_m": 5.0, "relevance_score": 0.9, "price_rub": 15_000_000, "building_cadastral_number": None, "days_on_market": 5, }, { "price_per_m2": 305_000, "area_m2": 50.0, "rooms": 2, "source": "cian", "source_url": "u2", "address": "a", "lat": 56.838, "lon": 60.595, "floor": 6, "total_floors": 16, "listing_date": datetime(2026, 5, 1), "photo_urls": [], "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), "distance_m": 6.0, "relevance_score": 0.9, "price_rub": 15_250_000, "building_cadastral_number": None, "days_on_market": 5, }, { "price_per_m2": 298_000, "area_m2": 50.0, "rooms": 2, "source": "cian", "source_url": "u3", "address": "a", "lat": 56.838, "lon": 60.595, "floor": 4, "total_floors": 16, "listing_date": datetime(2026, 5, 1), "photo_urls": [], "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), "distance_m": 7.0, "relevance_score": 0.9, "price_rub": 14_900_000, "building_cadastral_number": None, "days_on_market": 5, }, { "price_per_m2": 302_000, "area_m2": 50.0, "rooms": 2, "source": "cian", "source_url": "u4", "address": "a", "lat": 56.838, "lon": 60.595, "floor": 7, "total_floors": 16, "listing_date": datetime(2026, 5, 1), "photo_urls": [], "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), "distance_m": 8.0, "relevance_score": 0.9, "price_rub": 15_100_000, "building_cadastral_number": None, "days_on_market": 5, }, ], "A", ), ), patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy), ): return await estimate_quality(payload, db) anyio.run(_run) assert len(captured_anchor_ppm2) == 1 ppm2_used = captured_anchor_ppm2[0] assert ppm2_used is not None # Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k). assert ( ppm2_used > 200_000 ), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}" # ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ── def test_ratio_graceful_none_no_expected_sold() -> None: """Если ratio = None → все expected_sold_* == None, headline в норме.""" est, _calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (None, None)) assert est.expected_sold_price_rub is None assert est.expected_sold_per_m2 is None assert est.expected_sold_range_low_rub is None assert est.expected_sold_range_high_rub is None assert est.asking_to_sold_ratio is None assert est.ratio_basis is None # Headline не изменён. assert est.median_price_rub > 0