gendesign/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
bot-backend d22e0e00ae
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970) (#2475)
2026-07-12 12:46:18 +00:00

239 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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