Files
enduro-trails/tests/unit/test_gps_tracks_enduro_russia.py
claude-bot 3577ff32ac
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
feat(ET-009): activate EnduroRussia + Wikiloc GPS sources
Конфиг-only активация двух новых источников GPS-треков поверх
pipeline ET-008. Не вводит новых компонентов, БД-таблиц, endpoint'ов.

Config:
- config/gps_sources.yaml: enduro_russia enabled=true, base_url исправлен
  на endurorussia.ru (без дефиса); добавлена запись wikiloc с
  max_tracks_per_run=50, activity_filter=[motorcycle, enduro].
- config/gps_regions.yaml: wikiloc добавлен в tsfo_plus_chuvashia.sources.

Parser:
- wikiloc.py: добавлен soft-cap max_tracks_per_run в collect(),
  извлечение created_at из GPX metadata/первого trkpt — для корректной
  межисточниковой дедупликации с EnduroRussia.

UI (src/web/gps_tracks.js):
- GPS_SOURCE_COLORS: добавлен цвет wikiloc (#4363d8).
- Дефолтный фильтр sources включает wikiloc.
- GPS_SOURCE_ATTRIBUTIONS: маппинг source_id → строка атрибуции;
  _updateGpsAttribution() подтягивает /api/gps-tracks/health и
  выставляет attribution с теми источниками, у которых tracks > 0.
- _buildGpsFiltersUI: чекбокс «Wikiloc» в #gps-source-grid.

Tests:
- Fixtures: 7 файлов в tests/fixtures/gps-tracks/.
- Unit: 10 UT-ER + 10 UT-WL — парсеры, MAPPING, bbox-фильтр,
  pagination, 429/403 graceful-stop, rate-limit, max_tracks_per_run.
- Integration: IT-ER-01, IT-WL-01, IT-WL-02, IT-DEDUP-01, IT-LIC-01
  через scripts.gps_collect.main + httpx.MockTransport.
- Contract: 2 CT-ER с маркером @pytest.mark.network (nightly only).
- JS: 2 новых теста на наличие wikiloc в SOURCE_COLORS и в фильтрах.

Linters/Tests: ruff clean (новые файлы), 166 pytest passed,
24 JS-tests passed.

Refs: ET-009
Acceptance: AC-01..AC-08, AC-14..AC-17 (для AC-09..AC-13 — продакшн-прогон)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:38:55 +00:00

265 lines
11 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.
"""Unit tests for EnduroRussiaParser (ET-009).
Coverage:
- UT-ER-01: _parse_gpx success on valid GPX fixture
- UT-ER-02: _parse_gpx returns None on empty GPX
- UT-ER-03: bbox filter rejects out-of-bbox track
- UT-ER-04: MAPPING translates categories correctly
- UT-ER-05: base_url without dash preserved (regression R-4)
- UT-ER-06: pagination stops when fetched_so_far >= total
- UT-ER-07: HTTP 429 on /api/tracks → graceful return
- UT-ER-08: HTTP 429 on /api/tracks/{id}/gpx → graceful return, earlier tracks preserved
"""
import json
import os
from typing import Callable
import httpx
from src.api.gps_tracks.sources.enduro_russia import (
EnduroRussiaParser,
_bbox_intersects,
_parse_gpx,
)
from src.api.gps_tracks.sources import enduro_russia as er_module
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures", "gps-tracks")
# Region bbox for ЦФО+Чувашия
BBOX_TSFO = (29.0, 49.5, 47.5, 60.0)
def _read_fixture(name: str) -> bytes:
with open(os.path.join(FIXTURES_DIR, name), "rb") as f:
return f.read()
def _make_config(**overrides) -> dict:
cfg = {
"id": "enduro_russia",
"base_url": "https://endurorussia.ru",
"rate_limit_sec": 0, # speed up tests
"user_agent": "test-agent",
"source_priority": 80,
}
cfg.update(overrides)
return cfg
def _patch_client(monkeypatch, handler: Callable[[httpx.Request], httpx.Response]) -> None:
"""Подменяет httpx.AsyncClient в модуле enduro_russia на клиент с MockTransport."""
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
def factory(*args, **kwargs):
kwargs["transport"] = transport
return original(*args, **kwargs)
monkeypatch.setattr(er_module.httpx, "AsyncClient", factory)
async def _collect_all(parser, bbox):
"""Собирает все треки из async-генератора."""
tracks = []
async for t in parser.collect(bbox, {}):
tracks.append(t)
return tracks
# ─── UT-ER-01 ───────────────────────────────────────────────────────────────
def test_ut_er_01_parse_gpx_track1_success():
"""UT-ER-01: _parse_gpx на track-1 → TrackInsert с points_count ≥ 10."""
content = _read_fixture("enduro-russia-track-1.gpx")
meta = {"name": "Маршрут Дмитровский", "difficulty": "hard", "created_at": "2024-08-15 12:30:00"}
track = _parse_gpx(
content,
track_id=1,
meta=meta,
source_id="enduro_russia",
base_url="https://endurorussia.ru",
source_priority=80,
mapping=EnduroRussiaParser.MAPPING,
)
assert track is not None
assert track.points_count >= 10
assert track.length_m > 0
assert track.min_lon < track.max_lon
assert track.min_lat < track.max_lat
assert track.external_url == "https://endurorussia.ru/tracks/1"
assert track.source_id == "enduro_russia"
# difficulty 'hard' → enduro
assert track.activity_type == "enduro"
# ─── UT-ER-02 ───────────────────────────────────────────────────────────────
def test_ut_er_02_parse_gpx_empty_returns_none():
"""UT-ER-02: _parse_gpx на пустом GPX → None."""
content = _read_fixture("enduro-russia-track-2.gpx")
track = _parse_gpx(
content,
track_id=2,
meta={},
source_id="enduro_russia",
base_url="https://endurorussia.ru",
source_priority=80,
mapping=EnduroRussiaParser.MAPPING,
)
assert track is None
# ─── UT-ER-03 ───────────────────────────────────────────────────────────────
def test_ut_er_03_bbox_filter_rejects_outside():
"""UT-ER-03: track-3 за пределами bbox ЦФО → _bbox_intersects False."""
content = _read_fixture("enduro-russia-track-3.gpx")
track = _parse_gpx(
content,
track_id=3,
meta={},
source_id="enduro_russia",
base_url="https://endurorussia.ru",
source_priority=80,
mapping=EnduroRussiaParser.MAPPING,
)
assert track is not None # парсится, но bbox не пересекается
intersects = _bbox_intersects(
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
BBOX_TSFO,
)
assert intersects is False
async def test_ut_er_03_collect_skips_out_of_bbox(monkeypatch):
"""UT-ER-03 (collect): out-of-bbox трек не yield-ится."""
api_data = json.loads(_read_fixture("enduro-russia-api-tracks-page1.json"))
# Оставим только один трек id=3 (вне bbox)
api_data = {"items": [it for it in api_data["items"] if it["id"] == 3], "total": 1, "page": 0}
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/api/tracks":
return httpx.Response(200, json=api_data)
if req.url.path == "/api/tracks/3/gpx":
return httpx.Response(200, content=_read_fixture("enduro-russia-track-3.gpx"))
return httpx.Response(404)
_patch_client(monkeypatch, handler)
parser = EnduroRussiaParser(_make_config())
tracks = await _collect_all(parser, BBOX_TSFO)
assert tracks == []
# ─── UT-ER-04 ───────────────────────────────────────────────────────────────
def test_ut_er_04_mapping_categories():
"""UT-ER-04: MAPPING маппит ключевые категории."""
m = EnduroRussiaParser.MAPPING
assert m["hard"] == "enduro"
assert m["soft"] == "enduro"
assert m["мото"] == "moto"
# 'unknown' нет в MAPPING → map_activity → 'other'
parser = EnduroRussiaParser(_make_config())
assert parser.map_activity("unknown") == "other"
# ─── UT-ER-05 ───────────────────────────────────────────────────────────────
def test_ut_er_05_base_url_no_dash_preserved():
"""UT-ER-05: base_url 'https://endurorussia.ru' сохраняется без замены."""
cfg = _make_config(base_url="https://endurorussia.ru")
parser = EnduroRussiaParser(cfg)
assert parser.config["base_url"] == "https://endurorussia.ru"
# Регрессия: проверим что в default fallback тоже без дефиса
parser_no_url = EnduroRussiaParser({"id": "enduro_russia"})
# default используется в collect() — но base_url берётся через get()
assert "enduro-russia" not in parser_no_url.config.get("base_url", "https://endurorussia.ru")
async def test_ut_er_05_collect_uses_no_dash_url(monkeypatch):
"""UT-ER-05 (collect): HTTP-запросы уходят на endurorussia.ru (без дефиса)."""
seen_hosts = []
def handler(req: httpx.Request) -> httpx.Response:
seen_hosts.append(req.url.host)
if req.url.path == "/api/tracks":
return httpx.Response(200, json={"items": [], "total": 0, "page": 0})
return httpx.Response(404)
_patch_client(monkeypatch, handler)
parser = EnduroRussiaParser(_make_config(base_url="https://endurorussia.ru"))
await _collect_all(parser, BBOX_TSFO)
assert any(h == "endurorussia.ru" for h in seen_hosts)
assert not any("enduro-russia" in h for h in seen_hosts)
# ─── UT-ER-06 ───────────────────────────────────────────────────────────────
async def test_ut_er_06_pagination_stops_at_total(monkeypatch):
"""UT-ER-06: collect() делает 1 запрос /api/tracks при total=5, items=5."""
api_data = json.loads(_read_fixture("enduro-russia-api-tracks-page1.json"))
list_calls = []
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/api/tracks":
list_calls.append(req.url.query.decode() if isinstance(req.url.query, bytes) else str(req.url.query))
return httpx.Response(200, json=api_data)
# GPX: вернём пустой (None) или валидный для track-1
return httpx.Response(200, content=_read_fixture("enduro-russia-track-2.gpx"))
_patch_client(monkeypatch, handler)
parser = EnduroRussiaParser(_make_config())
await _collect_all(parser, BBOX_TSFO)
assert len(list_calls) == 1, f"expected 1 /api/tracks call, got {len(list_calls)}: {list_calls}"
# ─── UT-ER-07 ───────────────────────────────────────────────────────────────
async def test_ut_er_07_http_429_on_tracks_list_graceful(monkeypatch):
"""UT-ER-07: 429 на /api/tracks → завершение без exception, 0 треков."""
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/api/tracks":
return httpx.Response(429, json={"error": "Too Many Requests"})
return httpx.Response(404)
_patch_client(monkeypatch, handler)
parser = EnduroRussiaParser(_make_config())
tracks = await _collect_all(parser, BBOX_TSFO)
assert tracks == []
# ─── UT-ER-08 ───────────────────────────────────────────────────────────────
async def test_ut_er_08_http_429_on_gpx_graceful(monkeypatch):
"""UT-ER-08: 429 на /api/tracks/{id}/gpx после первых OK → ранние треки сохраняются."""
# Соберём API ответ с двумя треками: 1 (OK) и 2 (429)
api_data = {
"items": [
{"id": 1, "name": "T1", "difficulty": "hard", "created_at": "2024-08-15 12:30:00"},
{"id": 2, "name": "T2", "difficulty": "hard", "created_at": "2024-08-15 13:00:00"},
],
"total": 2,
"page": 0,
}
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/api/tracks":
return httpx.Response(200, json=api_data)
if req.url.path == "/api/tracks/1/gpx":
return httpx.Response(200, content=_read_fixture("enduro-russia-track-1.gpx"))
if req.url.path == "/api/tracks/2/gpx":
return httpx.Response(429)
return httpx.Response(404)
_patch_client(monkeypatch, handler)
parser = EnduroRussiaParser(_make_config())
tracks = await _collect_all(parser, BBOX_TSFO)
# Ранний трек должен сохраниться
assert len(tracks) == 1
assert tracks[0].external_url.endswith("/tracks/1")