gendesign/backend/tests/test_poi_loader.py
bot-backend 33b72bac2e feat(site-finder): poi_loader грузит POI не только по Екатеринбургу
Блок «что рядом» в оценке молчал для Москвы — poi_loader.py тянул Overpass
строго по EKB_BBOX константе. Параметризовал регионом: REGION_BBOX содержит
"ekb" (дефолт, поведение weekly-расписания tasks.poi_sync.sync_osm_poi_ekb не
меняется) и "msk" (bbox_product_core региона 77 из tradein-mvp regions.py,
продублирован явно — Site Finder не импортирует tradein-mvp).

Живой замер Overpass (13.09.2026, count-only + один тайл) показал, что просто
разрешить region было бы недостаточно: в Москве 12 181 bus_stop — больше, чем
ВСЕ 14 категорий ЕКБ вместе (4 850) — и уже один uniform-тайл 0.25×0.25° (размер
ЕКБ) вернул 504 на этой категории. Единый безопасный размер тайла заранее не
подобрать — плотность Москвы неравномерна (плотный центр / разреженная
периферия), а у Overpass нет сигнала "это частичный ответ", кроме HTTP-ошибки.
Поэтому вместо фиксированного тайла — адаптивное дробление: тайл, дважды
упавший в Overpass, дробится на 4 четверти и перезапрашивается рекурсивно (до
RECURSIVE_SPLIT_MAX_DEPTH=3), вместо того чтобы тихо потерять данные по тайлу.
Для ЕКБ (единственный тайл исторически всегда отвечал 200) рекурсия не
срабатывает — поведение дефолта не меняется.

Таблицу osm_poi_ekb НЕ переименовывал: она читается ещё в двух местах вне
Site Finder (FDW gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local
в tradein), переименование задело бы миграции обеих половин ради красоты
имени. tradein-mvp/backend/app/tasks/osm_poi_ekb_refresh.py менять не
потребовалось — зеркало уже делает безусловный full-scan FDW-таблицы без
фильтра по bbox, так что московские точки подхватятся тем же кодом.

Загрузку Москвы в прод не запускал — по заданию это делает главная сессия:
sync_poi_to_db(region="msk").
2026-09-13 15:44:35 +03:00

178 lines
7.7 KiB
Python
Raw 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.

"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
Mock-based / pure — БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
к Overpass в тестах недопустимы). Покрывает:
- REGION_BBOX / DEFAULT_REGION — дефолт остаётся "ekb", не меняется молча.
- _bbox_tiles — для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы — несколько
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
- _build_overpass_query — bbox теперь параметр, а не глобальная константа.
- _fetch_category — retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.site_finder.poi_loader import (
DEFAULT_REGION,
EKB_BBOX,
RECURSIVE_SPLIT_MAX_DEPTH,
REGION_BBOX,
_bbox_tiles,
_build_overpass_query,
_fetch_category,
_split_bbox_quadrants,
)
class _FakeResponse:
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
self._ok = ok
self._elements = elements or []
def raise_for_status(self) -> None:
if not self._ok:
raise RuntimeError("simulated Overpass failure")
def json(self) -> dict:
return {"elements": self._elements}
@pytest.fixture
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
уровне рекурсии)."""
async def _instant(_seconds: float) -> None:
return None
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
def test_default_region_is_ekb_unchanged() -> None:
assert DEFAULT_REGION == "ekb"
assert REGION_BBOX["ekb"] == EKB_BBOX
def test_region_bbox_has_msk_product_core() -> None:
assert "msk" in REGION_BBOX
south, west, north, east = REGION_BBOX["msk"]
assert south < north
assert west < east
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
tiles = _bbox_tiles(EKB_BBOX)
assert tiles == [EKB_BBOX]
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
bbox = REGION_BBOX["msk"]
tiles = _bbox_tiles(bbox)
assert len(tiles) > 1
south, west, north, east = bbox
total_area = (north - south) * (east - west)
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
assert tiles_area == pytest.approx(total_area, rel=1e-9)
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
def test_split_bbox_quadrants_covers_original_area() -> None:
bbox = (55.55, 37.30, 55.95, 37.90)
quads = _split_bbox_quadrants(bbox)
assert len(quads) == 4
south, west, north, east = bbox
total_area = (north - south) * (east - west)
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
assert quads_area == pytest.approx(total_area, rel=1e-9)
# ── _build_overpass_query ─────────────────────────────────────────────────────
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
assert "(1.0,2.0,3.0,4.0)" in q
assert '["amenity"="pharmacy"]' in q
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
if calls["n"] == 1:
return _FakeResponse(ok=False)
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1))
assert calls["n"] == 2
assert len(result) == 1
assert result[0]["_gd_category"] == "pharmacy"
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
instant_sleep: None,
) -> None:
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
query = data["data"]
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
return _FakeResponse(ok=False)
return _FakeResponse(
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0)
)
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
assert calls["n"] == 2 + 4
assert len(result) == 4
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
instant_sleep: None,
) -> None:
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
и возвращает пустой список — не зацикливается и не падает."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data # параметр используется — сигнатура должна совпадать с client.post
return _FakeResponse(ok=False)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0)
)
assert result == []
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
assert calls["n"] == expected_nodes * 2