gendesign/backend/tests/services/test_objective_backfill.py
bot-backend e9c8d05e1d
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Successful in 2m8s
Deploy / build-frontend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
feat(etl): auto_core_dev_v5 — core-name+dev backfill mapping'а (#2177 шаг 2) (#2237)
2026-07-02 21:59:16 +00:00

442 lines
18 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,
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()