"""#1774 — Tier A (same building) gated relaxation of the #1186 novostroyki guard. В СДАННОМ доме cian тегирует переуступки/перепродажи собственниками (sale_type=free) как listing_segment='novostroyki'. Раньше Tier A-гард #1186 `(listing_segment IS NULL OR listing_segment='vtorichka')` отбрасывал их, и same-building listing пользователя не учитывался (кейс «Евгения Савкова 29», ЕКБ). Дизайн #1774 (Tier A ONLY), gate hardened после code-review: - secondary_count = число vtorichka/NULL rows; primary_count = остальные. - flag ON + secondary_count ≥ 1 И secondary_count ≥ primary_count → впускаем ВСЕ rows (включая novostroyki-переуступки). - flag ON + primary-dominated дом (secondary_count < primary_count) → гард #1186 сохраняется: иначе MAD-clip выкинет редкую вторичку и заякорит на ценах застройщика (~27% overvaluation). - flag OFF → гард #1186 сохраняется всегда (novostroyki исключены). - dedup: PRIMARY ключ (source, source_id) — codebase canon (скрейперы дедупят на source_id; source_url может отличаться trailing slash/query-param). Tier C / радиус / ratio — НЕ затрагиваются (см. test_segment_guard_1186.py). """ from __future__ import annotations import os from typing import Any from unittest.mock import MagicMock, patch # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services import estimator as est_mod from app.services.estimator import _fetch_anchor_comps # Адрес сводится к ('евгения савкова', 29, None) — Tier A street+base. _ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29" def _row( *, source: str = "avito", source_url: str | None = None, source_id: str | None = None, listing_segment: str | None = None, price_per_m2: float = 150_000.0, area_m2: float = 50.0, rooms: int = 2, address: str = _ADDRESS, ) -> dict[str, Any]: """Mock listings-row (mappings shape) для Tier A: несёт listing_segment + source_id.""" if source_url is None: source_url = f"https://{source}.ru/offer/{source_id or '0'}" return { "price_per_m2": price_per_m2, "area_m2": area_m2, "rooms": rooms, "floor": 3, "total_floors": 9, "address": address, "source": source, "source_url": source_url, "price_rub": int(price_per_m2 * area_m2), "listing_date": None, "days_on_market": 15, "photo_urls": [], "lat": 56.838, "lon": 60.595, "listing_segment": listing_segment, "source_id": source_id, } def _db_mock(rows: list[dict[str, Any]]) -> MagicMock: """Session mock: db.execute().mappings().all() → rows (Tier A path).""" db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = rows return db def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]: """Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip).""" return _fetch_anchor_comps( db, address=_ADDRESS, target_house_id=None, lat=None, lon=None, rooms=2, area=50.0, ) def test_tier_a_includes_novostroyki_when_secondary_present() -> None: """Mixed/сданный дом: 4 vtorichka/NULL + 2 novostroyki → flag ON впускает ВСЕ 6. secondary_count=4 ≥ primary_count=2 → hardened gate допускает novostroyki. Зеркало «Евгения Савкова 29»: vtorichka-комплы (avito/yandex) соседствуют с cian-переуступками (novostroyki) — последние тоже должны попасть в anchor. """ rows = [ _row(source="avito", source_id="a1", listing_segment="vtorichka"), _row(source="avito", source_id="a2", listing_segment=None), _row(source="yandex", source_id="y1", listing_segment="vtorichka"), _row(source="yandex", source_id="y2", listing_segment=None), _row(source="cian", source_id="c1", listing_segment="novostroyki"), _row(source="cian", source_id="c2", listing_segment="novostroyki"), ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. assert len(comps) == 6 def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None: """Hardened gate (code-review fix): 1 vtorichka + 5 novostroyki → secondary_count=1 < primary_count=5 → novostroyki ИСКЛЮЧЕНЫ (anchor строился бы на ценах застройщика → ~27% overvaluation после MAD-clip выкидывания одинокой вторички). После исключения остаётся 1 vtorichka < min_comps=4 → Tier A не срабатывает (lat/lon None → Tier C skip → tier=None, comps=[]). Это доказывает, что primary rows отброшены: при их допуске было бы 6 comps ≥ min_comps и tier='A'. """ rows = [ _row(source="avito", source_id="a1", listing_segment="vtorichka"), _row(source="cian", source_id="c1", listing_segment="novostroyki"), _row(source="cian", source_id="c2", listing_segment="novostroyki"), _row(source="cian", source_id="c3", listing_segment="novostroyki"), _row(source="cian", source_id="c4", listing_segment="novostroyki"), _row(source="cian", source_id="c5", listing_segment="novostroyki"), ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. assert tier is None assert comps == [] def test_tier_a_excludes_novostroyki_when_pure_primary() -> None: """Чисто-первичный дом (0 вторички, ВСЕ novostroyki) → гард #1186 сохраняется, novostroyki исключаются → comps < min_comps → Tier A не срабатывает (tier=None). Tier-D-skip path намеренный: lat/lon=None → Tier C не запускается, поэтому после отбраковки всех 4 novostroyki Tier A не набирает min_comps и возвращает (tier=None, comps=[]). Assert именно на этом нулевом результате — фиксирует, что чисто-первичный дом НЕ заякоривается на ценах застройщика.""" rows = [ _row(source="cian", source_id="c1", listing_segment="novostroyki"), _row(source="cian", source_id="c2", listing_segment="novostroyki"), _row(source="cian", source_id="c3", listing_segment="novostroyki"), _row(source="cian", source_id="c4", listing_segment="novostroyki"), ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). assert tier is None assert comps == [] def test_tier_a_dedup_same_source_id_collapses() -> None: """#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки — одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу (source, source_id). 5 уникальных vtorichka + дубль одного source_id → 5 comps (не 6 — дубль убран). """ dup_url = "https://cian.ru/sale/flat/330047129/" rows = [ _row(source="avito", source_id="a1", listing_segment="vtorichka"), _row(source="avito", source_id="a2", listing_segment="vtorichka"), _row(source="yandex", source_id="y1", listing_segment="vtorichka"), _row(source="yandex", source_id="y2", listing_segment="vtorichka"), _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment="vtorichka"), # Дубль: тот же (source, source_id), но другой row (house_id_fk NULL-вариант). _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. assert len(comps) == 5 cian = [c for c in comps if c["source"] == "cian"] assert len(cian) == 1 def test_tier_a_dedup_same_source_id_different_url_collapses() -> None: """Code-review Fix 2: те же source_id, но РАЗНЫЙ source_url (trailing slash / query-param). PRIMARY ключ (source, source_id) всё равно схлопывает в 1 comp — старый url-primary ключ этот дубль ПРОПУСКАЛ (это и есть баг #1774 Fix 2). 5 уникальных vtorichka + дубль того же source_id с другим url → 5 comps. """ rows = [ _row(source="avito", source_id="a1", listing_segment="vtorichka"), _row(source="avito", source_id="a2", listing_segment="vtorichka"), _row(source="yandex", source_id="y1", listing_segment="vtorichka"), _row(source="yandex", source_id="y2", listing_segment="vtorichka"), _row( source="cian", source_url="https://cian.ru/sale/flat/330047129/", source_id="330047129", listing_segment="vtorichka", ), # Дубль того же source_id, но url отличается trailing slash + query-param. _row( source="cian", source_url="https://cian.ru/sale/flat/330047129?utm=x", source_id="330047129", listing_segment=None, ), ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # source_id-primary схлопывает несмотря на разные url → 5 comps. assert len(comps) == 5 cian = [c for c in comps if c["source"] == "cian"] assert len(cian) == 1 def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None: """source_url=None, но source_id задан → PRIMARY ключ (source, source_id): реально разные лоты НЕ схлопываются (разные source_id).""" rows = [ _row(source="avito", source_url=None, source_id="a1", area_m2=50.0), _row(source="avito", source_url=None, source_id="a2", area_m2=60.0), _row(source="avito", source_url=None, source_id="a3", area_m2=70.0), _row(source="avito", source_url=None, source_id="a4", area_m2=80.0), ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. assert len(comps) == 4