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>
172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
"""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""
|