---
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: "<tag>enduro</tag><tag>motorcycle</tag>"
        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/<id>)"
    - "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"
