gendesign/tradein-mvp/backend/tests/test_3051_region_registry.py
lekss361 cef872ace1
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m34s
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Московская область в реестре регионов, обход — по специфичности вместо кода (#3501)
2026-09-12 13:59:43 +00:00

180 lines
10 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.

"""#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 —
ЗА ИСКЛЮЧЕНИЕМ пары (50, 77): регион 50 (Московская область) admin-bbox'ом
геометрически СОДЕРЖИТ регион 77 (Москва) целиком, это ожидаемая вложенность,
не баг (см. #3051 Moscow-oblast, tests/test_3051_region_registry_moscow_oblast.py
— там же настоящий инвариант для вложенной пары: `region_for_point` обязан
резолвить точку во ВЛАДЕЮЩИЙ, более специфичный регион по площади bbox, а не
по числовому коду). 66 (Урал) физически за тысячи км от 50/77 — для него
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе."""
nested_pairs = {(50, 77), (77, 50)}
# Исключение из инварианта обязано опираться на ДОКАЗАННУЮ вложенность, а не
# на допущение в докстринге: сначала проверяем, что bbox_region(50)
# действительно содержит bbox_region(77) как множество, и только потом
# разрешаем этой паре не соблюдать «центры не пересекаются». Если 77 когда-
# нибудь выедет за границы области, тест упадёт ЗДЕСЬ — с понятной причиной,
# а не молча пропустит настоящее пересечение через список исключений.
oblast_lat_min, oblast_lat_max, oblast_lon_min, oblast_lon_max = regions.REGIONS[50].bbox_region
msk_lat_min, msk_lat_max, msk_lon_min, msk_lon_max = regions.REGIONS[77].bbox_region
assert oblast_lat_min <= msk_lat_min and msk_lat_max <= oblast_lat_max, (
"bbox_region(77) выехал за широты bbox_region(50) — исключение (50,77) больше "
"не обосновано вложенностью"
)
assert oblast_lon_min <= msk_lon_min and msk_lon_max <= oblast_lon_max, (
"bbox_region(77) выехал за долготы bbox_region(50) — исключение (50,77) больше "
"не обосновано вложенностью"
)
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 or (r.code, other.code) in nested_pairs:
continue
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_canonical_city_and_region_for_point() -> None:
"""canonical_city: 66 None (byte-for-byte прежнее) / 77 'Москва' (Росреестр отдаёт
округ/поселение, не город). region_for_point резолвит регион по bbox_region —
точка в Москве → 77, в ЕКБ → 66, вне обоих (Пермь) → None.
"""
assert regions.REGIONS[66].canonical_city is None
assert regions.REGIONS[77].canonical_city == "Москва"
moscow = regions.region_for_point(55.75, 37.62)
assert moscow is not None and moscow.code == 77
ekb = regions.region_for_point(56.8300, 60.6000)
assert ekb is not None and ekb.code == 66
assert regions.region_for_point(58.01, 56.25) 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