gendesign/tradein-mvp/backend/tests/test_same_building_match.py
Light1YT 30a80ee608 feat(tradein): same-building матчинг аналогов по house_id (#6)
Estimator теперь резолвит target-адрес в КАНОНИЧЕСКИЙ house_id (read-only,
без создания записи) и матчит аналоги по listings.house_id_fk — детерминированный
Tier S «тот же дом» вместо хрупкого address-string prefix match. 99% листингов
слинкованы с houses, поэтому это срабатывает уже сегодня и точнее работает между
разнородными источниками (Avito/Cian/Yandex форматируют адрес по-разному).

- matching/houses.py: match_house_readonly() — read-only target→house резолвер
  (cadastr_exact → fingerprint → geo_proximity 50m), без INSERT / house_sources /
  advisory lock. Возвращает None если уверенного match нет.
- estimator.estimate_quality: после geocode+DaData резолвит target_house_id
  (cadastr от DaData → cadastr_exact заработает по мере backfill houses), graceful.
- estimator._fetch_analogs: новый param target_house_id + канонический Tier S
  (WHERE house_id_fk = target) ПЕРЕД address-prefix Tier S (теперь fallback).
  tier label 'S' сохранён (frontend уже рендерит).

Эффект: вместо «похожие в радиусе 800-1000м» — «вот что в ЭТОМ доме», прямой
выигрыш в точности оценки. Foundation под cadastr/FIAS exact match (DaData Q1/Q3).

Tests: 9 новых (match_house_readonly tiers + never-inserts + canonical Tier S
fires/fallback/too-few). Ruff clean. Pre-existing 4 fail в test_estimator_source_quota
(unpack 2≠3, красные на main до этого PR) — не затронуты.
2026-05-28 13:55:51 +05:00

153 lines
6.6 KiB
Python

"""Tests for #6 same-building analog matching via canonical house_id.
Two pieces:
1. matching.houses.match_house_readonly — read-only target→house resolver
(cadastr → fingerprint → geo, никогда не INSERT'ит).
2. estimator._fetch_analogs canonical Tier S — when target_house_id задан,
матчит listings по house_id_fk; иначе fallback на address-prefix Tier S.
"""
import os
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock
from app.services.estimator import _fetch_analogs
from app.services.matching.houses import match_house_readonly
# ── Helpers ───────────────────────────────────────────────────────────────────
def _listing(**over: Any) -> dict[str, Any]:
base = {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "Екатеринбург, ул Малышева, д 125",
"lat": 56.8408,
"lon": 60.6540,
"rooms": 2,
"area_m2": 50.0,
"floor": 3,
"total_floors": 9,
"price_rub": 6_000_000.0,
"price_per_m2": 120_000.0,
"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,
}
base.update(over)
return base
def _db_returning(rows: list[dict[str, Any]]) -> MagicMock:
"""Session mock where every db.execute().mappings().all() returns rows."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
return db
def _executed_sqls(db: MagicMock) -> list[str]:
return [str(call.args[0]) for call in db.execute.call_args_list if call.args]
# ── match_house_readonly ────────────────────────────────────────────────────
def test_readonly_cadastr_exact_hit():
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [{"id": 42}]
result = match_house_readonly(
db, address="ул Малышева 125", cadastral_number="66:41:0704045:350"
)
assert result == (42, 1.0, "cadastr_exact")
def test_readonly_fingerprint_hit():
db = MagicMock()
# cadastral_number=None → cadastr tier skipped; first .first() is fingerprint
db.execute.return_value.mappings.return_value.first.side_effect = [{"house_id": 7}]
result = match_house_readonly(db, address="Екатеринбург, ул Малышева, д 125")
assert result == (7, 0.9, "fingerprint")
def test_readonly_geo_proximity_hit():
db = MagicMock()
# fingerprint miss → geo hit
db.execute.return_value.mappings.return_value.first.side_effect = [
None, {"id": 9, "dist": 12.3}
]
result = match_house_readonly(db, address="неизвестный адрес", lat=56.84, lon=60.65)
assert result == (9, 0.7, "geo_proximity")
def test_readonly_no_match_returns_none():
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [None, None]
result = match_house_readonly(db, address="нигде", lat=56.84, lon=60.65)
assert result is None
def test_readonly_never_inserts_or_writes():
"""Критично: read-only matcher не должен ничего создавать/писать."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [None, None]
match_house_readonly(db, address="нигде", lat=56.84, lon=60.65)
for sql in _executed_sqls(db):
upper = sql.upper()
assert "INSERT" not in upper
assert "UPDATE" not in upper
assert "ADVISORY" not in upper # no pg_advisory_xact_lock
def test_readonly_no_coords_skips_geo_tier():
"""Без lat/lon geo-tier не запускается; при fingerprint-miss → None."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [None]
result = match_house_readonly(db, address="только адрес без координат")
assert result is None
# ── _fetch_analogs canonical Tier S ──────────────────────────────────────────
def test_canonical_tier_s_fires_with_house_id():
rows = [_listing(source=s) for s in ("cian", "avito", "yandex", "n1")]
db = _db_returning(rows)
listings, _fallback, tier = _fetch_analogs(
db, lat=56.84, lon=60.65, rooms=2, area=50.0, radius_m=1000,
full_address="Екатеринбург, ул Малышева, д 125", target_house_id=555,
)
assert tier == "S"
assert len(listings) >= 3
# Канонический Tier S — первый запрос должен матчить по house_id_fk
assert "house_id_fk" in _executed_sqls(db)[0]
def test_falls_back_to_address_tier_s_without_house_id():
rows = [_listing(source=s) for s in ("cian", "avito", "yandex", "n1")]
db = _db_returning(rows)
_listings, _fallback, tier = _fetch_analogs(
db, lat=56.84, lon=60.65, rooms=2, area=50.0, radius_m=1000,
full_address="Екатеринбург, ул Малышева, д 125", target_house_id=None,
)
assert tier == "S"
# Без target_house_id канонический tier пропущен — первый запрос по address ILIKE
first_sql = _executed_sqls(db)[0]
assert "house_id_fk" not in first_sql
assert "ILIKE" in first_sql
def test_canonical_tier_s_too_few_falls_through():
"""<3 в том же доме → не возвращаем Tier S(canonical), идём дальше."""
rows = [_listing()] # только 1 в доме
db = _db_returning(rows)
# full_address без распознаваемого short_addr И year/floors None →
# address-Tier S и Tier H пропустятся, упадём в Tier W (тоже вернёт rows mock).
_listings, _fallback, tier = _fetch_analogs(
db, lat=56.84, lon=60.65, rooms=2, area=50.0, radius_m=1000,
full_address=None, target_house_id=555,
)
# canonical Tier S не сработал (1 < 3); tier — не S(canonical) early-return.
# Mock возвращает те же rows для Tier W, так что итог не пустой, tier='W'.
assert tier == "W"