/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.
Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).
Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""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
|