gendesign/tradein-mvp/backend/tests/test_same_building_match.py
bot-backend deeff7ed1f fix(tradein/estimator): Tier S radius-fallback house-number boundary + distance_m
Audit finding 1 (medium): the Tier S same-building radius-fallback matched
`address ILIKE short_addr + '%'` with no boundary after the house number, so
"ул. Ленина, 5%" collided with "..., 50/51/500/5а" (a different building).
MAX_ANALOGS_PER_ADDRESS=5 + `len(tier_s) >= 3` meant one stray building could
poison the same-building median. Fix reuses the already-vetted anchor Tier A
machinery (_normalize_building_key + new shared _house_boundary_regex helper)
instead of a bare string prefix, and replaces the hardcoded `0.0 AS distance_m`
with a real ST_Distance so anomalously-far "same building" matches are visible
in the data.

_extract_short_addr is left in place (unused by _fetch_analogs now, own tests
still pass) with a note explaining the supersession, rather than deleted, to
keep this PR scoped to the boundary bug.

Audit finding 2 (low): conflict_resolution.py's resolve_house_field/
resolve_listing_field priority system is confirmed dead in the production
merge path (matching/houses.py + listings.py use a simpler COALESCE
newest-wins pattern instead) per vault Decision_774_Matching_Architecture
(2026-05-31, code-archaeology + live-DB verified). Documented this in the
module docstring instead of deleting — removal + its ~30 dedicated tests is
already scoped as an independent "Path 2 / Sub-3" cleanup in that decision,
kept separate from this unrelated Tier-S bugfix. Not wired into the price-calc
path per audit instructions.

Tests: new tests/test_estimator_tier_s_house_boundary.py (boundary regex +
SQL-rendering checks); test_same_building_match.py updated to assert the new
boundary-regex SQL shape instead of the old bare ILIKE. Full suite: 2632
passed, 8 skipped, 1 deselected (same deselect as ci-tradein.yml).
2026-07-26 23:42:36 +03:00

212 lines
8.4 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_fias_exact_hit():
"""Tier 0.5: house_fias_id match → fias_exact (conf 0.95), no cad supplied."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [{"id": 55}]
result = match_house_readonly(
db,
address="ул Малышева 125",
house_fias_id="0a1b2c3d-0000-4000-8000-000000000001",
)
assert result == (55, 0.95, "fias_exact")
def test_readonly_fias_skipped_when_absent():
"""No house_fias_id → fias tier skipped; first .first() is fingerprint tier."""
db = MagicMock()
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_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_geo_rejected_when_house_number_differs():
"""P2: geo candidate within 50 m but with a DIFFERENT house number is rejected.
Target 'Малышева 125' must not resolve to a nearby '... 200' on a coarse geocode.
fingerprint miss → geo hit (wrong number) → reject → None.
"""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.side_effect = [
None,
{"id": 9, "dist": 12.3, "h_addr": "улица малышева 200"},
]
result = match_house_readonly(db, address="ул Малышева 125", lat=56.84, lon=60.65)
assert result is None
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 пропущен — первый запрос по
# normalized street + numeric-boundary house regex (audit fix: raw ILIKE
# prefix replaced — see estimator.py _normalize_building_key/_house_boundary_regex).
first_sql = _executed_sqls(db)[0]
assert "house_id_fk" not in first_sql
assert ":house_re" in first_sql
assert ":street_like" in first_sql
assert "ILIKE" not 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"