gendesign/backend/tests/services/site_finder/test_district_resolver.py
Light1YT 681a922d99
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m7s
feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
/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.
2026-06-05 07:03:37 +05:00

222 lines
11 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.

"""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