"""Unit-тесты district-resolver (Step 2 — мост admin↔micro вокабуляров ЕКБ). Резолвер лечит баг §9.x: /analyze передаёт ОФИЦИАЛЬНОЕ админ-имя («Кировский»), а objective_lots.district хранит НЕФОРМАЛЬНЫЕ микро («Втузгородок»/«ЖБИ») → админ-имя давало 0 строк → молча пустой прогноз (см. data/sql/131_district_vocab_map.sql). Контракт resolve_objective_districts(db, district): • None / 'не определён' → None (EKB-wide, без district-фильтра) • админ-имя с чистыми алиасами → [micro, …] (note IS NULL) • админ-имя без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) • не-админ (raw микро, legacy) → [district] (фильтр-по-микро как есть) Тесты бьют МОК-сессию (без живой БД): резолвер делает максимум 2 execute — admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧНО: тест на админ-вход ОБЯЗАН падать, если резолвер регрессирует к pass-through админ-имени. """ from __future__ import annotations import os from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from app.services.site_finder import district_resolver from app.services.site_finder.district_resolver import ( _reset_admin_cache, resolve_objective_districts, ) # 8 официальных админ-районов ЕКБ (district_name из ekb_districts с непустым geom). _ADMIN_NAMES = [ "Академический", "Верх-Исетский", "Железнодорожный", "Кировский", "Ленинский", "Октябрьский", "Орджоникидзевский", "Чкаловский", ] # Чистые микро (note IS NULL) для админов, которые тестим (срез prod-сида 131_*). _CLEAN_MICROS: dict[str, list[str]] = { "Кировский": ["Втузгородок", "ЖБИ"], "Ленинский": ["Автовокзал", "УНЦ", "Юго-Западный"], "Орджоникидзевский": ["Уралмаш", "Эльмаш"], } @pytest.fixture(autouse=True) def _reset_cache_between_tests() -> None: """Сбрасывать per-process кеш админ-имён — иначе мок одного теста течёт в другой.""" _reset_admin_cache() yield _reset_admin_cache() def _make_db( admin_names: list[str] | None = None, micros: list[str] | None = None, ) -> MagicMock: """Мок-сессия: 1-й execute → admin-names; 2-й (если есть) → micro-lookup. Оба читаются через .scalars().all(). admin_names=None → дефолтные 8. """ names = _ADMIN_NAMES if admin_names is None else admin_names db = MagicMock() admin_result = MagicMock() admin_result.scalars.return_value.all.return_value = names micro_result = MagicMock() micro_result.scalars.return_value.all.return_value = micros if micros is not None else [] db.execute.side_effect = [admin_result, micro_result] return db def _micro_lookup_param(db: MagicMock) -> str | None: """Достать bind :d из ВТОРОГО execute (micro-lookup). None если его не было.""" if len(db.execute.call_args_list) < 2: return None args, _kwargs = db.execute.call_args_list[1] return args[1]["d"] # ── None / 'не определён' → None (EKB-wide) ─────────────────────────────────── class TestNoneAndUndetermined: def test_none_returns_none(self) -> None: db = _make_db() assert resolve_objective_districts(db, None) is None # ранний выход — БД даже не трогается assert db.execute.call_count == 0 def test_undetermined_returns_none(self) -> None: db = _make_db() assert resolve_objective_districts(db, "не определён") is None assert db.execute.call_count == 0 # ── админ-имя → его чистые микро (THE FIX) ───────────────────────────────────── class TestAdminExpandsToMicros: @pytest.mark.parametrize("admin", list(_CLEAN_MICROS.keys())) def test_admin_expands_to_clean_micros(self, admin: str) -> None: expected = _CLEAN_MICROS[admin] db = _make_db(micros=expected) out = resolve_objective_districts(db, admin) assert out == expected def test_does_not_pass_admin_name_through(self) -> None: # РЕГРЕССИОННЫЙ guard: резолвер НЕ должен возвращать [admin] (это и был баг — # админ-имя → 0 строк). Должен вернуть микро, а админ-имя НЕ должно быть в наборе. db = _make_db(micros=["Втузгородок", "ЖБИ"]) out = resolve_objective_districts(db, "Кировский") assert out == ["Втузгородок", "ЖБИ"] assert out != ["Кировский"] assert "Кировский" not in out def test_micro_lookup_bound_with_admin_name(self) -> None: # micro-lookup параметризован именно админ-именем (CAST(:d AS text)). db = _make_db(micros=["Уралмаш", "Эльмаш"]) resolve_objective_districts(db, "Орджоникидзевский") assert _micro_lookup_param(db) == "Орджоникидзевский" # ── админ без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) ─────── class TestAdminNoCleanAliasesFallback: def test_admin_without_clean_micros_returns_none(self) -> None: # Админ в ekb_districts, но micro-lookup пуст (note IS NULL отсёк всё) → # EKB-wide fallback. НИКОГДА не пустой список (иначе гарантированно 0 строк). db = _make_db(micros=[]) out = resolve_objective_districts(db, "Чкаловский") assert out is None def test_never_returns_empty_list(self) -> None: db = _make_db(micros=[]) out = resolve_objective_districts(db, "Ленинский") assert out != [] assert out is None # ── raw микро (legacy внутренний вызов) → [district] как есть ────────────────── class TestRawMicroPassThrough: def test_raw_micro_returned_as_is(self) -> None: # 'Втузгородок' — НЕ админ-имя → фильтруем по нему как есть (legacy-поведение). db = _make_db() out = resolve_objective_districts(db, "Втузгородок") assert out == ["Втузгородок"] def test_raw_micro_does_not_hit_micro_lookup(self) -> None: # raw-микро не разворачивается → второй execute (micro-lookup) НЕ делается. db = _make_db() resolve_objective_districts(db, "Уралмаш") assert db.execute.call_count == 1 # только admin-names # ── кеш админ-имён ───────────────────────────────────────────────────────────── class TestAdminNamesCache: def test_admin_names_cached_across_calls(self) -> None: # admin-names читается ОДИН раз и кешируется per-process: второй резолв # raw-микро НЕ делает повторный admin-names запрос. db = MagicMock() admin_result = MagicMock() admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES db.execute.return_value = admin_result # любой вызов вернёт admin-names resolve_objective_districts(db, "Уралмаш") # raw micro → 1 execute (admins) resolve_objective_districts(db, "Эльмаш") # raw micro → 0 execute (cache hit) assert db.execute.call_count == 1 def test_admin_lookup_failure_treats_input_as_raw_micro(self) -> None: # Сбой admin-names lookup → пустой admin-set → ЛЮБОЙ вход трактуется как raw # микро (pass-through), НЕ роняем /analyze. Кеш-от-сбоя не залипает. db = MagicMock() db.execute.side_effect = RuntimeError("db down") out = resolve_objective_districts(db, "Кировский") # admin-set пуст → 'Кировский' не распознан как админ → [district] как есть. assert out == ["Кировский"] # ── сбой micro-lookup → EKB-wide fallback (не crash) ────────────────────────── class TestMicroLookupFailure: def test_micro_lookup_exception_returns_none(self) -> None: db = MagicMock() admin_result = MagicMock() admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES db.execute.side_effect = [admin_result, RuntimeError("micro lookup down")] out = resolve_objective_districts(db, "Кировский") # админ распознан, но lookup упал → EKB-wide (None), а не crash/пустой набор. assert out is None # ── форма SQL: CAST(:d AS text), без :d::text ───────────────────────────────── class TestResolverSqlShape: def test_micro_sql_uses_cast_not_double_colon(self) -> None: sql = str(district_resolver._MICROS_FOR_ADMIN_SQL) assert "CAST(:d AS text)" in sql assert ":d::" not in sql def test_micro_sql_filters_note_is_null(self) -> None: # cross-city/ambiguous (note IS NOT NULL) ОБЯЗАНЫ быть отсечены. sql = str(district_resolver._MICROS_FOR_ADMIN_SQL) assert "note IS NULL" in sql def test_admin_sql_filters_non_null_geom(self) -> None: # 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён). sql = str(district_resolver._ADMIN_NAMES_SQL) assert "geom IS NOT NULL" in sql