"""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[""] == 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) == []