gendesign/tradein-mvp/backend/tests/test_3051_region_registry_moscow_oblast.py
lekss361 3d114672fe
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m49s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / deploy (push) Successful in 6m59s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
Регион точки резолвится по настоящей границе, а не по прямоугольнику (#3052) (#3522)
2026-09-15 06:44:31 +00:00

165 lines
8.8 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, регион 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