"""#3051, регион 50 (Московская область) + фикс порядка обхода реестра. `region_for_point`/`region_by_city` раньше обходили `sorted(REGIONS)` — «первый по коду выигрывает». Это работало, пока регионы физически не пересекались (66 Урал и 77 Москва — тысячи км друг от друга). Регион 50 ломает допущение: bbox_region(50) (Московская область целиком) геометрически СОДЕРЖИТ bbox_region(77) (Москва) как прямоугольники, а 50 < 77 по числовому коду — наивный код-порядок отправил бы ВСЕ точки Москвы (включая центр) в регион 50. Фикс — `_POINT_LOOKUP_ORDER`: обход по площади bbox_region по возрастанию (компактный регион проверяется раньше объемлющего), без ручного списка. Область на дату этого PR — тир обогащения пуст (frozenset()), в deals/listings 0 строк (импорт Росреестра из FDW — отдельный PR). Тесты ниже проверяют РЕЕСТР (геометрию/классификацию), не данные. #3052: резолв внутри `region_for_point` перестал быть чисто bbox-based — `_POINT_LOOKUP_ORDER` (регрессия ниже) остался прежним дешёвым предфильтром, но финальный ответ теперь даёт настоящий полигон границы региона. Тест на Химки/Балашиху обновлён под это (было «известное ограничение» bbox, теперь — исправленное поведение). """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services import regions # ── 1. Регрессия на порядок обхода (главный тест) ─────────────────────────── def test_lookup_order_is_by_bbox_area_not_by_code() -> None: """77 (Москва, самый маленький bbox_region) проверяется первым, 66 (Урал, самый большой) — последним. Порядок НЕ (50, 66, 77) — это был бы код.""" assert regions._POINT_LOOKUP_ORDER == (77, 50, 66) def test_moscow_center_resolves_to_77_not_50() -> None: """Точка в центре Москвы лежит внутри bbox_region И 77, И 50 одновременно — регрессия на сам баг: наивный код-порядок (50 < 77) увёл бы её в 50.""" res = regions.region_for_point(55.75, 37.62) assert res is not None assert res.code == 77 # Инвариант, который делает это регрессией, а не совпадением: assert regions.is_within_bbox(55.75, 37.62, regions.REGIONS[50].bbox_region) assert regions.is_within_bbox(55.75, 37.62, regions.REGIONS[77].bbox_region) def test_novaya_moskva_resolves_to_77() -> None: """Посёлок Птичное (Новая Москва) — административно Москва, не область.""" res = regions.region_for_point(55.52, 37.21) assert res is not None assert res.code == 77 def test_far_moscow_oblast_points_resolve_to_50() -> None: """Точки вне генерального bbox 77 однозначно — дальнее Подмосковье.""" for lat, lon in [ (54.9152, 37.4166), # Серпухов (56.3430, 37.5228), # Дмитров (55.7889, 38.4458), # Электросталь ]: res = regions.region_for_point(lat, lon) assert res is not None and res.code == 50, (lat, lon, res) def test_ekb_point_still_resolves_to_66() -> None: """Регион 66 не сломан добавлением 50/переходом на area-порядок.""" res = regions.region_for_point(56.8300, 60.6000) assert res is not None assert res.code == 66 def test_point_outside_all_regions_is_none() -> None: res = regions.region_for_point(58.01, 56.25) # Пермь assert res is None def test_khimki_balashikha_resolve_to_50_via_polygon() -> None: """#3052: bbox_region(77) — генеральный fallback-net Москвы, по долготе тянется до 38.10 (~13 км восточнее МКАД). Химки (55.91, 37.41) и Балашиха (55.7965, 37.9388) физически лежат внутри ЭТОГО прямоугольника, хотя административно это область — до #3052 region_for_point (bbox-based) резолвил их в 77 («известное ограничение», см. issue #3052: 8 647 московских лотов уходили в отказ по покрытию, а 11 172 областные сделки ложно попадали в московское ядро). С настоящим полигоном (bbox остался только дешёвым предфильтром — см. regions.region_for_point) обе точки физически вне контура Москвы → резолвятся в 50, как и region_by_city.""" khimki = regions.region_for_point(55.91, 37.41) assert khimki is not None and khimki.code == 50 balashikha = regions.region_for_point(55.7965, 37.9388) assert balashikha is not None and balashikha.code == 50 # ── 2. region_by_city ───────────────────────────────────────────────────── def test_region_by_city_resolves_moscow_oblast_cities() -> None: for city in ("Красногорск", "Балашиха", "химки", "Серпухов", "Ногинск"): res = regions.region_by_city(city) assert res is not None and res.code == 50, city def test_region_by_city_moscow_and_ekb_unaffected() -> None: assert regions.region_by_city("Москва").code == 77 assert regions.region_by_city("Екатеринбург").code == 66 assert regions.region_by_city("Пермь") is None def test_no_city_name_duplicated_across_regions() -> None: """Если бы Химки/Балашиха и т.п. попали в `cities` двух регионов — region_by_city резолвил бы их по меньшему коду. Список 50 сверен вручную с 66/77 — пересечений нет.""" seen: dict[str, int] = {} for code, r in regions.REGIONS.items(): for city in r.cities: key = city.replace("ё", "е") assert key not in seen or seen[key] == code, ( f"'{city}' в cities и региона {seen.get(key)}, и региона {code}" ) seen[key] = code # ── 3. Тиры обогащения и bbox-инварианты региона 50 ────────────────────────── def test_moscow_oblast_has_no_enrichment_tiers_and_degrades_loudly() -> None: r50 = regions.REGIONS[50] assert r50.enrichment_tiers == frozenset() for tier in ( regions.TIER_AVITO_IMV, regions.TIER_YANDEX_VALUATION, regions.TIER_CIAN_VALUATION, regions.TIER_QUARTER_INDEX, regions.TIER_SBER_INDEX, ): reason = regions.unsupported_tier_reason(r50, tier) assert reason is not None and "50" in reason and tier in reason def test_moscow_oblast_canonical_city_is_none() -> None: """В отличие от 77 (Росреестр отдаёт округ/поселение) — источники по области несут настоящий city (Химки, Балашиха), перезаписывать нечего.""" assert regions.REGIONS[50].canonical_city is None def test_moscow_oblast_bbox_nesting() -> None: r50 = regions.REGIONS[50] 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] ) assert _contains(r50.bbox_wide, r50.bbox_tight) assert _contains(r50.bbox_region, r50.bbox_wide) assert _contains(r50.bbox_region, r50.bbox_product_core) # tight == product_core для 50 (нет отдельного «города-ядра» — см. docstring). assert r50.bbox_tight == r50.bbox_product_core