---
type: test-plan
work_item_id: ET-009
title: "Test Plan: Новые источники GPS-треков — EnduroRussia и Wikiloc"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
  - "agent:analyst"
related:
  - "ET-008"

scope_note: >
  ET-009 не строит новую инфраструктуру; цель — активировать два
  новых источника (EnduroRussia, Wikiloc) в существующем pipeline
  ET-008. Тест-план фокусируется на (1) корректности парсеров на
  реальных фикстурах, (2) лицензионном guard'е, (3) дедупликации
  межисточниковых пересечений, (4) первом продакшн-прогоне с
  отчётностью, (5) непротиворечивости UI. Регрессия ET-008 проверяется
  существующим test_plan ET-008.

test_suites:

  - name: unit-enduro-russia-parser
    type: unit
    description: "EnduroRussiaParser на фикстурах"
    cases:
      - id: UT-ER-01
        name: "_parse_gpx из enduro-russia-track-1.gpx — успех"
        input: "GPX-фикстура с ≥ 10 trkpt, координаты внутри ЦФО"
        expected: |
          TrackInsert.points_count ≥ 10,
          length_m > 0,
          min_lon/max_lon корректны,
          external_url = 'https://endurorussia.ru/tracks/<id>',
          source_id = 'enduro_russia'

      - id: UT-ER-02
        name: "_parse_gpx из enduro-russia-track-2.gpx (пустой) → None"
        input: "GPX-фикстура с 0 trkpt"
        expected: "_parse_gpx возвращает None"

      - id: UT-ER-03
        name: "Bbox-фильтр отсеивает enduro-russia-track-3.gpx"
        input: "GPX с точкой за пределами bbox ЦФО"
        expected: "_bbox_intersects → False; collect() не yield-ит этот трек"

      - id: UT-ER-04
        name: "MAPPING категорий"
        input: "difficulty ∈ {'hard', 'soft', 'мото', 'unknown'}"
        expected: |
          'hard' → 'enduro'
          'soft' → 'enduro'
          'мото' → 'moto'
          'unknown' → 'other' (через map_activity default)

      - id: UT-ER-05
        name: "Конфиг base_url без дефиса (регрессия R-4)"
        input: "source_config = {'base_url': 'https://endurorussia.ru', ...}"
        expected: |
          parser.config['base_url'] == 'https://endurorussia.ru'
          (без дефиса). HTTP-запросы в collect() уходят на endurorussia.ru.

      - id: UT-ER-06
        name: "Pagination завершается при fetched_so_far >= total"
        input: "Mock API: total=5, page 0 возвращает 5 items, page 1 не должен запрашиваться"
        expected: "collect() сделал 1 запрос /api/tracks, не 2+"

      - id: UT-ER-07
        name: "HTTP 429 на /api/tracks — graceful return"
        input: "Mock 429 на первой странице"
        expected: "collect() завершается, exception не пробрасывается, 0 yield-ов"

      - id: UT-ER-08
        name: "HTTP 429 на /api/tracks/{id}/gpx — graceful return, ранние треки сохранены"
        input: "Mock: первая страница ОК (3 GPX), на 4-м GPX → 429"
        expected: "collect() yield-ит 3 трека, затем завершается без exception"

  - name: unit-wikiloc-parser
    type: unit
    description: "WikilocParser на фикстурах"
    cases:
      - id: UT-WL-01
        name: "_extract_track_paths из wikiloc-search-page1.html"
        input: "HTML-фикстура с ≥ 5 ссылками на треки"
        expected: "Возвращён список из ≥ 5 уникальных строк вида '/trails/<slug>/<id>'"

      - id: UT-WL-02
        name: "_extract_gpx_url: downloadTrail.do"
        input: "HTML с 'downloadTrail.do?id=12345'"
        expected: "Возвращён 'https://www.wikiloc.com/wikiloc/downloadTrail.do?id=12345'"

      - id: UT-WL-03
        name: "_extract_gpx_url: fallback по track_id"
        input: "HTML без явных ссылок на GPX, track_id='99999'"
        expected: "Возвращён 'https://www.wikiloc.com/wikiloc/downloadTrail.do?id=99999'"

      - id: UT-WL-04
        name: "_extract_track_name: <h1>"
        input: "HTML с '<h1>Test Trail</h1>'"
        expected: "Возвращена строка 'Test Trail'"

      - id: UT-WL-05
        name: "_parse_gpx из wikiloc-track.gpx — успех"
        input: "GPX-фикстура Wikiloc"
        expected: |
          TrackInsert.activity_type == 'moto' (для активности 'motorcycle'),
          source_id == 'wikiloc',
          external_url содержит 'wikiloc.com'

      - id: UT-WL-06
        name: "MAPPING категорий"
        input: "{'motorcycle', 'hiking', 'mtb'}"
        expected: |
          motorcycle → moto
          hiking → hike
          mtb → bicycle

      - id: UT-WL-07
        name: "HTTP 403 на странице поиска — graceful stop"
        input: "Mock: первая страница поиска → 403"
        expected: "collect() возвращается без exception, 0 yield-ов"

      - id: UT-WL-08
        name: "HTTP 429 на странице трека — graceful stop, ранние сохранены"
        input: "Mock: поиск ОК, 1-й трек ОК, на 2-м → 429"
        expected: "collect() yield-ит 1 трек, затем завершается без exception"

      - id: UT-WL-09
        name: "rate_limit соблюдается"
        input: "asyncio.sleep mock; парсер с rate_limit_sec=10"
        expected: |
          asyncio.sleep вызван между запросами с аргументом ≥ 10.
          Минимум 2 вызова asyncio.sleep на 2 трека.

      - id: UT-WL-10
        name: "max_tracks_per_run кап"
        input: "Mock поиск выдаёт 5 треков, max_tracks_per_run=2"
        expected: "collect() yield-ит ровно 2 трека и завершается"

  - name: unit-config-loader
    type: unit
    description: "Расширения существующего config-loader"
    cases:
      - id: UT-CFG-01
        name: "gps_sources.yaml парсится с записью wikiloc"
        input: "Текущий config/gps_sources.yaml после правок ET-009"
        expected: |
          load_sources_config возвращает список с id ∈ {osm, enduro_russia, wikiloc, ttrails}.
          wikiloc.enabled == True.
          enduro_russia.base_url == 'https://endurorussia.ru'.

      - id: UT-CFG-02
        name: "gps_regions.yaml содержит wikiloc"
        input: "Текущий config/gps_regions.yaml после правок ET-009"
        expected: |
          tsfo_plus_chuvashia.sources contains 'wikiloc' and 'enduro_russia'.

      - id: UT-CFG-03
        name: "Невалидный rate_limit_sec ≤ 0 → ошибка"
        input: "wikiloc.rate_limit_sec = 0"
        expected: "ConfigError или валидация при load"

  - name: integration-pipeline-et009
    type: integration
    description: "Pipeline gps_collect.py с mock EnduroRussia + Wikiloc"
    cases:
      - id: IT-ER-01
        name: "Прогон EnduroRussia с 3 фикстурными GPX"
        input: |
          Mock https://endurorussia.ru/api/tracks → enduro-russia-api-tracks-page1.json
          Mock /api/tracks/1/gpx → enduro-russia-track-1.gpx (inside bbox)
          Mock /api/tracks/2/gpx → enduro-russia-track-2.gpx (empty)
          Mock /api/tracks/3/gpx → enduro-russia-track-3.gpx (outside bbox)
        expected: |
          tracks_new == 1 (track-1 прошёл, track-2 None, track-3 filtered)
          pipeline_runs[-1].status == 'ok'
          exit_code == 0

      - id: IT-WL-01
        name: "Прогон Wikiloc с 1 фикстурным треком"
        input: |
          Mock /wikiloc/find.do?... → wikiloc-search-page1.html
          Mock /trails/.../12345 → wikiloc-trail-page.html
          Mock /wikiloc/downloadTrail.do?id=12345 → wikiloc-track.gpx
          (остальные ссылки из поиска → 404, чтобы остановиться)
        expected: |
          tracks_new == 1
          pipeline_runs[-1].status ∈ {'ok', 'partial'}
          exit_code == 0

      - id: IT-WL-02
        name: "Wikiloc graceful-stop на 403"
        input: "Mock /wikiloc/find.do → 403"
        expected: |
          tracks_new == 0
          pipeline_runs[-1].status == 'partial' (не 'error')
          exit_code == 0 (graceful-stop ≠ error)

      - id: IT-WL-03
        name: "Wikiloc graceful-stop на 429 после первого трека"
        input: "Mock: поиск ОК (2 трека), trail-page для 1-го ОК, GPX 1-го ОК, для 2-го → 429"
        expected: |
          tracks_new == 1
          pipeline_runs[-1].status == 'partial'
          exit_code == 0

      - id: IT-DEDUP-01
        name: "Dedup-merge: EnduroRussia + Wikiloc один и тот же трек"
        input: |
          1) Pipeline собирает EnduroRussia: 1 трек с bbox X, length L, date D.
          2) Pipeline собирает Wikiloc: 1 трек с bbox X±0.005, length L±2%, date D.
        expected: |
          В БД 1 запись (не 2).
          sources_json содержит ['enduro_russia', 'wikiloc'] (порядок не важен).
          external_urls_json содержит обе ссылки.
          Метаданные (name, activity_type) приоритетно из enduro_russia (priority 80 > 70).

      - id: IT-DEDUP-02
        name: "Разные даты → разные записи"
        input: "Те же геометрия и длина, но даты отличаются на 5 дней"
        expected: "В БД 2 записи"

      - id: IT-LIC-01
        name: "Licensing-guard блокирует source при status=proposed"
        input: |
          Подменить ADR-010 на временный файл со status: proposed.
          Запустить pipeline для enduro_russia.
        expected: |
          tracks_new == 0
          pipeline_runs[-1].status == 'skipped_license'
          exit_code == 1 (has_error)

      - id: IT-LIC-02
        name: "Licensing-guard пропускает source при status=accepted"
        input: "Обычный ADR-010 со status: accepted"
        expected: |
          pipeline загружает parser и пытается собирать.
          status НЕ 'skipped_license'.

  - name: contract-endurorussia-api
    type: contract
    description: "Реальные запросы к endurorussia.ru — nightly-only"
    marker: "@pytest.mark.network"
    cases:
      - id: CT-ER-01
        name: "GET /api/tracks?page=0&limit=5 → 200 + JSON"
        input: "Реальный HTTPS-запрос с UA enduro-trails"
        expected: |
          status_code == 200
          response.json() имеет ключи: items (list), total (int)
          len(items) > 0
          items[0] имеет ключи: id (int), name (str)

      - id: CT-ER-02
        name: "GET /api/tracks/{first_id}/gpx → 200 + parseable GPX"
        input: "first_id из CT-ER-01"
        expected: |
          status_code == 200
          Content-Type содержит 'xml' или 'gpx'
          defusedxml.fromstring(response.content) не бросает exception
          Root tag заканчивается на 'gpx'

  - name: contract-wikiloc
    type: contract
    description: "Реальный smoke-тест Wikiloc — ручной, не в CI"
    marker: "manual"
    cases:
      - id: CT-WL-01
        name: "Wikiloc find.do возвращает HTML с трек-ссылками"
        input: |
          Один curl-запрос с UA enduro-trails:
          GET https://www.wikiloc.com/wikiloc/find.do?act=19&sw=55,37&ne=56,38&page=0
        expected: |
          status_code == 200
          HTML содержит ≥ 1 совпадение '/trails/'
          Результат фиксируется в 13-test-report.md, скриншот сохраняется в docs/work-items/ET-009/.

  - name: integration-api-endpoint
    type: integration
    description: "Endpoint /api/gps-tracks после ET-009 — новые ID источников"
    cases:
      - id: IT-API-01
        name: "Ответ содержит features с source 'enduro_russia'"
        input: |
          Подготовка: вставить в test-БД 5 треков с source_id='enduro_russia'.
          GET /api/gps-tracks?bbox=37,55,38,56
        expected: |
          status 200
          features[].properties.sources содержит 'enduro_russia' хотя бы для одного

      - id: IT-API-02
        name: "Ответ содержит features с source 'wikiloc'"
        input: "Аналогично с wikiloc"
        expected: "features[].properties.sources содержит 'wikiloc'"

      - id: IT-API-03
        name: "Фильтр ?source=enduro_russia"
        input: "Тест-БД 5 enduro_russia + 5 wikiloc + 5 osm"
        expected: |
          status 200
          количество features ровно 5
          все sources == ['enduro_russia']

      - id: IT-API-04
        name: "Health: tracks_by_source включает оба новых ID"
        input: "GET /api/gps-tracks/health после подготовки"
        expected: |
          status 200
          tracks_by_source.enduro_russia ≥ 1
          tracks_by_source.wikiloc ≥ 1

  - name: e2e-first-production-run
    type: e2e
    description: "Первый ручной прогон в test-среде"
    marker: "manual"
    cases:
      - id: E2E-PROD-01
        name: "EnduroRussia: первый прогон собирает ≥ 200 треков"
        steps:
          - "ssh mva154"
          - "cd /opt/enduro-trails"
          - "Проверить наличие data/gps_tracks.sqlite (или ожидать создания)"
          - "Запустить: python scripts/gps_collect.py --region tsfo_plus_chuvashia --source enduro_russia"
          - "Дождаться завершения (≤ 45 мин)"
          - "Проверить exit code = 0"
          - "Запрос: sqlite3 data/gps_tracks.sqlite 'SELECT COUNT(*) FROM tracks WHERE sources_json LIKE \"%enduro_russia%\"'"
          - "Ожидаемо: count ≥ 200"
          - "Зафиксировать длительность и tracks_new в 14-deploy-log.md"

      - id: E2E-PROD-02
        name: "Wikiloc: первый прогон собирает ≥ 1 трек"
        steps:
          - "Запустить: python scripts/gps_collect.py --region tsfo_plus_chuvashia --source wikiloc"
          - "Дождаться (≤ 30 мин при max_tracks_per_run=50)"
          - "Проверить exit code = 0"
          - "sqlite3 ... 'SELECT COUNT(*) FROM tracks WHERE sources_json LIKE \"%wikiloc%\"'"
          - "Ожидаемо: count ≥ 1"
          - "Зафиксировать в 14-deploy-log.md (включая если 0 — отдельно отметить как fail E2E-PROD-02)"

      - id: E2E-PROD-03
        name: "Health-эндпоинт показывает новые источники"
        steps:
          - "curl https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks/health"
          - "Проверить наличие ключей tracks_by_source.enduro_russia и tracks_by_source.wikiloc"

      - id: E2E-PROD-04
        name: "Нет 'enduro-russia.ru' (с дефисом) в external_urls"
        steps:
          - "sqlite3 data/gps_tracks.sqlite \"SELECT COUNT(*) FROM tracks WHERE external_urls_json LIKE '%enduro-russia.ru%'\""
          - "Ожидаемо: 0 (или результаты пометить для опционального UPDATE-скрипта)"

  - name: regression-et008
    type: regression
    description: "Регрессия ET-008 — все существующие тесты остаются зелёными"
    cases:
      - id: RG-08-01
        name: "Все unit-тесты ET-008 проходят"
        input: "pytest tests/unit/ -v"
        expected: "Все тесты gps-tracks из ET-008 (U-01..U-62) проходят"

      - id: RG-08-02
        name: "Все integration-тесты ET-008 проходят"
        input: "pytest tests/integration/ -v"
        expected: "I-01..I-57 проходят"

      - id: RG-08-03
        name: "Все e2e-тесты ET-008 проходят"
        input: "pytest tests/e2e/ -v (или соответствующий маркер)"
        expected: "E-01..E-41 проходят"

  - name: load-baseline
    type: load
    description: "Производительность endpoint не деградировала"
    cases:
      - id: L-01
        name: "p95 /api/gps-tracks ≤ 300 мс"
        input: "100 параллельных клиентов, по 100 запросов, z=10, bbox с ~500 треков"
        expected: "p95 latency ≤ 300 ms"

      - id: L-02
        name: "p95 /api/gps-tracks/tiles ≤ 300 мс (cold)"
        input: "100 уникальных тайлов z=8..11"
        expected: "p95 cold ≤ 300 ms; hit-rate кэша > 80% на повторах"

test_data:
  fixtures_dir: "tests/fixtures/gps-tracks/"
  fixtures:
    - name: "enduro-russia-api-tracks-page1.json"
      description: "Реальный snapshot ответа GET /api/tracks?page=0&limit=50, ≥ 5 items"
      source: "manual curl до начала разработки"
    - name: "enduro-russia-track-1.gpx"
      description: "GPX с ≥ 10 trkpt, координаты в ЦФО"
    - name: "enduro-russia-track-2.gpx"
      description: "GPX пустой (для skip-логики)"
    - name: "enduro-russia-track-3.gpx"
      description: "GPX за пределами bbox ЦФО (для bbox-фильтра)"
    - name: "wikiloc-search-page1.html"
      description: "Snapshot страницы поиска Wikiloc, ≥ 5 ссылок"
    - name: "wikiloc-trail-page.html"
      description: "Snapshot страницы одного трека Wikiloc"
    - name: "wikiloc-track.gpx"
      description: "GPX из Wikiloc (для dedup-merge с EnduroRussia)"

test_environment:
  unit:
    - "Mock HTTP через respx или httpx_mock"
    - "asyncio.sleep моссится для UT-WL-09"
    - "Temporary sqlite через pytest tmp_path"
  integration:
    - "Mock HTTP-сервер для EnduroRussia и Wikiloc URLs"
    - "Изолированная sqlite в tmp_path"
  contract:
    - "Маркер @pytest.mark.network — пропускается в CI по умолчанию"
    - "Запуск nightly или вручную: pytest -m network"
  e2e:
    - "Test-среда https://openclaw.mva154.duckdns.org/enduro/"
    - "Доступ ssh mva154 у оператора Деплоя"
    - "UI-тесты — см. 04b-ui-test-cases.md (Playwright)"
  load:
    - "k6 или locust против test-среды"
    - "Запускается отдельно, не в обычном CI"

ci_gates:
  - "Все unit-тесты ET-009 (UT-ER-*, UT-WL-*, UT-CFG-*) — обязательны"
  - "Все integration-тесты ET-009 (IT-*) — обязательны"
  - "Регрессия ET-008 (RG-08-*) — обязательна"
  - "Contract-тесты (CT-*) — опциональны (network marker)"
  - "E2E ручные (E2E-PROD-*) — выполняются после деплоя, фиксируются в 14-deploy-log.md"
  - "Load-тесты (L-*) — выполняются один раз перед merge"
---
