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