"""Тесты для 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, REVIEW_THRESHOLD, CoreMatchReport, MatchCandidate, _dev_confirms, _norm_dev, apply_core_matches, auto_apply_matches, find_core_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()