Some checks failed
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m39s
Границы покрытия лежали литералами в трёх файлах (location_index / geocoder / matching.normalize), и каждая молча отвергла бы Москву. Новый модуль app.services.regions — лист дерева импортов — держит per-регион bbox'ы (tight/wide/region/product_core), города, city_token и набор доступных тиров обогащения; потребители держат прежние имена как алиасы на объекты реестра (identity закреплена тестом — копии, разъезжающиеся при правке, невозможны). Регион 66 — байт-в-байт прежние литералы (закреплено тестом: этот PR только переносит границы, менять их = отдельное решение). Регион 77 (Москва): МКАД- ядро + генеральный bbox с Новой Москвой и Зеленоградом; тиров обогащения НЕТ ни одного — и это явный факт реестра с готовой формулировкой (unsupported_tier_reason), а не молчаливое «посчитаем без источника». Приёмка #3051: точка 55.75/37.62 больше не out_of_coverage — location_index узнаёт регион 77 и считает в его ядре (сегодня листингов Москвы нет → честный insufficient_data). Область 50 отложена по решению в #2996. Не здесь (следующие шарды): city_fias_id сквозняком (п.2), doc_type в deals (п.3), region_code у houses (п.4), депромоут описаний (п.5), параметры загрузчиков (п.6). Гейт ЕКБ-тиров геокодера (#2582) уже деградирует правильно для Москвы — fail-closed открывает их только при подтверждённом ЕКБ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
7.2 KiB
Python
136 lines
7.2 KiB
Python
"""#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
|