feat(tradein/geo): реестр регионов — единственный источник гео-границ (#3051, шард 1a) #3116

Merged
bot-backend merged 2 commits from feat/3051-region-registry into main 2026-08-26 15:24:00 +00:00
6 changed files with 391 additions and 88 deletions

View file

@ -27,6 +27,9 @@ from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS
_REGION_66 = _ALL_REGIONS[66]
logger = logging.getLogger(__name__)
@ -110,8 +113,10 @@ class GeocodeResult:
# Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT, поэтому всё, что
# прошло бы tight-фильтр, проходит и wide-guard.
# bbox = (lat_min, lat_max, lon_min, lon_max).
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)
# #3051: значения живут в реестре регионов (app.services.regions, регион 66) —
# имена остаются здесь ради существующих импортёров и читаемости вызовов.
EKB_BBOX_TIGHT = _REGION_66.bbox_tight
EKB_BBOX_WIDE = _REGION_66.bbox_wide
def is_within_ekb_bbox(
@ -140,7 +145,7 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
# bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
OBLAST66_BBOX = _REGION_66.bbox_region # #3051: значение — в реестре регионов
# Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю
# область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn —
@ -162,54 +167,7 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
# городов), покрывает основные региональные центры. Используется чтобы не
# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой
# город региона (геокодер forward/suggest prefix-логика).
SVERDLOVSK_OBLAST_CITIES = frozenset(
{
"екатеринбург",
"нижний тагил",
"каменск-уральский",
"первоуральск",
"серов",
"новоуральск",
"ревда",
"полевской",
"асбест",
"богданович",
"ирбит",
"красноуфимск",
"верхняя пышма",
"берёзовский",
"березовский",
"заречный",
"качканар",
"краснотурьинск",
"североуральск",
"ивдель",
"тавда",
"туринск",
"сысерть",
"среднеуральск",
"дегтярск",
"верхняя салда",
"нижняя салда",
"невьянск",
"артёмовский",
"артемовский",
"камышлов",
"алапаевск",
"сухой лог",
"кушва",
"красноуральск",
"карпинск",
"нижняя тура",
"верхний тагил",
"нижние серги",
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
def known_city_hint(value: str | None) -> str | None:

View file

@ -52,25 +52,27 @@ from sqlalchemy import text
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
from app.services import regions as regions_mod
from app.services.estimator import LISTINGS_FRESH_DAYS
logger = logging.getLogger(__name__)
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
# Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main
# gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите
# (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp —
# отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована;
# при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны).
_EKB_BBOX_SOUTH = 56.70
_EKB_BBOX_WEST = 60.50
_EKB_BBOX_NORTH = 56.95
_EKB_BBOX_EAST = 60.75
# ── Гео-охват продукта ────────────────────────────────────────────────────────
# #3051: границы живут в реестре регионов (app.services.regions), НЕ здесь.
# У региона 66 bbox_product_core байт-в-байт равен прежним литералам этого файла
# (56.70..56.95 / 60.50..60.75, синхронизирован с EKB_BBOX Overpass-загрузчика
# POI основного gendesign-бэкенда — комментарий в обе стороны остаётся в
# реестре). Точка Москвы теперь попадает в покрытие региона 77 — медианы и POI
# считаются в ЕГО ядре (сегодня листингов Москвы нет → честный
# insufficient_data, не ложный out_of_coverage).
def _in_ekb_bbox(lat: float, lon: float) -> bool:
"""True если точка внутри гео-охвата продукта (Екатеринбург)."""
return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST
def _covered_region(lat: float, lon: float) -> regions_mod.Region | None:
"""Регион, в чьём продукт-ядре лежит точка; None — вне покрытия продукта."""
region = regions_mod.region_for_point(lat, lon)
if region is not None and regions_mod.is_within_bbox(lat, lon, region.bbox_product_core):
return region
return None
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
@ -277,7 +279,10 @@ _NEAREST_POI_SQL = text(
)
def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]:
def _local_median_ppm2(
db: Any, lat: float, lon: float, radius_m: int, region: regions_mod.Region
) -> tuple[float | None, int]:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
row = (
db.execute(
_MEDIAN_PPM2_LOCAL_SQL,
@ -288,10 +293,10 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH,
"bbox_north": _EKB_BBOX_NORTH,
"bbox_west": _EKB_BBOX_WEST,
"bbox_east": _EKB_BBOX_EAST,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
)
.mappings()
@ -303,7 +308,8 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
return (float(median) if median is not None else None), int(row["n"] or 0)
def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float | None, int]:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
row = (
db.execute(
_MEDIAN_PPM2_CITYWIDE_SQL,
@ -311,10 +317,10 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH,
"bbox_north": _EKB_BBOX_NORTH,
"bbox_west": _EKB_BBOX_WEST,
"bbox_east": _EKB_BBOX_EAST,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
)
.mappings()
@ -398,9 +404,11 @@ def compute_location_index(
меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена).
- "ok" location_index_pct надёжен.
"""
if not _in_ekb_bbox(lat, lon):
region = _covered_region(lat, lon)
if region is None:
logger.info(
"location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage",
"location_index: lat=%.5f lon=%.5f вне продукт-ядра всех регионов "
"покрытия (#3051) — out_of_coverage",
lat,
lon,
)
@ -417,7 +425,7 @@ def compute_location_index(
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
city_median, city_n = _citywide_median_ppm2(db)
city_median, city_n = _citywide_median_ppm2(db, region)
if city_median is None or city_n < MIN_SAMPLE_SIZE:
logger.warning(
"location_index: citywide reference sample too small (n=%d) — insufficient_data",
@ -439,7 +447,7 @@ def compute_location_index(
sample_size = 0
used_radius = radii[-1]
for r in radii:
local_median, sample_size = _local_median_ppm2(db, lat, lon, r)
local_median, sample_size = _local_median_ppm2(db, lat, lon, r, region)
used_radius = r
if sample_size >= MIN_SAMPLE_SIZE:
break

View file

@ -8,6 +8,8 @@ import hashlib
import re
import unicodedata
from app.services.regions import REGIONS as _REGIONS
# Strip punctuation EXCEPT hyphens — keep hyphens so abbreviation rules like
# 'пр-кт', 'б-р', 'пр-д' can match before they are collapsed to spaces.
_PUNCT = re.compile(r"[^\w\s\-]", flags=re.UNICODE)
@ -114,7 +116,11 @@ def house_number_token(normalized: str | None) -> str | None:
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
EKB_CITY_TOKEN = "екатеринбург"
# #3051: значение — city_token региона 66 из реестра (app.services.regions);
# _CITY_TOKENS ниже НЕ из реестра намеренно — это зеркало sweep-раскатки
# (CITY_ANCHORS в scraper_kit.orchestration.pipeline, «какие города скрапим»),
# а реестр отвечает на «какие города узнаём» — списки эволюционируют раздельно.
EKB_CITY_TOKEN = _REGIONS[66].city_token
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,

View file

@ -0,0 +1,191 @@
"""Реестр регионов покрытия (#3051, часть 1/3 эпика #2989 «Москва»).
ЕДИНСТВЕННОЕ место, где живут границы покрытия: bbox'ы, города, city-token и
набор доступных тиров обогащения. До реестра границы лежали константами в трёх
файлах (location_index / geocoder / matching.normalize), и каждая молча
отвергла бы Москву; расширение покрытия означало правку россыпи литералов
(ровно так дыра #2656 в свежести и появилась — «в главном месте есть, в
соседних забыли»).
Регион 50 (Московская область) отложен сознательно обоснование в #2996:
10 121 текстовое имя города против 612 у Москвы, вся мина имён в области.
Модуль ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
geocoder / location_index / matching.normalize, циклы недопустимы).
"""
from __future__ import annotations
from dataclasses import dataclass
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
BBox = tuple[float, float, float, float]
@dataclass(frozen=True)
class Region:
"""Один регион покрытия продукта.
bbox_tight ядро города: geocoder-фильтрация фуззи-матчей провайдеров
(не принять соседний город за совпадение по опечатке).
bbox_wide город + легитимное приграничье: ingest-guard координат,
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
bbox_region генеральный bbox региона: fallback-accept для провайдеров без
структурного region-поля. Содержит wide.
bbox_product_core гео-охват ПРОДУКТА в этом регионе: location_index
считает свои медианы/POI только внутри него, точка вне
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
location_index (56.70..56.95/60.50..60.75), синхронизирован с
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
(комментарий в обе стороны, см. site_finder/poi_loader.py).
city_token нормализованный токен главного города (нижний регистр, е==ё
нормализует потребитель matching.normalize).
cities узнаваемые города региона (для city_hint / prefix-логики
геокодера). НЕ исчерпывающий список основные центры.
enrichment_tiers какие тиры обогащения РЕАЛЬНО доступны региону.
Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника.
"""
code: int
name: str
bbox_tight: BBox
bbox_wide: BBox
bbox_region: BBox
bbox_product_core: BBox
city_token: str
cities: frozenset[str]
enrichment_tiers: frozenset[str]
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
"""True если (lat, lon) внутри bbox (inclusive)."""
lat_min, lat_max, lon_min, lon_max = bbox
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
TIER_AVITO_IMV = "avito_imv"
TIER_YANDEX_VALUATION = "yandex_valuation"
TIER_CIAN_VALUATION = "cian_valuation"
TIER_QUARTER_INDEX = "quarter_index"
TIER_SBER_INDEX = "sber_index"
REGIONS: dict[int, Region] = {
66: Region(
code=66,
name="Свердловская область",
# Значения — байт-в-байт прежние литералы из location_index/geocoder
# (менять их здесь = менять поведение, это отдельное решение):
# tight был geocoder.EKB_BBOX_TIGHT, wide — EKB_BBOX_WIDE,
# region — OBLAST66_BBOX, product_core — bbox location_index.
bbox_tight=(56.65, 56.95, 60.40, 60.85),
bbox_wide=(56.6, 57.1, 60.3, 60.9),
bbox_region=(55.8, 62.2, 56.7, 66.6),
bbox_product_core=(56.70, 56.95, 60.50, 60.75),
city_token="екатеринбург",
cities=frozenset(
{
"екатеринбург",
"нижний тагил",
"каменск-уральский",
"первоуральск",
"верхняя пышма",
"серов",
"новоуральск",
"асбест",
"полевской",
"ревда",
"берёзовский",
"березовский",
"среднеуральск",
"верхняя салда",
"качканар",
"краснотурьинск",
"алапаевск",
"ирбит",
"сухой лог",
"богданович",
"заречный",
"невьянск",
"сысерть",
"арамиль",
}
),
enrichment_tiers=frozenset(
{
TIER_AVITO_IMV,
TIER_YANDEX_VALUATION,
TIER_CIAN_VALUATION,
TIER_QUARTER_INDEX,
TIER_SBER_INDEX,
}
),
),
77: Region(
code=77,
name="Москва",
# Ядро — Москва в пределах МКАД (55.57..55.91 / 37.36..37.85) с тем же
# запасом на фуззи, что у ЕКБ-tight относительно города.
bbox_tight=(55.55, 55.95, 37.30, 37.90),
# Продукт-ядро Москвы = tight (МКАД): медианы/POI считаем в ядре,
# Новая Москва дозреет отдельным решением вместе с данными.
# Wide — МКАД + ближний пояс (Зеленоград ловится region-bbox'ом).
bbox_wide=(55.45, 56.05, 37.10, 38.10),
# Генеральный — Москва в административных границах, включая Новую
# Москву (юго-запад до ~55.14) и Зеленоград (север до ~56.05).
bbox_region=(55.10, 56.10, 36.80, 38.10),
bbox_product_core=(55.55, 55.95, 37.30, 37.90),
city_token="москва",
cities=frozenset({"москва", "зеленоград", "троицк", "щербинка", "московский"}),
# Тиров обогащения у Москвы пока НЕТ ни одного: IMV/квартальный индекс/
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
enrichment_tiers=frozenset(),
),
}
DEFAULT_REGION_CODE = 66
def region_for_point(lat: float, lon: float) -> Region | None:
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
Регионы географически не пересекаются; порядок обхода детерминирован кодом
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
и это станет видно в тестах реестра, а не в проде).
"""
for code in sorted(REGIONS):
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
return REGIONS[code]
return None
def region_by_city(city: str | None) -> Region | None:
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
регистр, е/ё не различаются). None город не узнан ни одним регионом."""
if not city:
return None
token = " ".join(city.lower().replace("ё", "е").split())
for code in sorted(REGIONS):
r = REGIONS[code]
if token in r.cities or any(token == c.replace("ё", "е") for c in r.cities):
return r
return None
def unsupported_tier_reason(region: Region, tier: str) -> str | None:
"""None — тир доступен региону; иначе готовая строка для лога/маркера.
Смысл «регион без тира деградирует явно»: вызывающий код обязан
залогировать/пометить причину, а не молча посчитать без источника.
"""
if tier in region.enrichment_tiers:
return None
return (
f"тир '{tier}' недоступен региону {region.code} ({region.name}) — "
f"оценка идёт без него; доступные тиры: "
f"{sorted(region.enrichment_tiers) or 'нет ни одного'}"
)

View file

@ -3,7 +3,8 @@
No live Postgres needed DB is a minimal fake returning queued results (mirrors the
convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py).
Covers:
- pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity)
- pure functions: _category_weight, _covered_region (реестр #3051), _pct_deviation
(incl. monotonicity)
- _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module)
- compute_location_index: out-of-coverage degradation, insufficient-sample degradation
(citywide AND local), radius-ladder expansion, explicit radius_m override, happy path
@ -48,22 +49,25 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
def test_in_ekb_bbox_center_is_inside() -> None:
assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True
# #3051: _in_ekb_bbox → _covered_region (реестр регионов); граничные точки
# ниже — те же, что до реестра: продукт-ядро 66 сохранено байт-в-байт.
region = lc._covered_region(_LAT_IN_EKB, _LON_IN_EKB)
assert region is not None and region.code == 66
def test_in_ekb_bbox_bounds_are_inclusive() -> None:
assert lc._in_ekb_bbox(56.70, 60.50) is True
assert lc._in_ekb_bbox(56.95, 60.75) is True
assert lc._covered_region(56.70, 60.50) is not None
assert lc._covered_region(56.95, 60.75) is not None
def test_in_ekb_bbox_outside_is_rejected() -> None:
# Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox.
assert lc._in_ekb_bbox(57.910, 59.970) is False
assert lc._covered_region(57.910, 59.970) is None
# Just past each edge of the bbox.
assert lc._in_ekb_bbox(56.69, 60.60) is False
assert lc._in_ekb_bbox(56.96, 60.60) is False
assert lc._in_ekb_bbox(56.80, 60.49) is False
assert lc._in_ekb_bbox(56.80, 60.76) is False
assert lc._covered_region(56.69, 60.60) is None
assert lc._covered_region(56.96, 60.60) is None
assert lc._covered_region(56.80, 60.49) is None
assert lc._covered_region(56.80, 60.76) is None
def test_pct_deviation_above_and_below_city_median() -> None:

View file

@ -0,0 +1,136 @@
"""#3051 часть 1, шард 1a: реестр регионов — единственный источник гео-границ.
До реестра границы покрытия лежали литералами в трёх файлах (location_index /
geocoder / matching.normalize), и каждая молча отвергла бы Москву. Теперь
литералы живут ТОЛЬКО в app.services.regions; потребители держат прежние имена
как алиасы на объекты реестра (identity, не копии см. test_no_local_copies).
Приёмка задачи «московская координата не out_of_coverage» красная на
origin/main ПО ЗНАЧЕНИЮ: там compute_location_index возвращает out_of_coverage,
с реестром insufficient_data (регион узнан, данных Москвы пока ноль честно).
Поведение региона 66 закреплено байт-в-байт (bbox'ы равны прежним литералам).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import MagicMock
from app.services import geocoder, location_index, regions
from app.services.matching import normalize
# ── 1. Инварианты реестра ────────────────────────────────────────────────────
def _contains(outer: regions.BBox, inner: regions.BBox) -> bool:
return (
outer[0] <= inner[0]
and outer[1] >= inner[1]
and outer[2] <= inner[2]
and outer[3] >= inner[3]
)
def test_bbox_nesting_invariants() -> None:
"""region ⊇ wide ⊇ tight и region ⊇ product_core — у КАЖДОГО региона."""
for r in regions.REGIONS.values():
assert _contains(r.bbox_wide, r.bbox_tight), r.code
assert _contains(r.bbox_region, r.bbox_wide), r.code
assert _contains(r.bbox_region, r.bbox_product_core), r.code
def test_regions_do_not_overlap() -> None:
"""Центры продукт-ядер каждого региона не попадают в чужой bbox_region."""
for r in regions.REGIONS.values():
core = r.bbox_product_core
center = ((core[0] + core[1]) / 2, (core[2] + core[3]) / 2)
for other in regions.REGIONS.values():
if other.code != r.code:
assert not regions.is_within_bbox(*center, other.bbox_region), (
f"центр {r.code} внутри bbox_region {other.code}"
)
# ── 2. Поведение региона 66 закреплено байт-в-байт ───────────────────────────
def test_region66_values_are_the_historical_literals() -> None:
"""Смена значений 66 = смена поведения гео-трактов — отдельное решение,
этот PR только ПЕРЕНОСИТ литералы. Тест не даст «подкрутить по пути»."""
r66 = regions.REGIONS[66]
assert r66.bbox_tight == (56.65, 56.95, 60.40, 60.85)
assert r66.bbox_wide == (56.6, 57.1, 60.3, 60.9)
assert r66.bbox_region == (55.8, 62.2, 56.7, 66.6)
assert r66.bbox_product_core == (56.70, 56.95, 60.50, 60.75)
assert r66.city_token == "екатеринбург"
def test_no_local_copies_in_consumers() -> None:
"""Потребители держат ИМЕННО объекты реестра (identity), не копии —
копия разъехалась бы при следующей правке (ровно так жила дыра #2656)."""
r66 = regions.REGIONS[66]
assert geocoder.EKB_BBOX_TIGHT is r66.bbox_tight
assert geocoder.EKB_BBOX_WIDE is r66.bbox_wide
assert geocoder.OBLAST66_BBOX is r66.bbox_region
assert geocoder.SVERDLOVSK_OBLAST_CITIES is r66.cities
assert normalize.EKB_CITY_TOKEN is r66.city_token
# ── 3. Приёмка: Москва больше не out_of_coverage ─────────────────────────────
def _empty_db() -> MagicMock:
"""db, у которой любой SELECT пуст (mappings().first() → None)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
db.execute.return_value.mappings.return_value.all.return_value = []
return db
def test_moscow_point_is_covered_not_rejected() -> None:
"""55.75/37.62 (центр Москвы): регион узнан → считаем; данных нет →
insufficient_data. На origin/main здесь out_of_coverage (красный по значению)."""
res = location_index.compute_location_index(_empty_db(), 55.75, 37.62)
assert res.status == "insufficient_data", (
f"Москва отвергнута статусом {res.status!r} — реестр не подключён"
)
def test_ekb_point_behaviour_unchanged() -> None:
"""ЕКБ-центр: та же пустая db → insufficient_data (инвариант обеих эр)."""
res = location_index.compute_location_index(_empty_db(), 56.8300, 60.6000)
assert res.status == "insufficient_data"
def test_point_outside_all_cores_still_rejected() -> None:
"""Точка в tight-66, но ЮЖНЕЕ продукт-ядра (56.68 < 56.70) → по-прежнему
out_of_coverage: ядро 66 сохранено байт-в-байт, покрытие НЕ расширилось
молча (инвариант обеих эр). И Пермь вне всего (инвариант)."""
res = location_index.compute_location_index(_empty_db(), 56.68, 60.60)
assert res.status == "out_of_coverage"
res2 = location_index.compute_location_index(_empty_db(), 58.01, 56.25)
assert res2.status == "out_of_coverage"
# ── 4. Города и тиры ─────────────────────────────────────────────────────────
def test_region_by_city_folds_and_resolves() -> None:
assert regions.region_by_city("Екатеринбург").code == 66
assert regions.region_by_city("Москва").code == 77
assert regions.region_by_city("Берёзовский").code == 66 # ё-фолд
assert regions.region_by_city("березовский").code == 66
assert regions.region_by_city("Пермь") is None
assert regions.region_by_city(None) is None
def test_unsupported_tier_degrades_loudly_not_silently() -> None:
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
формулировкой, а не молчаливое «посчитаем без источника»."""
r77, r66 = regions.REGIONS[77], regions.REGIONS[66]
assert regions.unsupported_tier_reason(r66, regions.TIER_AVITO_IMV) is None
reason = regions.unsupported_tier_reason(r77, regions.TIER_AVITO_IMV)
assert reason is not None and "77" in reason and "avito_imv" in reason