--- 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"