All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m45s
CI / backend-tests (pull_request) Successful in 14m3s
827 lines
34 KiB
Python
827 lines
34 KiB
Python
"""Тесты для 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_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()
|