gendesign/tradein-mvp/backend/tests/test_3051_region_registry.py
bot-backend 0529bb249c
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
feat(tradein/geo): реестр регионов — единственный источник гео-границ (#3051, шард 1a)
Границы покрытия лежали литералами в трёх файлах (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>
2026-08-26 18:39:28 +05:00

136 lines
7.2 KiB
Python
Raw Permalink 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.

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