feat(forecast): wire admin→micro district resolver into §9.x filters — Step 2 #1054
8 changed files with 662 additions and 30 deletions
|
|
@ -58,6 +58,7 @@ from sqlalchemy.orm import Session
|
|||
# Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в
|
||||
# macro_series, хоть и не в __all__ пакета.
|
||||
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
|
||||
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -357,7 +358,10 @@ _SOURCE_A_SQL = text(
|
|||
FROM objective_corpus_room_month crm
|
||||
WHERE crm.report_month >= CAST(:since AS date)
|
||||
AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text)))
|
||||
AND (CAST(:district AS text) IS NULL OR crm.district = CAST(:district AS text))
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR crm.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:room_bucket AS text) IS NULL
|
||||
OR crm.room_bucket = CAST(:room_bucket AS text)
|
||||
|
|
@ -415,8 +419,8 @@ _SOURCE_B_SQL = text(
|
|||
AND ol.registration_date >= CAST(:since AS date)
|
||||
AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text)))
|
||||
AND (
|
||||
CAST(:district AS text) IS NULL
|
||||
OR ol.district = CAST(:district AS text)
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
|
|
@ -534,10 +538,14 @@ def _query_source_a(
|
|||
"(monthly aggregate carries no per-lot price)",
|
||||
spec.price_bucket,
|
||||
)
|
||||
# district (админ-имя ЕКБ) → набор informal микро (crm.district хранит микро —
|
||||
# тот же вокабуляр, что objective_lots; verified на prod). None → EKB-wide.
|
||||
micros = resolve_objective_districts(db, spec.district)
|
||||
params = {
|
||||
"since": since,
|
||||
"cls": spec.obj_class,
|
||||
"district": spec.district,
|
||||
"has_district": micros is not None,
|
||||
"districts": micros if micros is not None else [],
|
||||
"room_bucket": spec.room_bucket,
|
||||
}
|
||||
try:
|
||||
|
|
@ -557,11 +565,15 @@ def _query_source_b(
|
|||
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
||||
правилом и в БД, и в Python.
|
||||
"""
|
||||
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
||||
# None → EKB-wide (без district-фильтра).
|
||||
micros = resolve_objective_districts(db, spec.district)
|
||||
params = {
|
||||
"since": since,
|
||||
"premise_kind": premise_kind,
|
||||
"cls": spec.obj_class,
|
||||
"district": spec.district,
|
||||
"has_district": micros is not None,
|
||||
"districts": micros if micros is not None else [],
|
||||
"room_bucket": spec.room_bucket,
|
||||
"price_bucket": spec.price_bucket,
|
||||
# bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*).
|
||||
|
|
|
|||
144
backend/app/services/site_finder/district_resolver.py
Normal file
144
backend/app/services/site_finder/district_resolver.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2).
|
||||
|
||||
Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`):
|
||||
• §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и
|
||||
`objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ
|
||||
(«Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений).
|
||||
• Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts`
|
||||
(8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …).
|
||||
• Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр.
|
||||
market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает,
|
||||
т.к. он есть в ОБОИХ вокабулярах).
|
||||
|
||||
Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias`
|
||||
(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который
|
||||
разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали
|
||||
возвращать пустоту.
|
||||
|
||||
Семантика `resolve_objective_districts(db, district)` — см. docstring функции.
|
||||
psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в
|
||||
# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None →
|
||||
# EKB-wide fallback (без district-фильтра), а не как буквальное имя района.
|
||||
_UNDETERMINED_DISTRICT: str = "не определён"
|
||||
|
||||
# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым
|
||||
# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только
|
||||
# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к
|
||||
# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах.
|
||||
_ADMIN_NAMES_CACHE: frozenset[str] | None = None
|
||||
|
||||
# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom
|
||||
# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен.
|
||||
_ADMIN_NAMES_SQL = text(
|
||||
"""
|
||||
SELECT district_name
|
||||
FROM ekb_districts
|
||||
WHERE geom IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city
|
||||
# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки —
|
||||
# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql).
|
||||
_MICROS_FOR_ADMIN_SQL = text(
|
||||
"""
|
||||
SELECT micro_district
|
||||
FROM ekb_district_alias
|
||||
WHERE admin_district = CAST(:d AS text)
|
||||
AND note IS NULL
|
||||
ORDER BY micro_district
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _admin_names(db: Session) -> frozenset[str]:
|
||||
"""Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process.
|
||||
|
||||
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
|
||||
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
|
||||
"""
|
||||
global _ADMIN_NAMES_CACHE
|
||||
if _ADMIN_NAMES_CACHE is not None:
|
||||
return _ADMIN_NAMES_CACHE
|
||||
try:
|
||||
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||||
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
|
||||
except Exception:
|
||||
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
|
||||
logger.exception("district_resolver: admin-names lookup failed")
|
||||
return frozenset()
|
||||
return _ADMIN_NAMES_CACHE
|
||||
|
||||
|
||||
def _reset_admin_cache() -> None:
|
||||
"""Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ)."""
|
||||
global _ADMIN_NAMES_CACHE
|
||||
_ADMIN_NAMES_CACHE = None
|
||||
|
||||
|
||||
def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None:
|
||||
"""Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots.
|
||||
|
||||
Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts).
|
||||
Возвращаемое значение — контракт для вызывающего фильтра:
|
||||
|
||||
• ``None`` → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит
|
||||
когда:
|
||||
- вход ``None`` или ``'не определён'`` (участок вне 8
|
||||
полигонов — нечего сужать), ЛИБО
|
||||
- вход — валидное админ-имя, но у него НЕТ чистых алиасов
|
||||
(на практике не случается: все 8 покрыты сидом; страховка).
|
||||
• ``[micro, …]`` → фильтровать ``ol.district = ANY(...)``:
|
||||
- вход — одно из 8 АДМИН-имён → его чистые микро
|
||||
(note IS NULL: cross-city/ambiguous исключены), ЛИБО
|
||||
- вход — НЕ админ-имя (raw МИКРО, переданный внутренним
|
||||
legacy-вызовом) → ``[district]`` как есть (сохраняем
|
||||
прежнее поведение фильтра-по-микро).
|
||||
|
||||
Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт
|
||||
``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть
|
||||
суть фикса).
|
||||
|
||||
psycopg v3: bind через CAST(:d AS text). Read-only.
|
||||
"""
|
||||
if district is None or district == _UNDETERMINED_DISTRICT:
|
||||
return None
|
||||
|
||||
admin_names = _admin_names(db)
|
||||
if district not in admin_names:
|
||||
# Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть.
|
||||
return [district]
|
||||
|
||||
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
|
||||
try:
|
||||
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||||
micros = [str(m) for m in rows]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide",
|
||||
district,
|
||||
)
|
||||
return None
|
||||
|
||||
if not micros:
|
||||
# Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор.
|
||||
logger.warning(
|
||||
"district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback",
|
||||
district,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.debug("district_resolver: admin '%s' → micros %s", district, micros)
|
||||
return micros
|
||||
|
|
@ -38,6 +38,7 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.analytics_queries import _elasticity_coef
|
||||
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -260,8 +261,8 @@ _STOCK_SQL = text(
|
|||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
CAST(:district AS text) IS NULL
|
||||
OR ol.district = CAST(:district AS text)
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
|
|
@ -300,8 +301,8 @@ _SALES_WINDOW_SQL = text(
|
|||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
CAST(:district AS text) IS NULL
|
||||
OR ol.district = CAST(:district AS text)
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
|
|
@ -333,9 +334,17 @@ def compute_market_metrics(
|
|||
"""
|
||||
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
|
||||
has_obj_ids = bool(obj_id_list)
|
||||
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
|
||||
# реально фильтруется objective_lots. None → EKB-wide (без district-фильтра); это
|
||||
# лечит баг «админ-имя → 0 строк → пустой прогноз» (см. district_resolver).
|
||||
micros = resolve_objective_districts(db, district)
|
||||
has_district = micros is not None
|
||||
params: dict[str, Any] = {
|
||||
"premise_kind": premise_kind,
|
||||
"district": district,
|
||||
# has_district=False → district-ветка фильтра отключена (EKB-wide).
|
||||
"has_district": has_district,
|
||||
# ANY(NULL::text[]) валиден; пустой список когда фильтра нет (has_district=False).
|
||||
"districts": micros if micros is not None else [],
|
||||
"has_obj_ids": has_obj_ids,
|
||||
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
||||
"obj_ids": obj_id_list,
|
||||
|
|
@ -378,9 +387,10 @@ def compute_market_metrics(
|
|||
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
|
||||
|
||||
logger.info(
|
||||
"market_metrics: district=%s obj_ids=%d n_lots=%d n_sold=%d n_available=%d "
|
||||
"obj_count=%d units_sold_window=%d confidence=%s",
|
||||
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
|
||||
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||
district,
|
||||
micros,
|
||||
len(obj_id_list),
|
||||
n_lots,
|
||||
n_sold_total,
|
||||
|
|
@ -417,7 +427,9 @@ def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
|
|||
try:
|
||||
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
||||
except Exception:
|
||||
logger.exception("market_metrics: stock query failed (district=%s)", params.get("district"))
|
||||
logger.exception(
|
||||
"market_metrics: stock query failed (districts=%s)", params.get("districts")
|
||||
)
|
||||
row = None
|
||||
if row is None:
|
||||
return {
|
||||
|
|
@ -449,7 +461,7 @@ def _query_sales_window(
|
|||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: sales-window query failed (district=%s)", params.get("district")
|
||||
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
|
||||
)
|
||||
rows = []
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ from typing import Any, Literal
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
|
@ -192,8 +194,8 @@ _L1_OPEN_SQL = text(
|
|||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
CAST(:district AS text) IS NULL
|
||||
OR ol.district = CAST(:district AS text)
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
|
|
@ -296,8 +298,18 @@ def compute_layer1_open(
|
|||
Источник — `objective_lots` (выставленные лоты), переиспользует district-stock
|
||||
паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке
|
||||
на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает.
|
||||
|
||||
district резолвится через district_resolver: админ-имя ЕКБ → набор informal микро
|
||||
(objective_lots хранит микро, не админ — иначе админ-имя дало бы 0 строк). None →
|
||||
EKB-wide (без district-фильтра). L2/L3 ниже НЕ резолвятся: они бьют по
|
||||
domrf_kn_objects.district_name, а это УЖЕ админ-вокабуляр (м.57 spatial-join).
|
||||
"""
|
||||
params: dict[str, Any] = {"premise_kind": premise_kind, "district": district}
|
||||
micros = resolve_objective_districts(db, district)
|
||||
params: dict[str, Any] = {
|
||||
"premise_kind": premise_kind,
|
||||
"has_district": micros is not None,
|
||||
"districts": micros if micros is not None else [],
|
||||
}
|
||||
rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1)
|
||||
|
||||
out: list[SupplyLayerRow] = []
|
||||
|
|
@ -484,7 +496,7 @@ def _safe_rows(
|
|||
try:
|
||||
return db.execute(sql, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"supply_layers: L%d query failed (district=%s)", layer, params.get("district")
|
||||
)
|
||||
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
|
||||
district_dbg = params.get("district", params.get("districts"))
|
||||
logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg)
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ from __future__ import annotations
|
|||
import datetime as dt
|
||||
import math
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
|
@ -45,6 +48,21 @@ from app.services.forecasting.sales_series import (
|
|||
room_area_bucket_of,
|
||||
)
|
||||
|
||||
# Резолвер admin→micro (Step 2). Обе SQL-ветки (Source A crm + Source B objective_lots)
|
||||
# теперь резолвят spec.district → набор микро. Патчим на identity raw-микро, чтобы
|
||||
# db.execute не получал лишний resolver-запрос; отдельный класс TestDistrictResolution
|
||||
# проверяет, что резолвнутые микро реально идут в SQL-параметры.
|
||||
_RESOLVE = "app.services.forecasting.sales_series.resolve_objective_districts"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_resolver() -> Iterator[MagicMock]:
|
||||
"""По умолчанию резолвер = identity raw-микро (district→[district], None→None)."""
|
||||
with patch(_RESOLVE) as m:
|
||||
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||||
yield m
|
||||
|
||||
|
||||
# ── pure: price_bucket_of ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -365,7 +383,8 @@ class TestBuildSalesSeriesSourceShape:
|
|||
sql = _sql_of(db)
|
||||
assert "CAST(:since AS date)" in sql
|
||||
assert "CAST(:cls AS text)" in sql
|
||||
assert "CAST(:district AS text)" in sql
|
||||
# district теперь резолвится в набор микро → ANY(CAST(:districts AS text[])).
|
||||
assert "CAST(:districts AS text[])" in sql
|
||||
assert "CAST(:room_bucket AS text)" in sql
|
||||
# psycopg v3 trap: никаких :name::type.
|
||||
assert "::" not in sql
|
||||
|
|
@ -419,7 +438,10 @@ class TestBuildSalesSeriesSourceShape:
|
|||
)
|
||||
params = _params_of(db)
|
||||
assert params["cls"] == "Комфорт"
|
||||
assert params["district"] == "Уралмаш"
|
||||
# district резолвится в набор микро (identity → ['Уралмаш']) → ANY(:districts).
|
||||
assert params["has_district"] is True
|
||||
assert params["districts"] == ["Уралмаш"]
|
||||
assert "district" not in params
|
||||
assert params["room_bucket"] == "1"
|
||||
assert isinstance(params["since"], dt.date)
|
||||
|
||||
|
|
@ -643,3 +665,61 @@ class TestBuildSalesSeriesGraceful:
|
|||
)
|
||||
assert out.n_months == 1 # только текущий месяц
|
||||
assert out.units == [0]
|
||||
|
||||
|
||||
class TestDistrictResolution:
|
||||
"""Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.
|
||||
|
||||
Оба источника используют МИКРО-вокабуляр: objective_corpus_room_month.district и
|
||||
objective_lots.district несут одни и те же 35 informal-микро (verified на prod) →
|
||||
админ-имя по ним давало 0 строк. Резолвер разворачивает его в чистые микро.
|
||||
"""
|
||||
|
||||
def test_source_a_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||
)
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([])
|
||||
build_sales_series(
|
||||
db,
|
||||
spec=SegmentSpec(district="Кировский"),
|
||||
source="corpus_room_month",
|
||||
months_back=1,
|
||||
)
|
||||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||
p = _params_of(db)
|
||||
assert p["has_district"] is True
|
||||
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||||
assert "Кировский" not in p["districts"]
|
||||
|
||||
def test_source_b_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
|
||||
)
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([])
|
||||
build_sales_series(
|
||||
db,
|
||||
spec=SegmentSpec(district="Орджоникидзевский"),
|
||||
source="objective_lots",
|
||||
months_back=1,
|
||||
)
|
||||
p = _params_of(db)
|
||||
assert p["has_district"] is True
|
||||
assert p["districts"] == ["Уралмаш", "Эльмаш"]
|
||||
assert "Орджоникидзевский" not in p["districts"]
|
||||
|
||||
def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, _d: None
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([])
|
||||
build_sales_series(
|
||||
db,
|
||||
spec=SegmentSpec(district="не определён"),
|
||||
source="objective_lots",
|
||||
months_back=1,
|
||||
)
|
||||
p = _params_of(db)
|
||||
assert p["has_district"] is False
|
||||
assert p["districts"] == []
|
||||
|
|
|
|||
222
backend/tests/services/site_finder/test_district_resolver.py
Normal file
222
backend/tests/services/site_finder/test_district_resolver.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""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
|
||||
|
|
@ -14,8 +14,11 @@ psycopg v3 правило проверяется явно: bind-параметр
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.site_finder.market_metrics import (
|
||||
|
|
@ -33,6 +36,22 @@ from app.services.site_finder.market_metrics import (
|
|||
)
|
||||
|
||||
_ELAST = "app.services.site_finder.market_metrics._elasticity_coef"
|
||||
# Резолвер admin→micro (Step 2). В shape/thin-data тестах патчим его на identity-обёртку
|
||||
# через _patch_resolver(), чтобы не ходить в БД за admin-names/aliases. Отдельный класс
|
||||
# TestDistrictResolution проверяет, что РЕЗОЛВНУТЫЕ микро реально попадают в SQL-параметры.
|
||||
_RESOLVE = "app.services.site_finder.market_metrics.resolve_objective_districts"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_resolver() -> Iterator[MagicMock]:
|
||||
"""По умолчанию резолвер = identity-обёртка raw-микро (district→[district], None→None).
|
||||
|
||||
Так _mock_db (stock+sales, 2 execute) остаётся валиден — резолвер не трогает db.
|
||||
Тесты, проверяющие admin→micro-разворот, переопределяют .side_effect у этого мока.
|
||||
"""
|
||||
with patch(_RESOLVE) as m:
|
||||
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||||
yield m
|
||||
|
||||
|
||||
# ── pure: _monthly_rate ───────────────────────────────────────────────────────
|
||||
|
|
@ -338,13 +357,14 @@ class TestComputeMarketMetricsShape:
|
|||
compute_market_metrics(db, district="Автовокзал")
|
||||
stock_sql = _executed_sql(db, 0)
|
||||
sales_sql = _executed_sql(db, 1)
|
||||
# psycopg v3: CAST(:x AS type), НЕ :x::type
|
||||
assert "CAST(:district AS text)" in stock_sql
|
||||
# psycopg v3: CAST(:x AS type), НЕ :x::type. District-фильтр теперь массив микро.
|
||||
assert "CAST(:districts AS text[])" in stock_sql
|
||||
assert "CAST(:obj_ids AS bigint[])" in stock_sql
|
||||
assert "CAST(:overstock_interval AS interval)" in stock_sql
|
||||
assert "CAST(:districts AS text[])" in sales_sql
|
||||
assert "CAST(:window_interval AS interval)" in sales_sql
|
||||
for sql in (stock_sql, sales_sql):
|
||||
assert ":district::" not in sql
|
||||
assert ":districts::" not in sql
|
||||
assert ":obj_ids::" not in sql
|
||||
assert ":window_interval::" not in sql
|
||||
assert ":overstock_interval::" not in sql
|
||||
|
|
@ -354,7 +374,11 @@ class TestComputeMarketMetricsShape:
|
|||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
compute_market_metrics(db, district="Автовокзал", premise_kind="квартира")
|
||||
p = _executed_params(db, 0)
|
||||
assert p["district"] == "Автовокзал"
|
||||
# district резолвится в набор микро (тут identity → ['Автовокзал']) и идёт в
|
||||
# ANY(:districts); прежнего скалярного :district больше нет.
|
||||
assert p["has_district"] is True
|
||||
assert p["districts"] == ["Автовокзал"]
|
||||
assert "district" not in p
|
||||
assert p["premise_kind"] == "квартира"
|
||||
assert p["has_obj_ids"] is False
|
||||
assert p["obj_ids"] == []
|
||||
|
|
@ -415,6 +439,67 @@ class TestComputeMarketMetricsShape:
|
|||
assert m.price_sensitivity_source is None
|
||||
|
||||
|
||||
class TestDistrictResolution:
|
||||
"""Step 2: админ-имя ЕКБ резолвится в МИКРО-набор, который реально идёт в SQL-фильтр.
|
||||
|
||||
Баг, который это ловит: раньше админ-имя («Кировский») фильтровало
|
||||
objective_lots.district = 'Кировский' → 0 строк (микро-вокабуляр не содержит
|
||||
админ-имён) → пустой прогноз. Теперь резолвер разворачивает его в чистые микро,
|
||||
и SQL фильтрует ol.district = ANY([микро…]).
|
||||
"""
|
||||
|
||||
def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None:
|
||||
# Резолвер разворачивает 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||
)
|
||||
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
m = compute_market_metrics(db, district="Кировский")
|
||||
|
||||
# резолвер вызван именно с админ-именем
|
||||
_patch_resolver.assert_called_once()
|
||||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||
|
||||
# КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||||
for call_idx in (0, 1): # stock + sales-window
|
||||
p = _executed_params(db, call_idx)
|
||||
assert p["has_district"] is True
|
||||
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||||
assert "Кировский" not in p["districts"]
|
||||
assert "district" not in p # старого скалярного параметра больше нет
|
||||
|
||||
# district на результате всё равно ЭХОИТ входное админ-имя (для отображения).
|
||||
assert m.district == "Кировский"
|
||||
|
||||
def test_resolver_none_drops_district_filter_ekb_wide(self, _patch_resolver: MagicMock) -> None:
|
||||
# Админ без чистых алиасов / 'не определён' → резолвер None → EKB-wide:
|
||||
# has_district=False, district-ветка фильтра отключена (пустой массив-плейсхолдер).
|
||||
_patch_resolver.side_effect = lambda _db, _d: None
|
||||
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
compute_market_metrics(db, district="не определён")
|
||||
p = _executed_params(db, 0)
|
||||
assert p["has_district"] is False
|
||||
assert p["districts"] == []
|
||||
|
||||
def test_does_not_filter_by_admin_name_directly(self, _patch_resolver: MagicMock) -> None:
|
||||
# Anti-vacuous guard: даже если кто-то «починит» резолвер обратно на pass-through
|
||||
# админ-имени, фильтр обязан содержать РЕЗОЛВНУТЫЙ набор. Здесь резолвер отдаёт
|
||||
# микро, и мы проверяем, что admin-имя НЕ просочилось в :districts ни в одном
|
||||
# из двух запросов.
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
|
||||
)
|
||||
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
compute_market_metrics(db, district="Орджоникидзевский")
|
||||
for call_idx in (0, 1):
|
||||
p = _executed_params(db, call_idx)
|
||||
assert p["districts"] == ["Уралмаш", "Эльмаш"]
|
||||
assert "Орджоникидзевский" not in p["districts"]
|
||||
|
||||
|
||||
class TestComputeMarketMetricsThinData:
|
||||
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ psycopg v3 правило проверяется явно: bind-параметр
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
|
@ -34,6 +37,23 @@ from app.services.site_finder.supply_layers import (
|
|||
_ALLOWED_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"}
|
||||
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
||||
|
||||
# Резолвер admin→micro (Step 2) — патчим в L1-тестах. ТОЛЬКО L1 (open) резолвится
|
||||
# (objective_lots = микро-вокабуляр); L2/L3 бьют domrf_kn_objects.district_name, а это
|
||||
# УЖЕ админ-вокабуляр (м.57 spatial-join) → их district НЕ резолвится.
|
||||
_RESOLVE = "app.services.site_finder.supply_layers.resolve_objective_districts"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_resolver() -> Iterator[MagicMock]:
|
||||
"""По умолчанию резолвер = identity raw-микро (district→[district], None→None).
|
||||
|
||||
Так _mock_db остаётся валиден (резолвер не трогает db). L1-тесты, проверяющие
|
||||
admin→micro-разворот, переопределяют .side_effect.
|
||||
"""
|
||||
with patch(_RESOLVE) as m:
|
||||
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||||
yield m
|
||||
|
||||
|
||||
# ── pure: _hidden_units (L2 clamp) ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -205,14 +225,18 @@ class TestLayer1Open:
|
|||
db = _mock_db([])
|
||||
compute_layer1_open(db, district="Автовокзал")
|
||||
sql = _executed_sql(db)
|
||||
assert "CAST(:district AS text)" in sql
|
||||
# District-фильтр L1 теперь массив резолвнутых микро (ANY), не скаляр.
|
||||
assert "CAST(:districts AS text[])" in sql
|
||||
_assert_no_double_colon_cast(sql)
|
||||
|
||||
def test_params(self) -> None:
|
||||
db = _mock_db([])
|
||||
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")
|
||||
p = _executed_params(db)
|
||||
assert p["district"] == "Автовокзал"
|
||||
# district резолвится в набор микро (identity → ['Автовокзал']) → ANY(:districts).
|
||||
assert p["has_district"] is True
|
||||
assert p["districts"] == ["Автовокзал"]
|
||||
assert "district" not in p
|
||||
assert p["premise_kind"] == "квартира"
|
||||
|
||||
def test_rows_built_with_whitelisted_values(self) -> None:
|
||||
|
|
@ -271,6 +295,36 @@ class TestLayer1Open:
|
|||
assert compute_layer1_open(db, district="Сбой") == []
|
||||
|
||||
|
||||
class TestLayer1DistrictResolution:
|
||||
"""Step 2: L1 (open) резолвит админ-имя → микро-набор в SQL-фильтре.
|
||||
|
||||
Тот же баг, что в market_metrics: админ-имя по objective_lots.district давало 0
|
||||
строк открытого предложения. Резолвер разворачивает его в чистые микро.
|
||||
"""
|
||||
|
||||
def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||
)
|
||||
db = _mock_db([])
|
||||
compute_layer1_open(db, district="Кировский")
|
||||
_patch_resolver.assert_called_once()
|
||||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||
p = _executed_params(db)
|
||||
# КРИТИЧНО: фильтр содержит МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||||
assert p["has_district"] is True
|
||||
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||||
assert "Кировский" not in p["districts"]
|
||||
|
||||
def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, _d: None
|
||||
db = _mock_db([])
|
||||
compute_layer1_open(db, district="не определён")
|
||||
p = _executed_params(db)
|
||||
assert p["has_district"] is False
|
||||
assert p["districts"] == []
|
||||
|
||||
|
||||
# ── Layer2 (hidden) — THE CRUX ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -325,6 +379,17 @@ class TestLayer2Hidden:
|
|||
assert p["region_cd"] == 66
|
||||
assert p["district"] == "Автовокзал"
|
||||
|
||||
def test_district_not_resolved_admin_vocab_preserved(self, _patch_resolver: MagicMock) -> None:
|
||||
# КРИТИЧНО: L2 бьёт domrf_kn_objects.district_name (УЖЕ админ-вокабуляр, м.57
|
||||
# spatial-join) → резолвер НЕ вызывается, админ-имя идёт в фильтр как есть.
|
||||
# Иначе резолв в микро дал бы 0 строк (domrf не знает микро-имён).
|
||||
db = _mock_db([])
|
||||
compute_layer2_hidden(db, district="Кировский")
|
||||
_patch_resolver.assert_not_called()
|
||||
p = _executed_params(db)
|
||||
assert p["district"] == "Кировский"
|
||||
assert "districts" not in p
|
||||
|
||||
def test_hidden_aggregate_medium_confidence(self) -> None:
|
||||
rows = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue