Files
enduro-trails/tests/api/test_gps_tracks_mvt.py
claude-bot 0060003f28
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped
feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
  (bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
  (GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
   GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
  enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc

Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
  debounced fetch + AbortController, фильтры активности/источника,
  цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
  restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
  applyGpsHaloVisibility() в applyBaseLayer()

Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)

Docker:
- gps-collector service с profiles: [batch]

Тесты: 48 новых тестов (unit + integration), 125/125 pass

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:28:54 +00:00

172 lines
6.4 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 тесты для MVT тайлов GPS-треков (ET-008).
U-50: тайл z=10 с треками
U-51: упрощение на z=7
U-52: min-length фильтр
"""
import json
import pytest
from shapely.geometry import LineString
from shapely import wkb
from src.api.gps_tracks.mvt import build_gps_mvt, _simplify_coords, _wkb_to_coords
def _make_mock_row(
track_id=1,
activity_type="enduro",
source_id="osm",
length_m=8000.0,
name="Test Track",
coords=None,
min_lon=37.60,
min_lat=55.74,
max_lon=37.65,
max_lat=55.78,
):
"""Создаёт mock sqlite3.Row как словарь."""
if coords is None:
coords = [
(min_lon, min_lat),
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
(max_lon, max_lat),
]
geom_wkb = wkb.dumps(LineString(coords))
# Имитируем sqlite3.Row через dict с поддержкой подписки
class MockRow(dict):
def __getitem__(self, key):
return super().__getitem__(key)
return MockRow({
"id": track_id,
"activity_type": activity_type,
"sources_json": json.dumps([source_id]),
"external_urls_json": json.dumps([]),
"length_m": length_m,
"name": name,
"geom": geom_wkb,
})
# ─── U-50: тайл z=10 с треками ───────────────────────────────────────────────
def test_u50_tile_z10_with_tracks():
"""U-50: build_gps_mvt возвращает непустой тайл при наличии треков."""
rows = [
_make_mock_row(1, "enduro", "osm", length_m=8000),
_make_mock_row(2, "moto", "osm", length_m=5000,
min_lon=37.61, min_lat=55.75, max_lon=37.62, max_lat=55.76),
]
# Тайл z=10, x=620, y=320 — область Москвы
result = build_gps_mvt(rows, z=10, x=620, y=320)
assert isinstance(result, bytes)
assert len(result) > 0
def test_u50_empty_rows_returns_empty_bytes():
"""U-50: пустой список строк возвращает b""."""
result = build_gps_mvt([], z=10, x=620, y=320)
assert result == b""
def test_u50_invalid_geom_row_skipped():
"""U-50: строка с невалидной геометрией пропускается."""
class BadRow(dict):
pass
bad_row = BadRow({
"id": 99,
"activity_type": "other",
"sources_json": '["osm"]',
"external_urls_json": "[]",
"length_m": 5000,
"name": "bad",
"geom": b"\x00\x01\x02", # невалидный WKB
})
good_row = _make_mock_row(1, length_m=5000)
result = build_gps_mvt([bad_row, good_row], z=10, x=620, y=320)
# Не падает, плохая строка пропускается
assert isinstance(result, bytes)
# ─── U-51: упрощение на z=7 ──────────────────────────────────────────────────
def test_u51_simplification_z7_reduces_points():
"""U-51: геометрия упрощается на малых зумах."""
# Создаём трек из 20 точек
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(20)]
simplified = _simplify_coords(coords, z=7)
# При z=7 tolerance=0.008, ожидаем меньше точек
assert len(simplified) < len(coords)
assert len(simplified) >= 2
def test_u51_no_simplification_z12():
"""U-51: на z=12 упрощение не применяется."""
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(10)]
result = _simplify_coords(coords, z=12)
assert result == coords
def test_u51_simplification_z10_moderate():
"""U-51: на z=10 умеренное упрощение."""
coords = [(37.60 + i * 0.0001, 55.74 + i * 0.0001) for i in range(30)]
simplified_z10 = _simplify_coords(coords, z=10)
simplified_z7 = _simplify_coords(coords, z=7)
# z=7 должен сильнее упрощать, чем z=10
assert len(simplified_z10) >= len(simplified_z7)
# ─── U-52: min-length фильтр ─────────────────────────────────────────────────
def test_u52_min_length_filter_z7():
"""U-52: на z<=7 треки короче 2000м отфильтровываются."""
short_track = _make_mock_row(1, length_m=1500) # меньше 2000м
long_track = _make_mock_row(2, length_m=5000) # больше 2000м
result_with_short = build_gps_mvt([short_track, long_track], z=7, x=77, y=40)
result_without_short = build_gps_mvt([long_track], z=7, x=77, y=40)
# Результаты должны совпадать (короткий трек отфильтрован)
assert result_with_short == result_without_short
def test_u52_no_min_length_filter_z10():
"""U-52: на z=10 нет min-length фильтра — все треки проходят."""
short_track = _make_mock_row(1, length_m=100)
long_track = _make_mock_row(2, length_m=5000)
result_both = build_gps_mvt([short_track, long_track], z=10, x=620, y=320)
result_long_only = build_gps_mvt([long_track], z=10, x=620, y=320)
# При z=10 оба трека должны включаться (если геометрия пересекается с тайлом)
# result_both может быть больше result_long_only если короткий трек в тайле
assert isinstance(result_both, bytes)
assert isinstance(result_long_only, bytes)
def test_u52_min_length_boundary():
"""U-52: трек ровно 2000м на z=7 проходит фильтр."""
track_2000 = _make_mock_row(1, length_m=2000)
track_1999 = _make_mock_row(2, length_m=1999)
result_2000 = build_gps_mvt([track_2000], z=7, x=77, y=40)
result_1999 = build_gps_mvt([track_1999], z=7, x=77, y=40)
# track_1999 должен быть отфильтрован (строго меньше 2000)
# track_2000 проходит (>= 2000 не выполняется для строгого фильтра < 2000)
# По коду: if min_length_m > 0 and length_m < min_length_m → skip
# 1999 < 2000 → skip, 2000 < 2000 → False → not skipped
assert result_2000 != result_1999 or result_1999 == b""