gendesign/backend/tests/services/test_objective_backfill.py
bot-backend 18a2cf0d2e
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
feat(etl): гео-проход auto_core_geo_v6 (#2177 остаток)
2026-07-03 06:04:32 +05:00

827 lines
34 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.

"""Тесты для 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()