"""Unit tests for #651/#652 v2 — same-building anchor (validated, 55 golden cases). Покрываем чистые helpers (без БД): нормализатор адреса, свёртку комплов в anchor, hard guardrail; и full estimate path с замоканным `_fetch_anchor_comps`: (a) premium lift (Хохрякова 48: 399k/472k/684k → est ~550k, real 684k в range), (b) economy NO overshoot (guardrail не раздувает), (c) address alias («Ткачёва 13» → «Ткачей 13»), (d) base-house-number match через corpus-литеры, (e) флаг OFF ⇒ неизменный радиусный результат, (f) headline = ASKING после якоря, expected_sold = headline × ratio (DISTINCT). """ import os from datetime import UTC, datetime from typing import Any # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from unittest.mock import AsyncMock, MagicMock, patch import anyio from app.services.estimator import ( _compute_same_building_anchor, _geocode_is_coarse, _normalize_building_key, ) # ── Address normalizer ────────────────────────────────────────────────────── def test_normalize_alias_tkacheva_to_tkachei() -> None: """golden «Ткачёва 13» нормализуется к БД-улице «ткачей» + дом 13 (ё→е + alias).""" street, base, letter = _normalize_building_key("Екатеринбург, ул. Ткачёва, 13") assert street == "ткачей" assert base == 13 assert letter is None def test_normalize_base_house_across_corpus_letter() -> None: """«8 Марта 204Г» → street «8 марта», base 204, letter 'г' (литера сохранена).""" street, base, letter = _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г") assert street == "8 марта" assert base == 204 assert letter == "г" def test_normalize_slash_corpus_collapses_to_base() -> None: """«4/2», «9/1» → base 4/9, корпус-slash отброшен (тот же дом).""" _, base1, letter1 = _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2") assert base1 == 4 assert letter1 is None _, base2, _ = _normalize_building_key("Екатеринбург, Олимпийская набережная, 9/1") assert base2 == 9 def test_normalize_empty_address() -> None: assert _normalize_building_key(None) == (None, None, None) assert _normalize_building_key("") == (None, None, None) # ── #677 hardening: real prod address-format variants → same key ───────────── def test_normalize_tkachei13_all_db_variants_same_key() -> None: """Все prod-форматы «Ткачей 13» (запятая/«д.»/город/·-хвост/р-н/мкр) → ('ткачей', 13).""" variants = [ "Екатеринбург, ул. Ткачёва, 13", # golden alias + ё "Екатеринбург, улица Ткачей, 13", "ул. Ткачей,13", # no space after comma "ул. Ткачей,д. 13", # «д.» variant "улица Ткачей, 13 · р-н Октябрьский", # ·-tail district "р-н Октябрьский, мкр. Парковый, улица Ткачей, 13", # district + mkr prefix ] for v in variants: assert _normalize_building_key(v) == ("ткачей", 13, None), v def test_normalize_house_13_not_130_or_131() -> None: """Дом 13 не должен совпасть с соседями 130/131 (разные base_no).""" assert _normalize_building_key("улица Ткачей, 13")[1] == 13 assert _normalize_building_key("улица Ткачей, 130")[1] == 130 assert _normalize_building_key("улица Ткачей, 131")[1] == 131 def test_normalize_naberezhnaya_type_word_either_side() -> None: """Type-слово до ИЛИ после имени → один street_core «олимпийская»; наб.↔набережная.""" keys = { _normalize_building_key("Олимпийская наб.,13"), _normalize_building_key("набережная Олимпийская, 13"), _normalize_building_key("Екатеринбург, Олимпийская набережная, 13"), _normalize_building_key("р-н Центр, набережная Олимпийская, 13"), } assert keys == {("олимпийская", 13, None)} def test_normalize_borisa_eltsina_multiword_street() -> None: """Многословное имя «Бориса Ельцина» сохраняется целиком; «д. 6»/город/·-хвост → 6.""" for v in ( "ул. Бориса Ельцина,д. 6", "ул. Бориса Ельцина,6", "Екатеринбург, улица Бориса Ельцина, 6", "Бориса Ельцина, 6", "улица Бориса Ельцина, 6 · р-н Центр", ): assert _normalize_building_key(v) == ("бориса ельцина", 6, None), v def test_normalize_sakko_multiword_with_conjunction() -> None: """«Сакко и Ванцетти» (с союзом) → стабильный core; 99 ≠ 100/105к1 (разные дома).""" assert _normalize_building_key("ул. Сакко и Ванцетти,99") == ("сакко и ванцетти", 99, None) assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 99") == ( "сакко и ванцетти", 99, None, ) assert _normalize_building_key("ул. Сакко и Ванцетти,100")[1] == 100 # separate building assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 105к1") == ( "сакко и ванцетти", 105, # «105к1» корпус схлопнут к base None, ) def test_normalize_fevralskoy_revolyutsii_no_space_house() -> None: """«ул. Февральской Революции,21» (genitive multiword, no-space house) → core+21.""" assert _normalize_building_key("ул. Февральской Революции,21") == ( "февральской революции", 21, None, ) def test_normalize_8_marta_numeric_street_prefix() -> None: """Числовой префикс имени («8 Марта») НЕ съедается как номер дома — номер в хвосте.""" assert _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г") == ("8 марта", 204, "г") # без литеры assert _normalize_building_key("улица 8 Марта, 50") == ("8 марта", 50, None) def test_normalize_corpus_letter_distinct_from_plain() -> None: """Литера корпуса — РАЗНЫЕ дома: 16А (letter 'а') ≠ 16 (None).""" assert _normalize_building_key("Екатеринбург, улица Ткачей, 16А") == ("ткачей", 16, "а") assert _normalize_building_key("улица Ткачей, 16") == ("ткачей", 16, None) def test_normalize_slash_corpus_collapses() -> None: """Корпус «/N» схлопывается к base (тот же дом): 9/1 → 9, 4/2 → 4, 19/2 → 19.""" assert _normalize_building_key("Олимпийская набережная, 9/1")[1] == 9 assert _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2")[1] == 4 assert _normalize_building_key("улица Ткачей, 19/2")[1] == 19 def test_normalize_alias_applied_to_full_core_with_yo() -> None: """ё→е применяется ДО alias; alias матчит целый core (ткачёва→ткачева→ткачей).""" assert _normalize_building_key("ул. Ткачёва, 13")[0] == "ткачей" def test_normalize_no_house_number_returns_none_base() -> None: """Адрес без номера дома («р-н Центр, улица Бориса Ельцина») → base_no None (no match).""" assert _normalize_building_key("р-н Центр, улица Бориса Ельцина") == ( "бориса ельцина", None, None, ) # ── Pure anchor compute ───────────────────────────────────────────────────── def _comp( ppm2: int, area: float | None = None, rooms: int | None = None, floor: int | None = None, total_floors: int | None = None, ) -> dict[str, Any]: return { "price_per_m2": ppm2, "area_m2": area, "rooms": rooms, "floor": floor, "total_floors": total_floors, } def test_anchor_premium_lift_hohryakova48() -> None: """Зеркало Хохрякова 48: комплы 399k/472k/684k, target 4к/146 → est_ppm² поднят, реал 684k попадает в диапазон. Радиусная медиана сильно ниже — якорь её заменяет.""" comps = [ _comp(399_478, area=153.2, rooms=3), _comp(472_298, area=110.1, rooms=3), _comp(683_995, area=146.2, rooms=4), ] res = _compute_same_building_anchor( comps, area_target=146.2, rooms_target=4, tier="A", sigma=0.18, rooms_boost=1.6, ) assert res is not None # rooms-boost на флагман-компл (rooms=4 == target) + area-вес тянут anchor к ~566k # (sim дал ~552k) — заметно выше радиусной медианы (~210k), что и есть фикс. assert res["anchor_ppm2"] >= 470_000 assert 520_000 <= res["anchor_sold_ppm2"] <= 600_000 # реал 684k должен попасть в диапазон point ± k·fsd (range_high ≥ real×0.95). point_ppm2 = res["anchor_sold_ppm2"] half = 1.65 * res["fsd"] assert point_ppm2 * (1.0 + half) >= 683_995 * 0.95 def test_anchor_olimp13_flagship_area_matched_no_uplift_needed() -> None: """Олимп 13 пентхаус: флагман 996k сам area+rooms-matched с target 207.9 → weighted mean уже ≈996k (реал 996k), uplift не нужен (был бы избыточен).""" comps = [ _comp(373_444, area=96.4, rooms=3), _comp(468_750, area=96.0, rooms=2), _comp(995_671, area=208.0, rooms=3), ] res = _compute_same_building_anchor( comps, area_target=207.9, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6 ) assert res is not None # Флагман доминирует вес → anchor ≈ 996k, реал 996k подтверждён. assert res["anchor_ppm2"] >= 900_000 assert res["used_uplift"] is False # mean уже ≥ p70 → uplift избыточен def test_anchor_premium_uplift_when_mean_dilutes() -> None: """premium uplift срабатывает когда премиум-компл НЕ area-similar (mean занижает), а target — топ-юнит дома (≥p66) И Tier A → берём верхний квантиль p70.""" comps = [ _comp(900_000, area=60.0, rooms=1), # дорогой, но малая площадь → area-вес низкий _comp(300_000, area=200.0, rooms=3), _comp(320_000, area=210.0, rooms=3), ] res = _compute_same_building_anchor( comps, area_target=205.0, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6 ) assert res is not None assert res["used_uplift"] is True assert res["anchor_ppm2"] >= 500_000 # подтянут к p70, не к дил. mean ~310k def test_anchor_uplift_skipped_off_tier_a() -> None: """uplift только Tier A — на Tier C (micro-radius) не применяется (комплы не из дома).""" comps = [ _comp(900_000, area=60.0, rooms=1), _comp(300_000, area=200.0, rooms=3), _comp(320_000, area=210.0, rooms=3), ] res = _compute_same_building_anchor( comps, area_target=205.0, rooms_target=3, tier="C", sigma=0.18, rooms_boost=1.6 ) assert res is not None assert res["used_uplift"] is False def test_anchor_economy_no_overshoot() -> None: """Эконом (Ильича 28): дешёвые комплы ~112k → guardrail не раздувает, anchor ~112k.""" comps = [_comp(112_500, area=64.0, rooms=3), _comp(112_500, area=63.0, rooms=3)] res = _compute_same_building_anchor( comps, area_target=63.0, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6, ) assert res is not None # mean ~112.5k; haircut эконом-band 7% → sold ~104.6k. НЕ раздут вверх. assert 100_000 <= res["anchor_sold_ppm2"] <= 113_000 # uplift не сработал (все комплы одной площади, p66≈target, p70≈mean → no lift вверх). assert res["anchor_sold_ppm2"] < res["anchor_ppm2"] + 1 # haircut только вниз def test_anchor_guardrail_floor_on_min_comp() -> None: """Guardrail-floor (применяется у caller'а) = min(comp)×(1−tol); helper отдаёт comp_min.""" comps = [_comp(400_000, area=100.0, rooms=2), _comp(600_000, area=100.0, rooms=2)] res = _compute_same_building_anchor( comps, area_target=100.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6 ) assert res is not None assert res["comp_min_ppm2"] == 400_000 def test_anchor_none_when_no_comps() -> None: assert ( _compute_same_building_anchor( [], area_target=50.0, rooms_target=1, tier="A", sigma=0.18, rooms_boost=1.6 ) is None ) def test_anchor_null_area_neutral_weight() -> None: """Комплы без площади (Yandex) судятся только по комнатам — area-вес 1.0, не падает.""" comps = [_comp(300_000, area=None, rooms=2), _comp(320_000, area=None, rooms=2)] res = _compute_same_building_anchor( comps, area_target=70.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6 ) assert res is not None # rooms совпали у обоих → equal weights → anchor ≈ mean 310k, haircut mid 5%. assert abs(res["anchor_ppm2"] - 310_000) < 1_000 # ── Full estimate path (mocked I/O) ───────────────────────────────────────── def _make_listing(*, price_per_m2: float, area_m2: float = 60.0) -> dict[str, Any]: return { "source": "cian", "source_url": "https://cian.ru/offer/1", "address": "ЕКБ, ул. Хохрякова, 48", "lat": 56.830, "lon": 60.592, "rooms": 4, "area_m2": area_m2, "floor": 5, "total_floors": 14, "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": 0.0, "relevance_score": 0.0, "listing_segment": "premium", } # Радиусные аналоги — НИЗКИЕ (массовая застройка рядом размывает премиум). _RADIUS_ANALOGS: list[dict[str, Any]] = [ _make_listing(price_per_m2=200_000.0), _make_listing(price_per_m2=210_000.0), _make_listing(price_per_m2=220_000.0), ] # Same-building комплы Хохрякова 48 (флагман 684k внутри). _SB_COMPS_PREMIUM: list[dict[str, Any]] = [ {"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3}, {"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3}, {"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4}, ] def _make_fake_geo(): from app.services.geocoder import GeocodeResult return GeocodeResult( lat=56.830, lon=60.592, full_address="Свердловская обл., Екатеринбург, ул. Хохрякова, 48", provider="nominatim", ) def _make_payload(area: float = 146.2, rooms: int = 4): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="Екатеринбург, ул. Хохрякова, 48", area_m2=area, rooms=rooms, floor=5, total_floors=14, ) def _run_estimate( *, anchor_comps: list[dict[str, Any]], anchor_tier: str | None, flag_enabled: bool = True, ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), payload=None, radius_analogs: list[dict[str, Any]] | None = None, dadata_result: Any | None = None, geo_result: Any | None = None, ): """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован. radius_analogs=[] моделирует прод-сценарий #691: геокод дал lat/lon, но ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать по same-building комплам независимо. """ from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() pl = payload or _make_payload() radius = list(_RADIUS_ANALOGS if radius_analogs is None else radius_analogs) async def _run(): with ( patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled), patch( "app.services.estimator.geocode", new=AsyncMock(return_value=geo_result or _make_fake_geo()), ), patch( "app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=dadata_result), ), 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=(radius, False, "W"), ), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._fetch_dkp_corridor", return_value=None), patch( "app.services.estimator._fetch_anchor_comps", return_value=(list(anchor_comps), anchor_tier), ), 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=ratio_tuple), ): return await estimate_quality(pl, db) return anyio.run(_run) def test_estimate_premium_lift_real_in_range() -> None: """(a) Хохрякова 48: радиус ~210k размывает → якорь поднимает, реал 684k в range.""" est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") radius_median = int(210_000 * 146.2) # старый радиусный headline # Якорь заменил радиусную медиану и поднял её. assert est.median_price_rub > radius_median assert est.median_price_per_m2 >= 450_000 # Реальная цена флагмана 684k×146.2 ≈ 100М должна попасть в [range_low, range_high]. real = int(683_995 * 146.2) assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub assert est.range_high_rub >= int(real * 0.9) def test_estimate_anchor_fires_without_radius_analogs() -> None: """#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но same-building комплы есть → якорь обязан сработать и дать median>0. До фикса гейт требовал listings_clean+median>0 → якорь скипался.""" est = _run_estimate( anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", radius_analogs=[], ) # Якорь дал ненулевой headline несмотря на пустой радиус. assert est.median_price_rub > 0 assert est.median_price_per_m2 >= 450_000 # range валиден (low ≤ point ≤ high), не вырожден в 0. assert 0 < est.range_low_rub <= est.median_price_rub <= est.range_high_rub def test_estimate_no_anchor_no_radius_stays_insufficient() -> None: """#691 контроль: ни радиуса, ни same-building комплов → median остаётся 0 (insufficient), фикс НЕ фабрикует число из воздуха.""" est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=[]) assert est.median_price_rub == 0 assert est.median_price_per_m2 == 0 def test_estimate_economy_no_regression() -> None: """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.""" eco_comps = [ {"price_per_m2": 112_500, "area_m2": 64.0, "rooms": 3}, {"price_per_m2": 112_500, "area_m2": 63.0, "rooms": 3}, ] est = _run_estimate( anchor_comps=eco_comps, anchor_tier="A", payload=_make_payload(area=63.0, rooms=3), ) # ~112.5k × haircut 7% × area; НЕ раздут вверх (overshoot-guard). assert est.median_price_per_m2 <= 113_000 assert est.median_price_per_m2 >= 100_000 def test_estimate_flag_off_unchanged_radius_result() -> None: """(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает.""" est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False) # Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k. assert est_off.median_price_per_m2 == 210_000 assert est_off.median_price_rub == int(210_000 * 146.2) def test_estimate_expected_sold_distinct_after_anchor() -> None: """(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median. Single asking→sold механизм (ratio); band-haircut больше не в headline.""" ratio = 0.92 est = _run_estimate( anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms") ) # expected_sold выведен из POST-anchor headline × ratio (не равен headline). # per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)), # поэтому сравниваем с допуском ±1 на округление float→int. assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1 assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) # Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел. assert est.expected_sold_price_rub < est.median_price_rub assert est.expected_sold_per_m2 < est.median_price_per_m2 def test_estimate_expected_sold_none_without_ratio_after_anchor() -> None: """Нет migration-080 ratio (None) → expected_sold_* = None даже при якоре (UI не показывает badge — не фабрикуем).""" est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(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 def test_estimate_tier_d_fallback_keeps_radius() -> None: """anchor_tier=None (Tier D) → headline остаётся радиусной медианой (210k).""" est = _run_estimate(anchor_comps=[], anchor_tier=None) assert est.median_price_per_m2 == 210_000 def test_estimate_range_covers_same_building_comp_spread() -> None: """range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не вылетает за диапазон — зеркало 8 Марта 204Г view-кейса).""" # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. view_comps = [ {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, {"price_per_m2": 200_000, "area_m2": 63.0, "rooms": 2}, {"price_per_m2": 255_459, "area_m2": 34.8, "rooms": 1}, ] est = _run_estimate( anchor_comps=view_comps, anchor_tier="A", payload=_make_payload(area=63.4, rooms=2), ) # range_high покрывает comp max в asking-пространстве (255_459 × area). assert est.range_high_rub >= int(255_459 * 63.4) def test_anchor_exposes_comp_max() -> None: """anchor dict отдаёт comp_max_ppm2 (нужен caller'у для spread-coverage).""" res = _compute_same_building_anchor( [_comp(300_000, area=60.0, rooms=2), _comp(500_000, area=60.0, rooms=2)], area_target=60.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6, ) assert res is not None assert res["comp_max_ppm2"] == 500_000 assert res["comp_min_ppm2"] == 300_000 # ── #694: UI-аналоги отражают комплы, на которых построен headline ────────── def test_estimate_analogs_reflect_anchor_comps_when_fired() -> None: """#694: якорь сработал (tier=A) → est.analogs строятся из SAME-BUILDING комплов (399k/472k/684k), а НЕ из дешёвых радиусных (_RADIUS_ANALOGS 200k-220k). price_rub вычислен ppm²×area (комплы без price_rub/address — fallback-путь).""" est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") assert len(est.analogs) == 3 ppm2_shown = {a.price_per_m2 for a in est.analogs} assert ppm2_shown == {399_478, 472_298, 683_995} # НЕ радиусные дешёвые лоты. assert ppm2_shown.isdisjoint({200_000, 210_000, 220_000}) # price_rub = round(ppm² × area) для каждого компла (нет price_rub в моке). by_ppm2 = {a.price_per_m2: a for a in est.analogs} for c in _SB_COMPS_PREMIUM: a = by_ppm2[c["price_per_m2"]] assert a.price_rub == round(c["price_per_m2"] * c["area_m2"]) def test_estimate_analogs_stay_radius_when_no_anchor() -> None: """#694: якорь НЕ сработал (tier=None, Tier D) → est.analogs отражают радиусные аналоги (_RADIUS_ANALOGS 200k-220k) — существующее поведение сохранено.""" est = _run_estimate(anchor_comps=[], anchor_tier=None) assert len(est.analogs) == len(_RADIUS_ANALOGS) ppm2_shown = {a.price_per_m2 for a in est.analogs} assert ppm2_shown == {200_000, 210_000, 220_000} def test_estimate_analogs_pass_through_display_fields() -> None: """#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).""" comps_with_display = [ { "price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3, "address": "ЕКБ, ул. Хохрякова, 48", "source": "cian", "source_url": "https://cian.ru/offer/42", "price_rub": 61_000_000, }, { "price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3, "address": "ЕКБ, ул. Хохрякова, 48", "source": "avito", "source_url": "https://avito.ru/offer/7", "price_rub": 52_000_000, }, {"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4}, # без display → fallback ] est = _run_estimate(anchor_comps=comps_with_display, anchor_tier="A") by_ppm2 = {a.price_per_m2: a for a in est.analogs} a1 = by_ppm2[399_478] assert a1.address == "ЕКБ, ул. Хохрякова, 48" assert a1.source == "cian" assert a1.source_url == "https://cian.ru/offer/42" assert a1.price_rub == 61_000_000 # прямой price_rub, не ppm²×area # компл без display → price_rub вычислен, source None. a3 = by_ppm2[683_995] assert a3.price_rub == round(683_995 * 146.2) assert a3.source is None # ── #680-WB: within-building heterogeneity refine (uplift-gate + floor weight) ── def test_anchor_uplift_gated_by_rooms_median_hohryakova_3k() -> None: """Зеркало Хохрякова 48 3к/153: дом с 4-комн. флагманом (комплы rooms=4 кроме одного 3к). target 3-комн. 153м² (топ по площади, ≥p66) — БЕЗ refine получил бы p70-uplift и унаследовал цену флагмана. С rooms-gate (3 < медианы=4) uplift НЕ срабатывает → anchor падает к similar-комплам, не к headline-флагману.""" comps = [ _comp(399_477, area=153.2, rooms=4, floor=8, total_floors=8), _comp(683_995, area=146.2, rooms=4, floor=5, total_floors=14), _comp(502_846, area=105.4, rooms=3, floor=6, total_floors=9), _comp(683_994, area=146.2, rooms=4, floor=5, total_floors=15), ] res = _compute_same_building_anchor( comps, area_target=153.2, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6, floor_target=8, total_floors_target=8, floor_sigma=0.25, ) assert res is not None # rooms_target 3 < median comp rooms 4 → uplift gated off. assert res["used_uplift"] is False # anchor НЕ должен быть на уровне флагмана (684k); прижат к похожим юнитам. assert res["anchor_ppm2"] < 550_000 def test_anchor_uplift_still_fires_for_genuine_top_unit() -> None: """Топ-юнит дома (rooms ≥ медианы И площадь ≥p66) в Tier A → uplift ВСЁ ЕЩЁ срабатывает, когда дорогой компл area-несимметричен и weighted mean занижает (do-no-harm для настоящих флагманов). Зеркало test_anchor_premium_uplift_when_* с rooms_target == медиане комнат комплов (gate проходит).""" comps = [ _comp(900_000, area=60.0, rooms=3), # дорогой, малая площадь → area-вес низкий _comp(300_000, area=200.0, rooms=3), _comp(320_000, area=210.0, rooms=3), ] res = _compute_same_building_anchor( comps, area_target=205.0, # топ по площади (≥p66) rooms_target=3, # == median comp rooms 3 → rooms-gate проходит tier="A", sigma=0.18, rooms_boost=1.6, ) assert res is not None # mean занижен дил. дорогим малометражным → uplift подтягивает к p70. assert res["used_uplift"] is True assert res["anchor_ppm2"] >= 500_000 def test_anchor_floor_weight_tilts_toward_similar_floor() -> None: """floor_sigma>0 → комплы с похожей вертикальной позицией (floor/total_floors) весят больше. Дорогой видовой топ-этаж и дешёвый средний этаж той же площади; target на среднем этаже → anchor тянется к среднеэтажному компла (не к видовому). target — НЕ топ-юнит по площади (есть компл крупнее) → uplift не маскирует floor.""" comps = [ _comp(300_000, area=60.0, rooms=2, floor=5, total_floors=20), # mid, как target _comp(600_000, area=60.0, rooms=2, floor=20, total_floors=20), # видовой топ _comp(310_000, area=120.0, rooms=3, floor=4, total_floors=20), # крупнее target ] target_kw = dict( area_target=60.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6, floor_target=5, total_floors_target=20, ) off = _compute_same_building_anchor(comps, floor_sigma=0.0, **target_kw) on = _compute_same_building_anchor(comps, floor_sigma=0.25, **target_kw) assert off is not None and on is not None assert off["used_uplift"] is False # target не топ по площади # floor-вес прижимает к среднеэтажному (300k), уводя от видового топа (600k). assert on["anchor_ppm2"] < off["anchor_ppm2"] def test_anchor_floor_weight_neutral_when_data_missing() -> None: """Нет floor у компла/target → floor-вес 1.0 (нейтрально), не падает, результат идентичен floor_sigma=0 (no regress на Yandex-листингах без этажа).""" comps = [_comp(300_000, area=60.0, rooms=2), _comp(340_000, area=62.0, rooms=2)] a = _compute_same_building_anchor( comps, area_target=60.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6, floor_target=None, total_floors_target=None, floor_sigma=0.25, ) b = _compute_same_building_anchor( comps, area_target=60.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6, floor_sigma=0.0, ) assert a is not None and b is not None assert abs(a["anchor_ppm2"] - b["anchor_ppm2"]) < 1.0 # ── #693: coarse-geo (centroid) confidence downgrade ───────────────────────── _APPROX_FLAG = "оценка ориентировочная" def _make_dadata(qc_geo: int | None): """Минимальный DadataAddressResult с заданным qc_geo (прочие поля None/пусто).""" from app.services.dadata import DadataAddressResult return DadataAddressResult( canonical_address=None, house_cadnum=None, house_fias_id=None, lat=None, lon=None, qc_geo=qc_geo, qc_house=None, kladr_id=None, okato=None, oktmo=None, metro=[], raw={}, ) def test_estimate_coarse_geo_no_anchor_downgrades_to_low() -> None: """#693: qc_geo=3 (город) + НЕТ якоря, но радиусные аналоги есть (median>0) → confidence='low' + флаг «ориентировочной» оценки (центроид НП, не дом).""" est = _run_estimate( anchor_comps=[], anchor_tier=None, dadata_result=_make_dadata(qc_geo=3), ) assert est.median_price_rub > 0 # радиусное число есть → было что квалифицировать assert est.confidence == "low" assert _APPROX_FLAG in est.confidence_explanation assert "города" in est.confidence_explanation def test_estimate_coarse_geo_with_anchor_not_downgraded() -> None: """#693 protect #691: qc_geo=3 грубый, НО якорь дома сработал (tier=A) → confidence НЕ даунгрейдится (оценка стоит на реальных комплах дома).""" est = _run_estimate( anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", dadata_result=_make_dadata(qc_geo=3), ) # Якорь сам выставил confidence — не "low" от #693-даунгрейда, и без флага. assert _APPROX_FLAG not in (est.confidence_explanation or "") def test_estimate_precise_geo_no_downgrade() -> None: """#693: qc_geo=0 (дом, точно) + нет якоря → НЕТ даунгрейда (точный адрес).""" est = _run_estimate( anchor_comps=[], anchor_tier=None, dadata_result=_make_dadata(qc_geo=0), ) assert _APPROX_FLAG not in (est.confidence_explanation or "") def test_estimate_no_dadata_no_false_downgrade() -> None: """#693: dadata=None (нет сигнала) → НЕ фабрикуем даунгрейд (no false positive).""" est = _run_estimate( anchor_comps=[], anchor_tier=None, dadata_result=None, ) assert _APPROX_FLAG not in (est.confidence_explanation or "") # ── #693 prod-fix: coarse geocode detected WITHOUT DaData (QA-fail #693) ────── def _make_fake_geo_coarse( full_address: str = "Россия, Свердловская область, Екатеринбург", ): """Геокод-centroid без дома (город/регион) — прод-сценарий, когда DaData off. full_address не содержит house-number токена → _geocode_is_coarse → True. """ from app.services.geocoder import GeocodeResult return GeocodeResult( lat=56.838, lon=60.605, full_address=full_address, provider="yandex", ) def test_geocode_is_coarse_signal() -> None: """Юнит: detector ловит centroid (нет дома) и пропускает реальные адреса.""" from app.services.geocoder import GeocodeResult def _geo(addr: str, conf: str = "approximate"): return GeocodeResult( lat=56.8, lon=60.6, full_address=addr, provider="yandex", confidence=conf ) # type: ignore[arg-type] # Coarse — нет house-number токена. assert _geocode_is_coarse(_geo("Россия, Свердловская область, Екатеринбург")) is True assert _geocode_is_coarse(_geo("Свердловская область")) is True # Почтовый индекс (6 цифр) НЕ считается домом → всё ещё coarse. assert _geocode_is_coarse(_geo("Екатеринбург, 620000")) is True # provider confidence == 'locality' → coarse даже при наличии цифр. assert _geocode_is_coarse(_geo("улица 8 Марта, 12", conf="locality")) is True # Precise — есть номер дома → НЕ coarse (zero false downgrade). assert _geocode_is_coarse(_geo("Екатеринбург, ул. Малышева, 30")) is False assert _geocode_is_coarse(_geo("ЕКБ, ул. Хохрякова, 48")) is False assert _geocode_is_coarse(_geo("Екатеринбург, ул.白, 16а")) is False def test_estimate_coarse_geo_no_dadata_downgrades_to_low() -> None: """#693 QA-fail: DaData off (dadata=None), геокод свалился к city-centroid (нет дома в full_address), якоря нет, но радиусные аналоги есть (median>0) → confidence='low' + флаг «ориентировочной» оценки. Это прод-кейс «фывапролд 999».""" est = _run_estimate( anchor_comps=[], anchor_tier=None, dadata_result=None, geo_result=_make_fake_geo_coarse(), ) assert est.median_price_rub > 0 assert est.confidence == "low" assert _APPROX_FLAG in (est.confidence_explanation or "") def test_estimate_coarse_geo_region_no_dadata_downgrades() -> None: """#693: регион-centroid («Свердловская область») без DaData → low + флаг.""" est = _run_estimate( anchor_comps=[], anchor_tier=None, dadata_result=None, geo_result=_make_fake_geo_coarse(full_address="Свердловская область"), ) assert est.confidence == "low" assert _APPROX_FLAG in (est.confidence_explanation or "") def test_estimate_coarse_geo_with_anchor_no_dadata_not_downgraded() -> None: """#693 protect #691: coarse-геокод без DaData, НО якорь дома (tier=A) сработал → НЕ даунгрейдим (оценка стоит на реальных комплах дома, центроид не важен).""" est = _run_estimate( anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", dadata_result=None, geo_result=_make_fake_geo_coarse(), ) assert _APPROX_FLAG not in (est.confidence_explanation or "") # ── #693 fixup 2/3: Tier C micro-radius must NOT shield a coarse-geo estimate ─ def test_estimate_coarse_geo_tier_c_downgrades() -> None: """#693 QA-fail 2/3 (canonical 'фывапролд 999' → ЕКБ-центроид): грубый геокод + Tier C micro-radius (≤500 м) набрал комплы в плотном центре. РАНЬШЕ guard `anchor_tier is None` блокировал downgrade → medium. Теперь защищаем ТОЛЬКО Tier A; coarse-геокод + Tier C → low + флаг «ориентировочная».""" est = _run_estimate( anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="C", dadata_result=None, geo_result=_make_fake_geo_coarse(), ) assert est.median_price_rub > 0 # Tier C дал headline — было что квалифицировать assert est.confidence == "low" assert _APPROX_FLAG in (est.confidence_explanation or "") def test_estimate_precise_geo_tier_c_not_downgraded() -> None: """#693 no false-downgrade: ТОЧНЫЙ геокод (есть дом) + Tier C → НЕ понижаем (Tier C — легитимный fallback для реального адреса; центроид-сигнала нет).""" est = _run_estimate( anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="C", dadata_result=None, # default geo = '…ул. Хохрякова, 48' → есть номер дома → не coarse ) assert _APPROX_FLAG not in (est.confidence_explanation or "") # ── #695: explanation согласован при сработавшем якоре (без радиус-противоречия) ── def test_explanation_anchor_no_radius_contradiction() -> None: """#695: якорь дома построил headline → explanation описывает якорные комплы (anchor['n']=3 из того же дома), БЕЗ противоречащего радиусного «Найдено N из M разных адресов» и без analog_tier-note про «нет аналогов в том же доме».""" est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") exp = est.confidence_explanation or "" assert "Оценка построена по 3 аналогам из того же дома" in exp assert "Найдено" not in exp # радиусный base-текст не конкатенируется assert "разных адресов" not in exp # нет противоречия «того же дома» vs «разных» def test_explanation_radius_path_keeps_base_text() -> None: """#695 контроль: без якоря (radius-путь) base-текст «Найдено N…» сохраняется.""" est = _run_estimate(anchor_comps=[], anchor_tier=None) exp = est.confidence_explanation or "" assert "Найдено" in exp assert "Оценка построена по" not in exp # якорной фразы нет на чистом радиусе # ── #695 QA fixup: n_analogs синхронизируется с anchor-популяцией (radius≠anchor) ── def test_anchor_n_analogs_syncs_to_anchor_count() -> None: """#695 QA-fail: anchor подменяет headline+список на комплы дома, но n_analogs раньше оставался радиусным → «Показано 5 из 4». radius=5 ≠ anchor=3 намеренно; n_analogs ДОЛЖЕН стать 3 (anchor), согласуясь с «по 3 аналогам» в explanation.""" radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)] est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", radius_analogs=radius) assert est.n_analogs == 3 # = len(_SB_COMPS_PREMIUM), НЕ 5 (радиус) assert "по 3 аналогам" in (est.confidence_explanation or "") def test_radius_path_n_analogs_unchanged() -> None: """#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).""" radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)] est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius) assert est.n_analogs == 5