gendesign/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py
Light1YT 1c6d59e3ee feat(tradein): backfill deals.lat/lon из house street-центроидов (#569)
deals (49,791 ДКП-сделок Росреестра) на 100% NULL geom → estimator
_fetch_deals (ST_DWithin) никогда не матчит, actual_deals всегда пустой
(корень #571 «deals disabled»). Добавляет scripts/geocode_deals_from_houses.py:
строит per-street центроиды из ~8.6k геокодированных houses и проставляет
lat/lon каждой сделке по нормализованному ключу улицы (geom авто-заполняется
триггером deals_set_geom_trg). Zero внешних geocoder-вызовов; --dry-run
проецирует coverage и топ-10 unmatched улиц.

Pre-push review поймал data-corruption баг: _APT_SUFFIX_RE без правого якоря
жрал реальные улицы на кв/корп/оф/пом/стр («Строителей»→'ул.' garbage key →
неверный центроид). Fix: требовать цифру после суффикс-токена + регресс-тест.

Tests: 26 (street-key нормализация вкл. regression, centroid AVG, matched UPDATE,
no_street_match, --dry-run no-op, SAVEPOINT isolation, geom-not-manual). Ruff clean.
2026-05-28 14:25:26 +05:00

440 lines
18 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.

"""Unit tests for issue #569 Step 2 — geocode_deals_from_houses.py.
Coverage:
- _street_key normalization: the three spec examples collapse to one key,
plus boundary cases (numeric streets '8 марта', names that start with a
type-token substring like 'алмазная', district/apt/region stripping).
- _build_centroid_map: AVG(lat)/AVG(lon) over multiple houses on one street.
- _run_backfill happy path: a deal whose street matches → UPDATE with the
centroid lat/lon, geocoded counter bumps.
- _run_backfill miss: a deal whose street has no centroid → no UPDATE,
counted as no_street_match + tracked for the dry-run report.
- --dry-run: no UPDATE / no commit, counters still move.
- per-row SAVEPOINT isolates a failing UPDATE.
- main() wires SessionLocal, respects --limit, and returns geocoded count.
No real Postgres. Session is a MagicMock that routes SELECT side-effects by
SQL substring and records UPDATE binds (same pattern as
test_backfill_houses_dadata.py).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
# Settings needs a DSN at import time — set a dummy before any app.* import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scripts.geocode_deals_from_houses import (
Centroid,
DealRow,
Stats,
_build_centroid_map,
_run_backfill,
_street_key,
_update_deal_coords,
main,
)
# ---------------------------------------------------------------------------
# Mock-DB helper
# ---------------------------------------------------------------------------
def _make_db_mock(
*,
house_rows: list[dict] | None = None,
deal_rows: list[dict] | None = None,
null_count: int = 0,
) -> tuple[MagicMock, list[dict]]:
"""MagicMock Session that:
- returns `house_rows` for the centroid SELECT (`FROM houses`)
- returns `deal_rows` for the candidate SELECT (`FROM deals ... lat IS NULL`)
- returns `null_count` for the `count(*)` SELECT
- records UPDATE binds into the returned `updated` list
- supports `db.begin_nested()` as a context manager
"""
house_rows = house_rows or []
deal_rows = deal_rows or []
updated: list[dict] = []
db = MagicMock()
db.begin_nested.return_value.__enter__ = lambda self: self
db.begin_nested.return_value.__exit__ = lambda self, *a: False
def execute_side_effect(sql, params=None):
sql_str = str(sql)
result = MagicMock()
if "UPDATE deals" in sql_str:
updated.append(dict(params) if params else {})
return result
if "count(*)" in sql_str:
result.first.return_value = (null_count,)
return result
if "FROM houses" in sql_str:
result.mappings.return_value.all.return_value = house_rows
return result
if "FROM deals" in sql_str:
lim = params.get("lim", len(deal_rows)) if params else len(deal_rows)
result.mappings.return_value.all.return_value = deal_rows[:lim]
return result
return result
db.execute.side_effect = execute_side_effect
db.commit = MagicMock()
db.rollback = MagicMock()
db.close = MagicMock()
return db, updated
# ---------------------------------------------------------------------------
# _street_key — normalization (the crux)
# ---------------------------------------------------------------------------
def test_street_key_three_spec_variants_collapse_to_same_key():
"""The spec's three Малышева variants must produce one identical key."""
a = _street_key("Екатеринбург, ул. Малышева, 125")
b = _street_key("г Екатеринбург, улица Малышева")
c = _street_key("Екатеринбург, Малышева")
assert a == b == c == "малышева"
def test_street_key_strips_region_and_house_number():
assert (
_street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17")
== "большакова"
)
def test_street_key_strips_district_marker():
assert _street_key("улица Яскина, 12 · р-н Октябрьский") == "яскина"
def test_street_key_handles_prospekt():
assert _street_key("г. Екатеринбург, проспект Ленина, 50") == "ленина"
def test_street_key_preserves_numeric_street_name():
"""'8 марта' is a real street — the leading number must survive while the
trailing house number is stripped."""
assert _street_key("Екатеринбург, ул. 8 Марта, 100") == "8 марта"
assert _street_key("Екатеринбург, 8 Марта") == "8 марта"
def test_street_key_preserves_multiword_numeric_street():
assert _street_key("Екатеринбург, ул. 40 лет Октября") == "40 лет октября"
def test_street_key_does_not_eat_name_starting_like_type_token():
"""'алмазная' must NOT lose 'ал', 'пришвина' must NOT lose 'пр' — the
street-type strip is \\b-bounded."""
assert _street_key("ул. Алмазная, 7") == "алмазная"
assert _street_key("ул. Пришвина, 3") == "пришвина"
def test_street_key_strips_korpus_and_kv_suffix():
assert _street_key("Екатеринбург, ул. Крауля, 48, корп. 2") == "крауля"
assert (
_street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12")
== "ленина"
)
def test_street_key_does_not_eat_names_starting_like_apt_suffix():
"""Regression (pre-push review): un-anchored _APT_SUFFIX_RE collapsed real
streets starting with кв/корп/оф/пом/стр («Строителей», «Офицеров»,
«Корпусная», «Помолова») to the garbage key 'ул.' → wrong centroid → wrong
coords. Suffix strip now requires a DIGIT after the token."""
assert _street_key("Екатеринбург, ул. Строителей, 5") == "строителей"
assert _street_key("Екатеринбург, ул. Офицеров") == "офицеров"
assert _street_key("Екатеринбург, ул. Корпусная, 14") == "корпусная"
assert _street_key("Екатеринбург, ул. Помолова, 3") == "помолова"
# deals-form (no house number) must still resolve to the street, not 'ул.'
assert _street_key("Екатеринбург, Стрелочников") == "стрелочников"
def test_street_key_normalizes_yo_to_e():
"""Рабочей Молодёжи (ё) and Молодежи (е) must collapse — sources differ."""
assert _street_key("Екатеринбург, наб. Рабочей Молодёжи, 2") == "рабочей молодежи"
assert _street_key("Екатеринбург, Рабочей Молодежи") == "рабочей молодежи"
def test_street_key_house_prefix_dom():
assert _street_key("Екатеринбург, пр. Ленина, д. 5") == "ленина"
def test_street_key_none_and_empty_and_city_only():
assert _street_key(None) == ""
assert _street_key("") == ""
# City alone has no street segment → empty key (caller treats as no-match).
assert _street_key("Екатеринбург") == ""
# ---------------------------------------------------------------------------
# _build_centroid_map — AVG over multiple houses on a street
# ---------------------------------------------------------------------------
def test_build_centroid_map_averages_houses_on_one_street():
"""Two houses on Малышева → centroid is the mean of their coords."""
house_rows = [
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70},
]
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"}
c = centroids["малышева"]
assert c.lat == pytest.approx(56.85)
assert c.lon == pytest.approx(60.60)
assert c.house_count == 2
def test_build_centroid_map_groups_distinct_streets():
house_rows = [
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
{"address": "г Екатеринбург, Малышева", "lat": 56.82, "lon": 60.54},
{"address": "Екатеринбург, проспект Ленина, 5", "lat": 56.84, "lon": 60.60},
]
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева", "ленина"}
assert centroids["малышева"].house_count == 2
assert centroids["ленина"].house_count == 1
# Малышева centroid = mean of the two Малышева rows.
assert centroids["малышева"].lat == pytest.approx(56.81)
assert centroids["ленина"].lat == pytest.approx(56.84)
def test_build_centroid_map_skips_unparseable_address():
"""A house whose address yields too-short a key is dropped, not crashed."""
house_rows = [
{"address": "Екатеринбург", "lat": 56.8, "lon": 60.6}, # city only → ''
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.84, "lon": 60.6},
]
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"}
# ---------------------------------------------------------------------------
# _run_backfill — happy path: street match → UPDATE with centroid coords
# ---------------------------------------------------------------------------
def test_run_backfill_matched_street_issues_update_with_centroid():
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
assert stats.geocoded == 1
assert stats.no_street_match == 0
assert stats.failed == 0
assert stats.processed == 1
assert len(updated) == 1
assert updated[0]["id"] == 42
assert updated[0]["lat"] == 56.838
assert updated[0]["lon"] == 60.586
assert db.commit.call_count == 1
def test_run_backfill_deal_with_only_street_name_matches():
"""Deal address with no house number / no type word still matches."""
deal = DealRow(id=7, address="Екатеринбург, Малышева")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
assert stats.geocoded == 1
assert len(updated) == 1
assert updated[0]["lat"] == 56.8
# ---------------------------------------------------------------------------
# _run_backfill — miss: no centroid → no UPDATE, counted as no_street_match
# ---------------------------------------------------------------------------
def test_run_backfill_unmatched_street_no_update():
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
assert stats.geocoded == 0
assert stats.no_street_match == 1
assert stats.processed == 1
assert updated == []
assert db.commit.call_count == 0
# Unmatched street tracked for the dry-run report.
assert stats.unmatched_streets["несуществующая"] == 1
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
"""A deal whose address yields an empty key is a no-match under a sentinel."""
deal = DealRow(id=5, address="Екатеринбург")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
assert stats.no_street_match == 1
assert updated == []
assert stats.unmatched_streets["<no-street-parsed>"] == 1
# ---------------------------------------------------------------------------
# --dry-run — no DB writes
# ---------------------------------------------------------------------------
def test_run_backfill_dry_run_issues_no_update():
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
assert stats.geocoded == 1
assert stats.processed == 1
assert updated == [] # no UPDATE
assert db.commit.call_count == 0 # no commit
# ---------------------------------------------------------------------------
# per-row SAVEPOINT — one bad UPDATE doesn't abort the batch
# ---------------------------------------------------------------------------
def test_run_backfill_db_write_failure_isolated_to_row():
deals = [
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
]
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
# Make the FIRST UPDATE raise, the rest succeed.
call = {"n": 0}
real_side_effect = db.execute.side_effect
def failing_execute(sql, params=None):
if "UPDATE deals" in str(sql):
call["n"] += 1
if call["n"] == 1:
raise RuntimeError("constraint blew up")
return real_side_effect(sql, params)
db.execute.side_effect = failing_execute
stats = _run_backfill(db, deals, centroids, batch="b", dry_run=False)
assert stats.failed == 1
assert stats.geocoded == 1
assert db.rollback.call_count == 1
# Only the second deal's UPDATE was recorded.
assert [u["id"] for u in updated] == [11]
# ---------------------------------------------------------------------------
# _update_deal_coords — bind shape + geom NOT set manually
# ---------------------------------------------------------------------------
def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
db = MagicMock()
_update_deal_coords(db, deal_id=3, lat=56.1, lon=60.2)
args, _kw = db.execute.call_args
sql_str = str(args[0])
binds = args[1]
assert "UPDATE deals" in sql_str
assert "geocode_tried_at = NOW()" in sql_str
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
assert "geom" not in sql_str
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2}
# ---------------------------------------------------------------------------
# main() — wiring, --limit, return value
# ---------------------------------------------------------------------------
def test_main_respects_limit_and_returns_geocoded():
house_rows = [
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70},
]
deal_rows = [
{"id": 1, "address": "Екатеринбург, Малышева"},
{"id": 2, "address": "Екатеринбург, ул. Малышева, 9"},
{"id": 3, "address": "Екатеринбург, ул. Малышева, 99"},
]
db, updated = _make_db_mock(house_rows=house_rows, deal_rows=deal_rows)
with patch("scripts.geocode_deals_from_houses.SessionLocal", return_value=db):
n = main(["--limit", "1", "--batch", "test_limit"])
assert n == 1
assert len(updated) == 1
assert updated[0]["id"] == 1
# Centroid used = mean of the two house rows.
assert updated[0]["lat"] == pytest.approx(56.85)
assert updated[0]["lon"] == pytest.approx(60.60)
def test_main_dry_run_writes_nothing():
house_rows = [
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
]
deal_rows = [
{"id": 1, "address": "Екатеринбург, Малышева"},
{"id": 2, "address": "Екатеринбург, ул. Неизвестная, 5"},
]
db, updated = _make_db_mock(house_rows=house_rows, deal_rows=deal_rows, null_count=2)
with patch("scripts.geocode_deals_from_houses.SessionLocal", return_value=db):
n = main(["--dry-run"])
# dry-run returns geocoded count (would-match), writes nothing.
assert n == 1
assert updated == []
assert db.commit.call_count == 0
def test_main_no_centroids_returns_zero():
"""Empty houses table → nothing to join against → graceful 0."""
db, updated = _make_db_mock(house_rows=[], deal_rows=[{"id": 1, "address": "x"}])
with patch("scripts.geocode_deals_from_houses.SessionLocal", return_value=db):
n = main([])
assert n == 0
assert updated == []
# ---------------------------------------------------------------------------
# Stats dataclass
# ---------------------------------------------------------------------------
def test_stats_unmatched_streets_counter_defaults_empty():
s = Stats()
assert s.processed == 0
assert s.geocoded == 0
assert s.no_street_match == 0
assert s.failed == 0
assert s.unmatched_streets.most_common(3) == []