---
type: test-plan
work_item_id: ET-008
title: "Test Plan: GPS-треки с публичных платформ на карте"
version: 2
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
changelog:
- "v2 (2026-06-01): полная переработка под BRD/TRZ/AC v2 — серверная агрегация, дедупликация, MVT, фильтры активности/источника. Предыдущая v1 описывала URL-импорт + OSM live-поиск."
authors:
- "agent:analyst"
test_suites:
- name: unit-config-loader
type: unit
description: "Загрузка и валидация YAML-конфигов sources/regions"
cases:
- id: U-01
name: "Валидный gps_sources.yaml парсится"
input: "Корректный YAML с 3 источниками"
expected: "Возвращает список объектов Source с обязательными полями"
- id: U-02
name: "Источник без license_adr — ошибка"
input: "YAML с enabled=true, но без license_adr"
expected: "ConfigError: 'enabled source requires license_adr'"
- id: U-03
name: "Регион с unknown source — ошибка"
input: "regions.sources содержит ID, которого нет в sources.yaml"
expected: "ConfigError: 'unknown source id'"
- id: U-04
name: "Bbox региона валидируется"
input: "bbox=[200, 100, 250, 150]"
expected: "ConfigError: 'bbox out of valid range'"
- id: U-05
name: "Disabled source игнорируется в pipeline"
input: "Регион ссылается на disabled source"
expected: "Pipeline пропускает этот source, warning в логе"
- name: unit-dedup
type: unit
description: "compute_dedup_key и merge-логика"
cases:
- id: U-10
name: "Два трека с одинаковым bbox+length+date → один ключ"
input: "geom1, geom2 с близкими bounds, length_m differ < 5%, dates same day"
expected: "compute_dedup_key(g1) == compute_dedup_key(g2)"
- id: U-11
name: "Разные даты → разные ключи"
input: "Те же bbox+length, daty отличаются на 2 дня"
expected: "compute_dedup_key различаются"
- id: U-12
name: "Bbox-округление до 0.01°"
input: "geom1.bounds=(37.6173, 55.7558, …), geom2.bounds=(37.6171, 55.7559, …)"
expected: "Один ключ (округление до 2 знаков)"
- id: U-13
name: "Merge: union sources"
input: "track в БД с sources=['osm'], новый с source='enduro_russia', тот же dedup_key"
expected: "Запись в БД обновлена: sources=['osm','enduro_russia']"
- id: U-14
name: "Merge: union external_urls"
input: "track в БД с external_urls=[...A], новый с [...B], тот же dedup_key"
expected: "В БД external_urls=[...A,...B] без дубликатов"
- id: U-15
name: "Merge: приоритет metadata по порядку sources.yaml"
input: "OSM (priority 1) собрал name='X', EnduroRussia (priority 2) собрал name='Y' с тем же dedup_key"
expected: "В БД name='X' (приоритет первого source)"
- name: unit-activity-mapping
type: unit
description: "Маппинг категорий источников в ACTIVITY_TYPES"
cases:
- id: U-20
name: "OSM tag 'enduro' → 'enduro'"
input: "['enduro', 'motorcycle']"
expected: "'enduro'"
- id: U-21
name: "OSM tag 'mtb' → 'bicycle'"
input: "['mtb']"
expected: "'bicycle'"
- id: U-22
name: "Unknown tag → 'other'"
input: "['xyz']"
expected: "'other'"
- id: U-23
name: "Пустой список тэгов → 'other'"
input: "[]"
expected: "'other'"
- name: unit-bbox-validation
type: unit
description: "Валидация bbox в /api/gps-tracks"
cases:
- id: U-30
name: "Валидный bbox"
input: "bbox=37.0,55.0,38.0,56.0"
expected: "validate_bbox() = True"
- id: U-31
name: "bbox out-of-range"
input: "bbox=200,100,250,150"
expected: "validate_bbox() = False"
- id: U-32
name: "Перевёрнутый bbox"
input: "bbox=38,55,37,56 (west > east)"
expected: "validate_bbox() = False"
- id: U-33
name: "Невалидный формат"
input: "bbox=foo"
expected: "validate_bbox() = False"
- name: unit-osm-parser
type: unit
description: "Парсер OSM trackpoints"
cases:
- id: U-40
name: "Группировка trkpt по gpx_id"
input: "GPX 1.0 с trkpt разных gpx_id"
expected: "Возвращает по треку на каждый gpx_id"
- id: U-41
name: "Анонимные точки (без gpx_id) — пропуск"
input: "GPX с точками без gpx_id"
expected: "Эти точки не попадают в результат"
- id: U-42
name: "Bbox-разбиение региона"
input: "region.bbox=(37, 55, 39, 57), cell_size=0.25"
expected: "len(cells) = 8 * 8 = 64"
- id: U-43
name: "Расчёт length_m через Haversine"
input: "trkpt: [37.6,55.7], [37.7,55.8], [37.8,55.9]"
expected: "length_m ≈ 28300 (±500)"
- id: U-44
name: "Защита от XXE"
input: "GPX с DOCTYPE и внешней entity"
expected: "defusedxml блокирует, парсер не выполняет загрузку"
- id: U-45
name: "Тэги из GPX → activity_type"
input: "enduromotorcycle"
expected: "activity_type='enduro'"
- name: unit-mvt-generation
type: unit
description: "Генерация MVT-тайлов для gps_tracks"
cases:
- id: U-50
name: "Тайл z=10 с 50 треками"
input: "tile_to_bbox(10, x, y), 50 треков в bbox"
expected: "Валидный MVT с layer gps_tracks, 50 features"
- id: U-51
name: "Упрощение геометрии на z=7"
input: "Трек 1000 точек, z=7"
expected: "После simplify_coords ≤ 100 точек"
- id: U-52
name: "Min-length фильтр на z ≤ 7"
input: "Треки с length_m=500 и 5000 на z=7"
expected: "Только трек ≥ 2000м попадает в тайл (min_length для z≤7)"
- id: U-53
name: "Properties в feature"
input: "Track в БД"
expected: "feature.properties содержит id, activity, source, sources,
length_km, name, ext_url"
- id: U-54
name: "Пустой тайл"
input: "Bbox без треков"
expected: "build_mvt() возвращает b'' (или валидный пустой MVT)"
- name: unit-color-palette
type: unit
description: "Цветовая палитра по источнику и активности"
cases:
- id: U-60
name: "Color by source: OSM = #3cb44b"
input: "feature.source='osm'"
expected: "Match-expression возвращает '#3cb44b'"
- id: U-61
name: "Color by activity: enduro = #e6194b"
input: "feature.activity='enduro'"
expected: "'#e6194b'"
- id: U-62
name: "Unknown source → fallback"
input: "feature.source='unknown'"
expected: "'#808080' (или fallback из палитры)"
- name: integration-pipeline
type: integration
description: "Pipeline gps_collect.py end-to-end с mock-источниками"
cases:
- id: I-01
name: "Полный прогон с 1 mock-источником"
input: "Mock OSM API → 100 треков; пустая БД"
expected: "После прогона в БД 100 tracks, pipeline_runs.status='ok',
tracks_new=100, tracks_updated=0"
- id: I-02
name: "Повторный прогон того же источника — все треки updated"
input: "Тот же mock + та же БД с предыдущей записью"
expected: "tracks_new=0, tracks_updated=100"
- id: I-03
name: "Прогон двух источников с пересечением"
input: "OSM mock = 100 треков, EnduroRussia mock = 50, из них 20 — те же по dedup_key"
expected: "В БД 130 уникальных записей (100 + 50 - 20). 20 пересекающихся имеют sources=['osm','enduro_russia']"
- id: I-04
name: "Падение одного источника"
input: "OSM mock OK, EnduroRussia mock возвращает 503"
expected: "OSM треки в БД, EnduroRussia status='error' в pipeline_runs,
но pipeline exit=0 (не strict-mode)"
- id: I-05
name: "Dry-run"
input: "Любой источник + флаг --dry-run"
expected: "БД не меняется, pipeline_runs не пишется,
stdout содержит план"
- id: I-06
name: "Rate-limit соблюдается"
input: "Mock source с rate_limit_sec=2, 5 запросов"
expected: "Суммарное время ≥ 8 сек (4 интервала × 2 сек)"
- id: I-07
name: "Backoff на 429"
input: "Mock source первый раз 429, второй раз 200"
expected: "Pipeline делает retry после exponential backoff,
трек собран"
- name: integration-endpoint-geojson
type: integration
description: "/api/gps-tracks GeoJSON"
cases:
- id: I-20
name: "Малый bbox с фильтрами"
input: "GET /api/gps-tracks?bbox=...&activity=enduro&source=osm"
expected: "200, FeatureCollection только enduro+OSM треков"
- id: I-21
name: "Truncation"
input: "В bbox 1500 треков, limit=500"
expected: "returned=500, total_in_bbox=1500, truncated=true"
- id: I-22
name: "Невалидный bbox → 400"
input: "bbox=foo"
expected: "400, JSON error"
- id: I-23
name: "Bbox в океане → пустой результат"
input: "bbox=0,0,1,1"
expected: "200, features=[], total=0"
- id: I-24
name: "CORS headers"
input: "Origin: https://example.com"
expected: "Response содержит Access-Control-Allow-Origin: *"
- id: I-25
name: "Производительность"
input: "100 запросов на bbox с 500 треков"
expected: "p95 ≤ 300 мс"
- name: integration-endpoint-mvt
type: integration
description: "/api/gps-tracks/tiles/{z}/{x}/{y}.mvt"
cases:
- id: I-30
name: "Тайл MVT отдаётся"
input: "GET /api/gps-tracks/tiles/10/623/325.mvt"
expected: "200, Content-Type: application/x-protobuf,
X-Cache: MISS"
- id: I-31
name: "Cache hit"
input: "Повторный запрос того же тайла"
expected: "X-Cache: HIT, ≤ 20 мс"
- id: I-32
name: "Невалидные z/x/y"
input: "z=25 / x вне диапазона"
expected: "400"
- id: I-33
name: "Очистка кэша"
input: "POST /api/gps-tracks/cache/clear, повторный запрос тайла"
expected: "X-Cache: MISS"
- name: integration-endpoint-health
type: integration
description: "/api/gps-tracks/health"
cases:
- id: I-40
name: "Полный отчёт"
input: "GET /api/gps-tracks/health"
expected: "200, JSON со всеми полями (см. REQ-F-12)"
- id: I-41
name: "БД отсутствует"
input: "Удалить data/gps_tracks.sqlite, GET /api/gps-tracks/health"
expected: "503 или 200 с tracks_total=0 и warning"
- id: I-42
name: "Счётчики корректны"
input: "БД с 100 OSM + 50 EnduroRussia"
expected: "tracks_by_source: {osm: 100, enduro_russia: 50}"
- name: integration-web-layer
type: integration
description: "Клиентский слой публичных треков"
cases:
- id: I-50
name: "Включение/выключение слоя"
input: "Симуляция click на #public-tracks-cb"
expected: "map.getSource('gps-tracks-tiles') существует,
layer 'gps-tracks-layer' visibility=visible"
- id: I-51
name: "Фильтр по активности через setFilter"
input: "filters.activities = ['enduro']"
expected: "map.getFilter('gps-tracks-layer') содержит ['in', ['get','activity'], ['literal',['enduro']]]"
- id: I-52
name: "Переключение color-mode"
input: "Переключить с source на activity"
expected: "Layer paint['line-color'] переустановлен на activity-палитру"
- id: I-53
name: "GeoJSON-загрузка при z ≥ 12"
input: "map.zoom=14, moveend"
expected: "Через 500мс debounce — fetch /api/gps-tracks?bbox=…"
- id: I-54
name: "AbortController при быстром pan"
input: "Два moveend подряд за 100мс"
expected: "Первый fetch отменён, выполняется только второй"
- id: I-55
name: "Halo на спутнике"
input: "applyBaseLayer('satellite'), public-tracks включен"
expected: "layer 'gps-tracks-halo-satellite' visibility=visible"
- id: I-56
name: "Halo выключен на схеме"
input: "applyBaseLayer('schematic')"
expected: "halo visibility=none"
- id: I-57
name: "Сохранение слоя при setStyle"
input: "Переключение тёмной темы (switchMapStyle)"
expected: "rebuildMapOverlays() → restorePublicTracksState() →
слой пересоздан, фильтры применены"
- name: e2e-pipeline
type: e2e
description: "Полный pipeline на тестовых mock-источниках"
cases:
- id: E-01
name: "Сбор → API → визуализация"
steps:
- "Очистить test-БД"
- "Запустить pipeline с mock OSM + mock EnduroRussia"
- "Проверить: tracks_total > 0 в /api/gps-tracks/health"
- "Открыть веб-интерфейс"
- "Включить чекбокс «Публичные треки»"
- "Убедиться: на карте видны линии треков"
- "Кликнуть по треку → popup с метаданными"
- id: E-02
name: "Дедупликация — два прогона"
steps:
- "Запустить pipeline (mock-источники отдают 100 треков)"
- "Запомнить tracks_total"
- "Запустить pipeline повторно (mock отдаёт те же 100)"
- "Убедиться: tracks_total не изменился"
- "Убедиться: pipeline_runs.tracks_updated=100"
- name: e2e-ui-filters
type: e2e
description: "UI-фильтры по активности и источнику"
cases:
- id: E-10
name: "Открытие фильтров и переключение"
steps:
- "Включить чекбокс «Публичные треки»"
- "Нажать «Фильтры…» → открывается #sheet-gps-filters"
- "Снять все галки активности кроме «Эндуро»"
- "Убедиться: на карте видны только enduro-треки"
- "Снять «OSM» в источниках"
- "Убедиться: OSM enduro-треки скрылись"
- id: E-11
name: "Переключение color-mode"
steps:
- "Включить слой"
- "Открыть фильтры"
- "Выбрать «По активности»"
- "Убедиться: цвета линий перерисованы (например, enduro = красный)"
- "Перезагрузить страницу"
- "Убедиться: color-mode='activity' сохранён"
- id: E-12
name: "Persistence фильтров"
steps:
- "Настроить фильтры (только moto, только EnduroRussia)"
- "Перезагрузить страницу"
- "Открыть фильтры"
- "Убедиться: чекбоксы соответствуют настройкам"
- name: e2e-popup
type: e2e
description: "Popup трека"
cases:
- id: E-20
name: "Popup полный набор полей"
steps:
- "Включить слой"
- "Кликнуть на трек на карте"
- "Убедиться: popup содержит name, activity-иконку, км, дату, user, sources"
- "Кликнуть по ссылке источника"
- "Убедиться: открыта новая вкладка"
- id: E-21
name: "Popup для трека без user"
steps:
- "Найти трек без user"
- "Кликнуть → popup без строки «Автор»"
- name: e2e-compat
type: e2e
description: "Совместимость с другими функциями"
cases:
- id: E-30
name: "Слой + спутник + halo"
steps:
- "Включить «Публичные треки»"
- "Переключить подложку на «Спутник»"
- "Убедиться: треки видны на спутнике с белой обводкой"
- id: E-31
name: "Слой + тёмная тема"
steps:
- "Включить слой"
- "Переключить тёмную тему"
- "Убедиться: треки остаются на карте"
- "Убедиться: фильтры сохранены"
- id: E-32
name: "Слой + личный GPX (ET-006)"
steps:
- "Включить слой"
- "Загрузить личный GPX"
- "Убедиться: оба видны"
- "Убедиться: личный трек выше публичных по z-order"
- id: E-33
name: "Слой + маршрут OSRM"
steps:
- "Включить слой"
- "Построить маршрут OSRM"
- "Убедиться: маршрут OSRM визуально выше публичных треков"
- id: E-34
name: "Слой + hillshade"
steps:
- "Включить слой"
- "Включить hillshade"
- "Убедиться: оба видны"
- name: e2e-low-zoom-protection
type: e2e
description: "Защита от шторма запросов на low-zoom"
cases:
- id: E-40
name: "Слой скрыт на z<8"
steps:
- "Включить слой"
- "Отзумиться до z=5"
- "Убедиться: линии не отображаются"
- "Убедиться: появилась подсказка «Зум 8+» у чекбокса"
- id: E-41
name: "Pan на z 14 не штормит запросы"
steps:
- "Включить слой, z=14"
- "Быстро панить карту (5 раз за 1 сек)"
- "Проверить network log: не более 2 запросов /api/gps-tracks"
- name: load-pipeline
type: load
description: "Нагрузочные сценарии pipeline и API"
cases:
- id: L-01
name: "Полный прогон pipeline на ЦФО+Чувашию (mock)"
input: "Mock OSM с реальным объёмом ≈ 50K треков"
expected: "Прогон завершается за ≤ 6 часов (cron-окно)"
- id: L-02
name: "API под нагрузкой"
input: "10 параллельных клиентов делают по 100 запросов /api/gps-tracks"
expected: "p95 ≤ 500 мс, нет ошибок"
- id: L-03
name: "MVT-тайлы под нагрузкой"
input: "100 параллельных запросов разных тайлов"
expected: "p95 cold ≤ 300 мс, hit-rate кэша > 80% на повторах"
test_data:
fixtures_dir: "tests/fixtures/gps-tracks/"
fixtures:
- name: "osm-trackpoints-bbox-moscow.gpx"
description: "Реальный ответ OSM API на bbox центра Москвы"
- name: "osm-trackpoints-multipage.json"
description: "Серия ответов OSM с has_more=true на нескольких страницах"
- name: "enduro-russia-mock-listing.html"
description: "Главная страница региона на EnduroRussia (mock)"
- name: "enduro-russia-mock-track.gpx"
description: "GPX-файл, отдаваемый EnduroRussia mock"
- name: "ttrails-mock-track.gpx"
description: "GPX от ttrails mock"
- name: "xxe-payload.gpx"
description: "GPX с DOCTYPE и внешней entity (для проверки defusedxml)"
- name: "dedup-pair-osm-enduro.json"
description: "Пара треков (одна и та же поездка из двух источников) для проверки dedup"
- name: "gps_tracks_seed.sql"
description: "SQL-сид: 1000 синтетических треков для интеграционных тестов"
test_environment:
mock_servers:
- "Mock OSM API (отвечает на /api/0.6/trackpoints и /api/0.6/gpx/)"
- "Mock EnduroRussia.ru (HTML-страницы + GPX-файлы)"
- "Mock ttrails.ru"
cron_simulation:
- "В тестах cron заменяется на pytest fixture, вызывающий run() напрямую"
db_isolation:
- "Каждый тест использует in-memory или временный sqlite-файл в pytest tmp_path"
network:
- "Все исходящие HTTP в unit/integration — через httpx_mock или respx (без реальной сети)"
notes:
- "L-01 (полный прогон pipeline) запускается отдельно, не в обычном CI"
- "E2E UI-тесты — Playwright; URL test-среды https://openclaw.mva154.duckdns.org/enduro/ (см. 04b-ui-test-cases.md)"
- "Для load-тестов использовать pytest-benchmark + locust"