gendesign/tradein-mvp/backend/tests/test_estimator_radius_dedup_1871.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

327 lines
11 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.

"""#1871 P2 — radius-tier (source, source_id) dedup в _fetch_analogs.
Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
тексте (как test_estimator_price_trend_dedup.py).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
import app.services.estimator as est
def _capture_tier_sql() -> list[str]:
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
тиров отрендерился (каждый тир возвращает [] → fallthrough к следующему).
"""
captured: list[str] = []
db = MagicMock()
def side_effect(*args, **kwargs): # type: ignore[no-untyped-def]
captured.append(str(args[0].text))
result = MagicMock()
result.mappings.return_value.all.return_value = []
return result
db.execute.side_effect = side_effect
est._fetch_analogs(
db,
lat=56.83,
lon=60.6,
rooms=2,
area=50.0,
radius_m=2000,
full_address="г Екатеринбург, ул Малышева, д 30",
year_built=2010,
house_type="монолит",
total_floors=20,
target_house_id=123,
)
return captured
# ---------------------------------------------------------------------------
# SQL-фрагмент во всех тирах
# ---------------------------------------------------------------------------
def test_all_four_tiers_render() -> None:
sqls = _capture_tier_sql()
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
def test_rn_dup_window_present_in_every_tier() -> None:
sqls = _capture_tier_sql()
for i, sql in enumerate(sqls):
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
def test_rn_dup_key_namespaced_and_freshest() -> None:
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
sql = _capture_tier_sql()[0]
assert "PARTITION BY source" in sql
assert "'id:' || source_id::text" in sql
assert "'url:' || source_url" in sql
assert "'ctid:' || ctid::text" in sql
# freshest: окно сортируется по scraped_at DESC → rn_dup=1 это свежайшая строка
win_start = sql.find("AS rn_dup")
win_region = sql[max(0, win_start - 400) : win_start]
assert "ORDER BY scraped_at DESC" in win_region
# ---------------------------------------------------------------------------
# psycopg3-инвариант: column::type OK, :bind::type запрещён
# ---------------------------------------------------------------------------
def test_no_bind_param_double_colon_cast() -> None:
"""В сгенерированном SQL нет :name::type (только column::type, что легально)."""
import re
bad = re.compile(r":[a-z_]+::[a-z]")
for i, sql in enumerate(_capture_tier_sql()):
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
# ---------------------------------------------------------------------------
# Семантика дедупа окна (Python-симуляция row_number() OVER PARTITION/ORDER)
# ---------------------------------------------------------------------------
def _simulate_rn_dup(rows: list[dict]) -> list[dict]:
"""Эмулирует SQL-окно rn_dup=1: per (source, key) freshest scraped_at.
Зеркалит SQL: ключ = source + namespaced(id:source_id | url:source_url |
ctid:ctid). NULL source_id+source_url → разные ctid → разные группы (обе живут).
"""
groups: dict[tuple, dict] = {}
for r in rows:
if r.get("source_id") is not None:
key = (r["source"], "id", r["source_id"])
elif r.get("source_url") is not None:
key = (r["source"], "url", r["source_url"])
else:
key = (r["source"], "ctid", r["ctid"])
cur = groups.get(key)
if cur is None or r["scraped_at"] > cur["scraped_at"]:
groups[key] = r
return list(groups.values())
def test_same_source_source_id_collapses_to_freshest() -> None:
rows = [
{
"source": "yandex",
"source_id": "A1",
"source_url": None,
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 150_000,
},
{
"source": "yandex",
"source_id": "A1",
"source_url": None,
"ctid": "(0,2)",
"scraped_at": 200,
"price_per_m2": 151_000,
},
]
out = _simulate_rn_dup(rows)
assert len(out) == 1
assert out[0]["scraped_at"] == 200, "должна выжить freshest (scraped_at=200)"
def test_different_source_id_both_survive() -> None:
rows = [
{
"source": "cian",
"source_id": "X",
"source_url": None,
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 140_000,
},
{
"source": "cian",
"source_id": "Y",
"source_url": None,
"ctid": "(0,2)",
"scraped_at": 100,
"price_per_m2": 145_000,
},
]
out = _simulate_rn_dup(rows)
assert len(out) == 2
def test_same_source_id_different_source_both_survive() -> None:
"""(source, source_id) — PARTITION включает source: одинаковый id у разных
источников НЕ схлопывается."""
rows = [
{
"source": "cian",
"source_id": "Z",
"source_url": None,
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 140_000,
},
{
"source": "yandex",
"source_id": "Z",
"source_url": None,
"ctid": "(0,2)",
"scraped_at": 100,
"price_per_m2": 145_000,
},
]
out = _simulate_rn_dup(rows)
assert len(out) == 2
def test_null_source_id_null_url_both_survive_via_ctid() -> None:
"""CASE-guard: 2 строки с NULL source_id И NULL source_url → ctid-tiebreak →
обе сохранены (не схлопываются по NULL)."""
rows = [
{
"source": "rosreestr",
"source_id": None,
"source_url": None,
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 130_000,
},
{
"source": "rosreestr",
"source_id": None,
"source_url": None,
"ctid": "(0,2)",
"scraped_at": 100,
"price_per_m2": 132_000,
},
]
out = _simulate_rn_dup(rows)
assert len(out) == 2
def test_null_source_id_falls_back_to_source_url() -> None:
"""source_id NULL, но одинаковый source_url → схлопываются (url-ключ)."""
rows = [
{
"source": "avito",
"source_id": None,
"source_url": "http://a/1",
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 120_000,
},
{
"source": "avito",
"source_id": None,
"source_url": "http://a/1",
"ctid": "(0,2)",
"scraped_at": 300,
"price_per_m2": 121_000,
},
]
out = _simulate_rn_dup(rows)
assert len(out) == 1
assert out[0]["scraped_at"] == 300
# ---------------------------------------------------------------------------
# n_analogs отражает дедупнутое число (Python-симуляция)
# ---------------------------------------------------------------------------
def test_n_analogs_reflects_deduped_count() -> None:
"""3 строки одного объекта (source_id) схлопываются в 1 → n_analogs=1.
Прямое требование спека: «n_analogs отражает дедупнутое число» — без дедупа
3 дубля раздули бы счётчик, с дедупом выживает 1 (freshest).
Отдельные объекты (разные source_id) не схлопываются → n_analogs=3.
"""
# 3 дубля одного объекта (один source_id, разные scraped_at)
dupes = [
{
"source": "yandex",
"source_id": "Y1",
"source_url": None,
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 150_000,
},
{
"source": "yandex",
"source_id": "Y1",
"source_url": None,
"ctid": "(0,2)",
"scraped_at": 200,
"price_per_m2": 151_000,
},
{
"source": "yandex",
"source_id": "Y1",
"source_url": None,
"ctid": "(0,3)",
"scraped_at": 300,
"price_per_m2": 152_000,
},
]
out = _simulate_rn_dup(dupes)
n_analogs = len(out)
assert n_analogs == 1, "3 дубля → должен выжить 1"
assert out[0]["scraped_at"] == 300, "выживает freshest"
assert out[0]["price_per_m2"] == 152_000
# 3 разных объекта — не схлопываются
distinct = [
{
"source": "yandex",
"source_id": "Y1",
"source_url": None,
"ctid": "(0,1)",
"scraped_at": 100,
"price_per_m2": 150_000,
},
{
"source": "cian",
"source_id": "C1",
"source_url": None,
"ctid": "(0,2)",
"scraped_at": 100,
"price_per_m2": 145_000,
},
{
"source": "avito",
"source_id": None,
"source_url": "http://a/1",
"ctid": "(0,3)",
"scraped_at": 100,
"price_per_m2": 155_000,
},
]
out_d = _simulate_rn_dup(distinct)
assert len(out_d) == 3, "3 разных объекта → все 3 живут"
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))