--- 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/', 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//'" - 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:

" input: "HTML с '

Test Trail

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