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
180 lines
10 KiB
Python
180 lines
10 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 —
|
||
ЗА ИСКЛЮЧЕНИЕМ пары (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
|