"""Тесты для objective_backfill.py (#203). Использует mock DB (unittest.mock) — не требует реального PostgreSQL. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch from app.services.etl.objective_backfill import ( AUTO_ACCEPT_THRESHOLD, CORE_MATCH_METHOD, CORE_MATCH_SCORE, GEO_DADATA_CALL_LIMIT, GEO_MATCH_METHOD, GEO_MATCH_SCORE, REVIEW_THRESHOLD, CoreMatchReport, GeoMatchReport, MatchCandidate, _dev_confirms, _haversine_m, _norm_dev, apply_core_matches, apply_geo_matches, auto_apply_matches, find_core_matches, find_geo_matches, find_match_candidates, trigger_mv_refresh, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _make_candidate( obj_id: int, comm_name: str, project_name: str, score: float, dev_name: str | None = "ООО Девелопер", ) -> MatchCandidate: return MatchCandidate( domrf_obj_id=obj_id, domrf_comm_name=comm_name, domrf_dev_name=dev_name, objective_project_name=project_name, similarity_score=score, ) def _make_db_row(obj_id: int, comm_name: str, dev_name: str, project: str, score: float) -> Any: """Имитирует Row, возвращаемый SQLAlchemy db.execute().all().""" return (obj_id, comm_name, dev_name, project, score) # ── Test 1: find_match_candidates возвращает правильные MatchCandidate ──────── def test_find_match_candidates_returns_candidates() -> None: """find_match_candidates корректно преобразует DB rows в MatchCandidate.""" mock_rows = [ _make_db_row(100, "Северный квартал", "Брусника", "Северный квартал", 1.0), _make_db_row(200, "АЛЛЕГРО", "СЗ ГОРЖИЛСТРОЙ", "АЛЛЕГРО", 0.95), _make_db_row(300, "Некий ЖК", None, "Близкий ЖК", 0.72), ] mock_execute = MagicMock() mock_execute.all.return_value = mock_rows mock_db = MagicMock() mock_db.execute.return_value = mock_execute candidates = find_match_candidates(mock_db, only_unmapped=True) assert len(candidates) == 3 assert candidates[0].domrf_obj_id == 100 assert candidates[0].similarity_score == 1.0 assert candidates[0].domrf_dev_name == "Брусника" assert candidates[2].domrf_dev_name is None # NULL dev_name → None assert candidates[2].similarity_score == 0.72 # Убедимся, что db.execute вызывался с параметрами mock_db.execute.assert_called_once() call_kwargs = mock_db.execute.call_args[0][1] assert call_kwargs["only_unmapped"] is True assert call_kwargs["min_threshold"] == REVIEW_THRESHOLD # ── Test 2: auto_apply_matches dry_run не вставляет ────────────────────────── def test_auto_apply_matches_dry_run_no_inserts() -> None: """dry_run=True возвращает projected-счётчики без обращения к БД. auto_accepted = сколько кандидатов БЫЛО БЫ принято (preview), а не 0 — смысл dry-run в admin-endpoint'е именно показать оператору объём перед реальным insert. execute/commit при этом не вызываются. """ mock_db = MagicMock() candidates = [ _make_candidate(1, "ЖК А", "ЖК А", 0.95), # auto-accept _make_candidate(2, "ЖК Б", "ЖК Б", 0.70), # review queue _make_candidate(3, "ЖК В", "ЖК В", 0.65), # review queue ] result = auto_apply_matches(mock_db, candidates, dry_run=True) assert result["auto_accepted"] == 1 # projected: 1 кандидат >= AUTO_ACCEPT_THRESHOLD assert result["review_queue"] == 2 assert result["skipped"] == 0 mock_db.execute.assert_not_called() mock_db.commit.assert_not_called() # ── Test 3: auto_apply_matches high-score inserts, low-score → review_queue ── def test_auto_apply_matches_inserts_high_score_only() -> None: """Только кандидаты >= AUTO_ACCEPT_THRESHOLD вставляются в БД.""" # Мокируем begin_nested как context manager savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) mock_execute_result = MagicMock() mock_execute_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = mock_execute_result high_score = AUTO_ACCEPT_THRESHOLD # ровно на пороге — должен вставляться low_score = REVIEW_THRESHOLD # ровно REVIEW_THRESHOLD — не auto-accept candidates = [ _make_candidate(10, "ЖК Альфа", "ЖК Альфа", high_score), _make_candidate(11, "ЖК Бета", "ЖК Бета", high_score + 0.05), _make_candidate(20, "ЖК Гамма", "ЖК Гамма", low_score), # review только ] result = auto_apply_matches(mock_db, candidates, threshold=AUTO_ACCEPT_THRESHOLD) assert result["auto_accepted"] == 2 assert result["review_queue"] == 1 assert mock_db.execute.call_count == 2 # вызывался только для high-score mock_db.commit.assert_called_once() # ── Test 4: trigger_mv_refresh делегирует в layout_velocity_refresh ────────── def test_trigger_mv_refresh_calls_helper() -> None: """trigger_mv_refresh вызывает refresh_layout_velocity с concurrently=True. refresh_layout_velocity импортируется лениво внутри trigger_mv_refresh, поэтому патчим по полному пути модуля-источника. """ mock_db = MagicMock() with patch( "app.services.site_finder.layout_velocity_refresh.refresh_layout_velocity", return_value=512, ) as mock_refresh: count = trigger_mv_refresh(mock_db) assert count == 512 mock_refresh.assert_called_once_with(mock_db, concurrently=True) # ── Test 5: ON CONFLICT — rowcount=0 считается как skipped ─────────────────── def test_auto_apply_matches_conflict_counted_as_skipped() -> None: """Если INSERT вернул rowcount=0 (ON CONFLICT DO NOTHING), считается skipped.""" savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) mock_execute_result = MagicMock() mock_execute_result.rowcount = 0 # ON CONFLICT DO NOTHING mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = mock_execute_result candidates = [ _make_candidate(99, "Уже существующий ЖК", "Уже существующий ЖК", 0.99), ] result = auto_apply_matches(mock_db, candidates) assert result["auto_accepted"] == 0 assert result["skipped"] == 1 assert result["review_queue"] == 0 # ═════════════════════════════════════════════════════════════════════════════ # Core-pass (#2177 шаг 2) — auto_core_dev_v5 # ═════════════════════════════════════════════════════════════════════════════ def _make_core_db( domrf_rows: list[tuple[int, str, str | None]], objective_rows: list[tuple[str, list[str] | None, int | None]], taken_names: list[str], ) -> MagicMock: """Mock Session где 3 подряд db.execute().all() отдают objective-проекты, taken-имена, domrf-объекты (в порядке find_core_matches). find_core_matches вызывает execute в фиксированном порядке: 1) _OBJECTIVE_PROJECTS_SQL, 2) _TAKEN_NAMES_SQL, 3) _DOMRF_UNMAPPED_SQL. """ obj_result = MagicMock() obj_result.all.return_value = objective_rows taken_result = MagicMock() taken_result.all.return_value = [(n,) for n in taken_names] domrf_result = MagicMock() domrf_result.all.return_value = domrf_rows mock_db = MagicMock() mock_db.execute.side_effect = [obj_result, taken_result, domrf_result] return mock_db # ── _norm_dev / _dev_confirms ──────────────────────────────────────────────── def test_norm_dev_strips_org_form() -> None: """_norm_dev срезает орг-форму, кавычки, «СЗ», схлопывает пробелы.""" assert _norm_dev('ООО "СЗ "Малевич"') == "малевич" assert _norm_dev("PRINZIP") == "prinzip" assert _norm_dev("ООО Брозэкс") == "брозэкс" assert _norm_dev("ООО Специализированный застройщик Арсенал") == "арсенал" assert _norm_dev(None) == "" assert _norm_dev(" ") == "" def test_dev_confirms_matches_any_spv() -> None: """dev-подтверждение по ЛЮБОМУ из нескольких objective developers (SPV).""" # SPV «СЗ Малевич» подтверждает, хотя PRINZIP — нет. assert _dev_confirms('ООО "СЗ "Малевич"', {"PRINZIP", 'СЗ "Малевич"'}) is True assert _dev_confirms('ООО "СЗ "Малевич"', {"PRINZIP"}) is False # разные застройщики — не подтверждает assert _dev_confirms("ООО СЗ Арсенал", {"Брозэкс"}) is False # пустой domrf-dev — нет сигнала assert _dev_confirms("", {"Брозэкс"}) is False def test_dev_confirms_short_substring_guard() -> None: """Короткий (<4 симв.) корень не подтверждает подстрокой чужое длинное имя. Ревью 2026-07-03: «ООО Дом»→«дом» как подстрока «Домострой» — ложное подтверждение; dev-confirm — последняя защита от generic-core коллизий. """ assert _dev_confirms("ООО Дом", {"Домострой"}) is False assert _dev_confirms("ООО СЗ Юг", {"Югстройинвест"}) is False # ≥4 символов подстрокой — по-прежнему подтверждает assert _dev_confirms("Брусника", {"Брусника-Урал"}) is True # ── find_core_matches: классификация tier_a / tier_b / ambiguous / skip ────── def test_find_core_matches_tier_a_dev_confirmed() -> None: """Малевич: 1 кандидат + SPV-подтверждение → tier_a.""" mock_db = _make_core_db( domrf_rows=[(101, 'Жилой комплекс "Малевич" (ЖК "Малевич")', 'ООО "СЗ "Малевич"')], objective_rows=[("Малевич", ["PRINZIP", 'СЗ "Малевич"'], 7001)], taken_names=[], ) report = find_core_matches(mock_db) assert len(report.tier_a) == 1 assert report.tier_a[0].domrf_obj_id == 101 assert report.tier_a[0].objective_project_name == "Малевич" assert report.tier_a[0].objective_project_id == 7001 assert report.tier_a[0].dev_confirmed is True assert report.tier_b == [] assert report.ambiguous == [] def test_find_core_matches_tier_b_dev_not_confirmed() -> None: """Арсенал↔Брозэкс: 1 кандидат, dev НЕ совпал → tier_b (не в БД).""" mock_db = _make_core_db( domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], ) report = find_core_matches(mock_db) assert report.tier_a == [] assert len(report.tier_b) == 1 assert report.tier_b[0].domrf_obj_id == 202 assert report.tier_b[0].dev_confirmed is False def test_find_core_matches_ambiguous_two_candidates() -> None: """Меридиан: 2 objective project_name делят одно ядро → ambiguous.""" mock_db = _make_core_db( domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], objective_rows=[ ("Меридиан", ["Девелопер"], 7003), ("Меридиан ", ["Другой"], 7004), # то же ядро «меридиан» ], taken_names=[], ) report = find_core_matches(mock_db) assert report.tier_a == [] assert report.tier_b == [] assert len(report.ambiguous) == 1 assert report.ambiguous[0].domrf_obj_id == 303 def test_find_core_matches_skips_taken_name() -> None: """objective_complex_name уже в mapping → skipped_taken, не tier_a.""" mock_db = _make_core_db( domrf_rows=[(404, 'ЖК "Симфония"', "ООО Симфония")], objective_rows=[("Симфония", ["Симфония"], 7005)], taken_names=["Симфония"], # уже занято ) report = find_core_matches(mock_db) assert report.tier_a == [] assert len(report.skipped_taken) == 1 assert report.skipped_taken[0].objective_project_name == "Симфония" def test_find_core_matches_no_objective_candidate_dropped() -> None: """domrf-ядро без objective-проекта — молча пропускается (не наш случай).""" mock_db = _make_core_db( domrf_rows=[(505, 'ЖК "Несуществующий"', "ООО X")], objective_rows=[("Малевич", ["PRINZIP"], 7001)], taken_names=[], ) report = find_core_matches(mock_db) assert report.counts() == { "tier_a": 0, "tier_b": 0, "ambiguous": 0, "skipped_taken": 0, } # ── apply_core_matches: dry-run / apply / ON CONFLICT ──────────────────────── def _tier_a_report() -> CoreMatchReport: """Отчёт с ровно одним tier_a (Малевич + SPV-подтверждение).""" mock_db = _make_core_db( domrf_rows=[(101, 'Жилой комплекс "Малевич"', 'ООО "СЗ "Малевич"')], objective_rows=[("Малевич", ['СЗ "Малевич"'], 7001)], taken_names=[], ) return find_core_matches(mock_db) def test_apply_core_matches_dry_run_no_write() -> None: """dry_run=True → только counts, execute/commit не вызываются.""" report = _tier_a_report() assert len(report.tier_a) == 1 mock_db = MagicMock() result = apply_core_matches(mock_db, report, dry_run=True) assert result["inserted"] == 0 assert result["tier_a_total"] == 1 mock_db.execute.assert_not_called() mock_db.commit.assert_not_called() def test_apply_core_matches_writes_tier_a_with_method_score() -> None: """apply пишет tier_a с match_method='auto_core_dev_v5' и score=0.90.""" report = _tier_a_report() savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) exec_result = MagicMock() exec_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = exec_result result = apply_core_matches(mock_db, report, dry_run=False) assert result["inserted"] == 1 assert result["conflict_skipped"] == 0 mock_db.commit.assert_called_once() # проверяем переданные в INSERT параметры params = mock_db.execute.call_args[0][1] assert params["method"] == CORE_MATCH_METHOD assert params["score"] == CORE_MATCH_SCORE assert params["reviewed"] is False assert params["group"] == "Екатеринбург" assert params["name"] == "Малевич" assert params["obj_id"] == 101 assert params["project_id"] == 7001 def test_apply_core_matches_on_conflict_counts_skip() -> None: """ON CONFLICT DO NOTHING (rowcount=0) → conflict_skipped, не падает.""" report = _tier_a_report() savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) exec_result = MagicMock() exec_result.rowcount = 0 # ON CONFLICT mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = exec_result result = apply_core_matches(mock_db, report, dry_run=False) assert result["inserted"] == 0 assert result["conflict_skipped"] == 1 mock_db.commit.assert_called_once() def test_apply_core_matches_ignores_non_tier_a() -> None: """tier_b / ambiguous / skipped НЕ пишутся — только tier_a.""" report = CoreMatchReport() # только tier_b и ambiguous, tier_a пуст tb_db = _make_core_db( domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], ) report = find_core_matches(tb_db) assert len(report.tier_b) == 1 assert report.tier_a == [] mock_db = MagicMock() result = apply_core_matches(mock_db, report, dry_run=False) assert result["inserted"] == 0 assert result["tier_a_total"] == 0 mock_db.execute.assert_not_called() # commit всё равно вызывается (пустой tier_a — no-op транзакция) mock_db.commit.assert_called_once() # ═════════════════════════════════════════════════════════════════════════════ # Geo-pass (#2177) — auto_core_geo_v6 # ═════════════════════════════════════════════════════════════════════════════ # Патч-путь: clean_address импортирован в namespace objective_backfill. _CLEAN_ADDRESS_PATH = "app.services.etl.objective_backfill.clean_address" # Опорная domrf-точка для тестов (центр ЕКБ, произвольная). _DOMRF_LAT = 56.8389 _DOMRF_LON = 60.6057 def _offset_coords(base_lat: float, base_lon: float, meters_north: float) -> tuple[float, float]: """Точка в meters_north к северу от base (для управляемой дистанции).""" # 1° широты ≈ 111_320 м. Сдвиг только по широте — долготу оставляем. return (base_lat + meters_north / 111_320.0, base_lon) def _make_geo_db( objective_rows: list[tuple[str, list[str] | None, int | None]], taken_names: list[str], domrf_rows: list[tuple[int, str, str | None]], address_rows: list[tuple[str, str]], coord_rows: list[tuple[int, float, float]], ) -> MagicMock: """Mock Session для find_geo_matches — 6 db.execute().all() по порядку: find_core_matches: 1) _OBJECTIVE_PROJECTS_SQL, 2) _TAKEN_NAMES_SQL, 3) _DOMRF_UNMAPPED_SQL geo: 4) _OBJECTIVE_ADDRESS_SQL (адреса per project), 5) _OBJECTIVE_PROJECTS_SQL (повтор, _load_objective_by_core), 6) _DOMRF_COORDS_SQL (координаты domrf per obj_id). """ def _result(rows: list[Any]) -> MagicMock: r = MagicMock() r.all.return_value = rows return r mock_db = MagicMock() mock_db.execute.side_effect = [ _result(objective_rows), _result([(n,) for n in taken_names]), _result(domrf_rows), _result(address_rows), _result(objective_rows), _result(coord_rows), ] return mock_db # ── haversine ──────────────────────────────────────────────────────────────── def test_haversine_zero_and_known_distance() -> None: """haversine: одна точка → 0; сдвиг 120 м → ≈120 м (±5 м).""" assert _haversine_m(_DOMRF_LAT, _DOMRF_LON, _DOMRF_LAT, _DOMRF_LON) == 0.0 lat2, lon2 = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 120.0) dist = _haversine_m(_DOMRF_LAT, _DOMRF_LON, lat2, lon2) assert abs(dist - 120.0) < 5.0 # ── tier_b confirm / reject по дистанции ───────────────────────────────────── def test_geo_tier_b_confirm_within_radius() -> None: """tier_b, адрес в 120 м от domrf → confirm.""" addr_lat, addr_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 120.0) mock_db = _make_geo_db( objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], # dev не совпал → tier_b address_rows=[("Арсенал", "г Екатеринбург, ул Арсенальная, 1")], coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)], ) with patch(_CLEAN_ADDRESS_PATH, return_value=(addr_lat, addr_lon)) as mock_geo: report = find_geo_matches(mock_db, max_distance_m=500) assert len(report.confirmed) == 1 gm = report.confirmed[0] assert gm.domrf_obj_id == 202 assert gm.objective_project_name == "Арсенал" assert gm.origin == "tier_b" assert abs(gm.distance_m - 120.0) < 5.0 assert report.rejected == [] assert report.dadata_calls == 1 mock_geo.assert_called_once() def test_geo_tier_b_reject_too_far() -> None: """tier_b, адрес в 900 м → reject с дистанцией (за пределами 500 м).""" addr_lat, addr_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 900.0) mock_db = _make_geo_db( objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], address_rows=[("Арсенал", "г Екатеринбург, ул Дальняя, 99")], coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)], ) with patch(_CLEAN_ADDRESS_PATH, return_value=(addr_lat, addr_lon)): report = find_geo_matches(mock_db, max_distance_m=500) assert report.confirmed == [] assert len(report.rejected) == 1 rej = report.rejected[0] assert rej.reason == "too_far" assert rej.distance_m is not None and abs(rej.distance_m - 900.0) < 10.0 # ── ambiguous: резолв на ближнего / остаётся при двух в радиусе ────────────── def test_geo_ambiguous_resolves_to_nearest() -> None: """ambiguous, 2 кандидата ядра: один в 150 м, другой в 5 км → резолв на ближнего.""" near_lat, near_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 150.0) far_lat, far_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 5000.0) mock_db = _make_geo_db( objective_rows=[ ("Меридиан", ["Девелопер"], 7003), ("Меридиан ", ["Другой"], 7004), # то же ядро «меридиан» ], taken_names=[], domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], address_rows=[ ("Меридиан", "г Екатеринбург, ул Близкая, 1"), ("Меридиан ", "г Екатеринбург, ул Далёкая, 2"), ], coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)], ) def _fake_geocode(addr: str) -> tuple[float, float]: if "Близкая" in addr: return (near_lat, near_lon) return (far_lat, far_lon) with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): report = find_geo_matches(mock_db, max_distance_m=500) assert len(report.confirmed) == 1 gm = report.confirmed[0] assert gm.objective_project_name == "Меридиан" # ближний assert gm.objective_project_id == 7003 assert gm.origin == "ambiguous" assert abs(gm.distance_m - 150.0) < 10.0 def test_geo_geocode_prefixes_city_when_missing() -> None: """Адреса без города префиксуются «г Екатеринбург, » перед DaData (qc_geo=5 фикс); адрес с городом уходит как есть.""" near_lat, near_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0) calls: list[str] = [] mock_db = _make_geo_db( objective_rows=[("Уникальный", ["Девелопер"], 7001)], taken_names=[], domrf_rows=[(301, 'ЖК "Уникальный"', "ООО Другой")], address_rows=[("Уникальный", "ул. Гидрострой, д. 2а")], coord_rows=[(301, _DOMRF_LAT, _DOMRF_LON)], ) def _fake_geocode(addr: str) -> tuple[float, float]: calls.append(addr) return (near_lat, near_lon) with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): report = find_geo_matches(mock_db, max_distance_m=500) assert calls == ["г Екатеринбург, ул. Гидрострой, д. 2а"] assert len(report.confirmed) == 1 def test_geo_ambiguous_partial_geocode_does_not_blind_confirm() -> None: """ambiguous, один кандидат геокодится в 100 м, второй НЕ геокодится вовсе → НЕ confirm (reject partial_geocode): «один в радиусе» — артефакт отсутствия данных, сосед мог быть ближе (ревью 2026-07-03).""" near_lat, near_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0) mock_db = _make_geo_db( objective_rows=[ ("Меридиан", ["Девелопер"], 7003), ("Меридиан ", ["Другой"], 7004), # то же ядро ], taken_names=[], domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], address_rows=[ ("Меридиан", "г Екатеринбург, ул Близкая, 1"), ("Меридиан ", "г Екатеринбург, ул Негеокодимая, 2"), ], coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)], ) def _fake_geocode(addr: str) -> tuple[float, float] | None: if "Близкая" in addr: return (near_lat, near_lon) return None # сосед не геокодится with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): report = find_geo_matches(mock_db, max_distance_m=500) assert report.confirmed == [] reasons = {r.reason for r in report.rejected} assert "partial_geocode" in reasons def test_geo_ambiguous_two_in_radius_stays_ambiguous() -> None: """ambiguous, оба кандидата в радиусе → НЕ резолвим, остаётся ambiguous.""" a_lat, a_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0) b_lat, b_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 200.0) mock_db = _make_geo_db( objective_rows=[ ("Меридиан", ["Девелопер"], 7003), ("Меридиан ", ["Другой"], 7004), ], taken_names=[], domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], address_rows=[ ("Меридиан", "г Екатеринбург, ул А, 1"), ("Меридиан ", "г Екатеринбург, ул Б, 2"), ], coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)], ) def _fake_geocode(addr: str) -> tuple[float, float]: return (a_lat, a_lon) if "ул А" in addr else (b_lat, b_lon) with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): report = find_geo_matches(mock_db, max_distance_m=500) assert report.confirmed == [] assert len(report.rejected) == 1 assert report.rejected[0].reason == "ambiguous_multi" # ── no address / no geocode → reject, не падаем ────────────────────────────── def test_geo_no_address_rejects_without_geocode_call() -> None: """tier_b без адреса → reject 'no_address', DaData не дёргается.""" mock_db = _make_geo_db( objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], address_rows=[], # адреса нет coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)], ) with patch(_CLEAN_ADDRESS_PATH, return_value=(1.0, 2.0)) as mock_geo: report = find_geo_matches(mock_db, max_distance_m=500) assert report.confirmed == [] assert len(report.rejected) == 1 assert report.rejected[0].reason == "no_address" assert report.dadata_calls == 0 mock_geo.assert_not_called() def test_geo_null_geocode_rejects_no_crash() -> None: """tier_b, DaData вернул None → reject 'no_geocode', не падаем.""" mock_db = _make_geo_db( objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], address_rows=[("Арсенал", "нераспознаваемый адрес")], coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)], ) with patch(_CLEAN_ADDRESS_PATH, return_value=None): report = find_geo_matches(mock_db, max_distance_m=500) assert report.confirmed == [] assert len(report.rejected) == 1 assert report.rejected[0].reason == "no_geocode" assert report.dadata_calls == 1 # ── лимит вызовов DaData соблюдается ───────────────────────────────────────── def test_geo_call_limit_respected() -> None: """При GEO_DADATA_CALL_LIMIT+2 tier_b с уникальными адресами делаем ровно LIMIT вызовов, остаток reject c call_limit_hit=True.""" n = GEO_DADATA_CALL_LIMIT + 2 objective_rows = [(f"Проект{i}", [f"Dev{i}"], 8000 + i) for i in range(n)] domrf_rows = [(9000 + i, f'ЖК "Проект{i}"', f"ООО Иное{i}") for i in range(n)] address_rows = [(f"Проект{i}", f"г Екатеринбург, ул {i}, дом {i}") for i in range(n)] coord_rows = [(9000 + i, _DOMRF_LAT, _DOMRF_LON) for i in range(n)] mock_db = _make_geo_db( objective_rows=objective_rows, taken_names=[], domrf_rows=domrf_rows, address_rows=address_rows, coord_rows=coord_rows, ) call_counter = {"n": 0} def _fake_geocode(addr: str) -> tuple[float, float]: call_counter["n"] += 1 return (_DOMRF_LAT, _DOMRF_LON) # всё в 0 м → confirm with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): report = find_geo_matches(mock_db, max_distance_m=500) assert report.dadata_calls == GEO_DADATA_CALL_LIMIT assert call_counter["n"] == GEO_DADATA_CALL_LIMIT assert report.call_limit_hit is True # 2 кандидата за лимитом → reject 'call_limit' call_limit_rejects = [r for r in report.rejected if r.reason == "call_limit"] assert len(call_limit_rejects) == 2 # ── apply_geo_matches: dry-run / apply ─────────────────────────────────────── def _confirmed_geo_report() -> GeoMatchReport: """Отчёт с одним confirmed tier_b гео-матчем (150 м).""" addr_lat, addr_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 150.0) mock_db = _make_geo_db( objective_rows=[("Арсенал", ["Брозэкс"], 7002)], taken_names=[], domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")], address_rows=[("Арсенал", "г Екатеринбург, ул Арсенальная, 1")], coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)], ) with patch(_CLEAN_ADDRESS_PATH, return_value=(addr_lat, addr_lon)): return find_geo_matches(mock_db, max_distance_m=500) def test_apply_geo_matches_dry_run_no_write() -> None: """dry_run=True → только counts, execute/commit не вызываются.""" report = _confirmed_geo_report() assert len(report.confirmed) == 1 mock_db = MagicMock() result = apply_geo_matches(mock_db, report, dry_run=True) assert result["inserted"] == 0 assert result["confirmed_total"] == 1 mock_db.execute.assert_not_called() mock_db.commit.assert_not_called() def test_apply_geo_matches_writes_v6_score_note() -> None: """apply пишет confirmed с method=auto_core_geo_v6, score=0.80, note-дистанцией.""" report = _confirmed_geo_report() savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) exec_result = MagicMock() exec_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = exec_result result = apply_geo_matches(mock_db, report, dry_run=False) assert result["inserted"] == 1 assert result["conflict_skipped"] == 0 mock_db.commit.assert_called_once() params = mock_db.execute.call_args[0][1] assert params["method"] == GEO_MATCH_METHOD assert params["score"] == GEO_MATCH_SCORE assert params["reviewed"] is False assert params["group"] == "Екатеринбург" assert params["name"] == "Арсенал" assert params["obj_id"] == 202 assert params["project_id"] == 7002 # note несёт дистанцию в метрах + origin assert "м" in params["note"] assert "tier_b" in params["note"] def test_apply_geo_matches_on_conflict_counts_skip() -> None: """ON CONFLICT DO NOTHING (rowcount=0) → conflict_skipped, не падает.""" report = _confirmed_geo_report() savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) exec_result = MagicMock() exec_result.rowcount = 0 mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = exec_result result = apply_geo_matches(mock_db, report, dry_run=False) assert result["inserted"] == 0 assert result["conflict_skipped"] == 1 mock_db.commit.assert_called_once()