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
165 lines
8.8 KiB
Python
165 lines
8.8 KiB
Python
"""#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
|