Files
enduro-trails/tests/unit/test_gpx_upload.py
claude-bot 55c9c389cd feat(gpx): загрузка и визуализация GPX-треков (ET-006)
Клиентская загрузка GPX 1.1: парсинг через DOMParser с чанковой
конвертацией (ADR-003), отрисовка треков и waypoints на карте,
панель #sheet-gpx со списком треков, статистикой и canvas-профилем
высот. GPX-слои встают ниже маршрута OSRM и восстанавливаются после
смены стиля карты (REQ-F-13).

- src/web/gpx.js — новый модуль фичи (ADR-002): парсинг, модель
  window.gpxTracks, слои/маркеры карты, sheet-gpx, профиль высот
- index.html / app.css — кнопка загрузки, кнопка тулбара, панель
  #sheet-gpx, toast-уведомления, индикатор парсинга
- app.js — один хук rebuildGpxOverlays() в rebuildMapOverlays()
- тесты: gpx.test.js (node --test, U-01..U-21) +
  test_gpx_upload.py (pytest: статические проверки + JS-раннер)

Refs: ET-006

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:00:27 +00:00

242 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ET-006 — тесты загрузки и визуализации GPX-треков.
Изменение ET-006 — фронтендовое: новый модуль `src/web/gpx.js` плюс
правки `src/web/index.html`, `src/web/app.css` и одна строка-хук в
`src/web/app.js` (см. `06-adr/ADR-002`, `ADR-003`).
В CI исполняется только `pytest tests/`, поэтому файл покрывает фичу
двумя способами (по аналогии с `tests/unit/test_unit_toggle.py`):
1. Статические проверки структуры `gpx.js`, `index.html`, `app.css`,
`app.js` — выполняются всегда, без внешних зависимостей.
2. Поведенческие JS unit-тесты (группы U-01..U-21 из
`04-test-plan.yaml`) — через встроенный тест-раннер Node
(`node --test`). Если `node` отсутствует — часть помечается `skip`.
E2E-сценарии (E-01..E-10) требуют Playwright-инфраструктуры, которой в
репозитории нет, — они остаются за этапом тестирования.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html"
APP_JS = REPO_ROOT / "src" / "web" / "app.js"
GPX_JS = REPO_ROOT / "src" / "web" / "gpx.js"
APP_CSS = REPO_ROOT / "src" / "web" / "app.css"
JS_TEST = REPO_ROOT / "tests" / "unit" / "gpx.test.js"
def _read(path: Path) -> str:
assert path.is_file(), f"не найден {path}"
return path.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки gpx.js (ADR-002, ADR-003, 08-data-requirements.md)
# ──────────────────────────────────────────────────────────────────────────────
def test_gpx_module_exists():
"""ADR-002: GPX-фича вынесена в отдельный модуль src/web/gpx.js."""
assert GPX_JS.is_file(), "не найден src/web/gpx.js"
def test_gpx_module_public_api():
"""ADR-002: модуль определяет публичный контракт парсинга и расчётов."""
js = _read(GPX_JS)
for fn in (
"parseGpxText", "parseGpxAsync", "extractGpxModel",
"trackStats", "colorForIndex", "haversineKm",
"tracksToGeoJSON", "waypointsToGeoJSON", "fileBounds",
):
assert f"function {fn}(" in js, f"в gpx.js не определена функция {fn}()"
def test_gpx_module_exports_for_browser_and_node():
"""ADR-002: window.Gpx для браузера и module.exports для unit-тестов."""
js = _read(GPX_JS)
assert "global.Gpx = Gpx" in js, "gpx.js не публикует глобальный неймспейс Gpx"
assert "module.exports" in js, "gpx.js не экспортируется для Node unit-тестов"
def test_gpx_module_publishes_onclick_handlers():
"""ADR-002: модуль публикует обработчики inline-onclick и хук REQ-F-13."""
js = _read(GPX_JS)
for fn in (
"onGpxFileSelected", "toggleGpxSheet",
"selectGpxTrack", "removeGpxTrack", "rebuildGpxOverlays",
):
assert f"global.{fn} =" in js, f"gpx.js не публикует глобаль {fn}"
def test_gpx_palette_eight_colors():
"""TRZ REQ-F-04 §5.3: палитра треков — ровно 8 цветов."""
js = _read(GPX_JS)
for color in (
"#e6194b", "#3cb44b", "#ffe119", "#4363d8",
"#f58231", "#911eb4", "#42d4f4", "#f032e6",
):
assert color in js, f"в палитре GPX нет цвета {color}"
def test_gpx_file_size_limit():
"""TRZ REQ-F-03: лимит размера GPX-файла — 50 МБ."""
js = _read(GPX_JS)
assert "50 * 1024 * 1024" in js, "не задан лимит размера файла 50 МБ"
def test_gpx_noise_filter_constant():
"""TRZ §5.2: дельты высот < 2 м фильтруются как GPS-шум."""
js = _read(GPX_JS)
assert "ELE_NOISE_M" in js, "нет константы фильтрации шума высот"
def test_gpx_uses_domparser_main_thread():
"""ADR-003: парсинг XML — через DOMParser в основном потоке."""
js = _read(GPX_JS)
assert "new DOMParser()" in js, "gpx.js не использует DOMParser (ADR-003)"
assert "Worker" not in js, "ADR-003: Web Worker не используется"
def test_gpx_chunked_conversion():
"""ADR-003: конвертация DOM → модель выполняется чанками."""
js = _read(GPX_JS)
assert "CHUNK_SIZE" in js, "нет чанковой конвертации (ADR-003)"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки index.html (TRZ §3, REQ-F-01, REQ-F-09)
# ──────────────────────────────────────────────────────────────────────────────
def test_gpx_upload_button_present():
"""TRZ REQ-F-01 §3.1: кнопка загрузки GPX в правой панели карты."""
html = _read(INDEX_HTML)
assert 'id="btn-gpx-upload"' in html, "нет кнопки загрузки GPX btn-gpx-upload"
assert 'id="gpx-file-input"' in html, "нет input[type=file] для GPX"
assert 'accept=".gpx"' in html, "input не ограничен расширением .gpx"
assert "multiple" in html, "TRZ REQ-F-01: допускается множественный выбор"
assert 'onchange="onGpxFileSelected(this)"' in html, "input не привязан к обработчику"
def test_gpx_upload_button_between_compass_and_locate():
"""TRZ §3.1: кнопка GPX расположена между «Компас» и «Геолокация»."""
html = _read(INDEX_HTML)
compass = html.index('id="btn-compass"')
gpx = html.index('id="btn-gpx-upload"')
locate = html.index('onclick="locateMe()"')
assert compass < gpx < locate, "кнопка GPX должна быть между компасом и геолокацией"
def test_gpx_toolbar_button_present():
"""TRZ §3.2: кнопка «GPX» в нижнем тулбаре переключает sheet."""
html = _read(INDEX_HTML)
assert 'id="tb-gpx"' in html, "нет кнопки GPX в тулбаре"
assert 'onclick="toggleGpxSheet()"' in html, "кнопка тулбара не вызывает toggleGpxSheet"
def test_gpx_sheet_present():
"""TRZ REQ-F-09 §3.3: bottom sheet #sheet-gpx со списком и деталями."""
html = _read(INDEX_HTML)
assert 'id="sheet-gpx"' in html, "нет панели #sheet-gpx"
for el in ("gpx-list", "gpx-stats", "gpx-elevation-canvas", "gpx-empty"):
assert f'id="{el}"' in html, f"в #sheet-gpx нет элемента {el}"
def test_gpx_sheet_uses_bottom_sheet_component():
"""TRZ REQ-F-09: панель GPX переиспользует компонент .bottom-sheet."""
html = _read(INDEX_HTML)
start = html.index('id="sheet-gpx"')
container_start = html.rfind("<div", 0, start)
container_open_end = html.index(">", container_start)
assert "bottom-sheet" in html[container_start:container_open_end], (
"панель GPX должна использовать класс bottom-sheet"
)
def test_gpx_toast_and_loading_present():
"""TRZ §3.4, REQ-NF-01: toast-уведомления и индикатор парсинга."""
html = _read(INDEX_HTML)
assert 'id="app-toast"' in html, "нет контейнера toast-уведомлений"
assert 'id="gpx-loading"' in html, "нет индикатора загрузки GPX"
def test_gpx_js_loaded_after_app_js():
"""ADR-002: gpx.js подключается строго ПОСЛЕ app.js."""
html = _read(INDEX_HTML)
gpx_pos = html.find('src="gpx.js"')
app_pos = html.find('src="app.js"')
assert gpx_pos != -1, "gpx.js не подключён в index.html"
assert app_pos != -1, "app.js не подключён в index.html"
assert gpx_pos > app_pos, "gpx.js должен подключаться ПОСЛЕ app.js (ADR-002)"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.js (ADR-002 — контракт интеграции, REQ-F-13)
# ──────────────────────────────────────────────────────────────────────────────
def test_app_js_has_rebuild_gpx_hook():
"""ADR-002 / REQ-F-13: rebuildMapOverlays() восстанавливает GPX-слои."""
js = _read(APP_JS)
assert "rebuildGpxOverlays" in js, "в app.js нет хука rebuildGpxOverlays"
# Хук защищён typeof — app.js остаётся валидным и без gpx.js (ADR-002).
assert "typeof rebuildGpxOverlays === 'function'" in js, (
"хук GPX должен быть защищён проверкой typeof (ADR-002)"
)
def test_app_js_gpx_hook_inside_rebuild_overlays():
"""ADR-002: хук GPX размещён внутри функции rebuildMapOverlays()."""
js = _read(APP_JS)
start = js.index("function rebuildMapOverlays(")
end = js.index("\n}", start)
assert "rebuildGpxOverlays" in js[start:end], (
"хук rebuildGpxOverlays должен вызываться из rebuildMapOverlays()"
)
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.css (TRZ §3.3, §3.4)
# ──────────────────────────────────────────────────────────────────────────────
def test_gpx_styles_present():
"""TRZ §3: для панели, статистики и профиля заданы стили в app.css."""
css = _read(APP_CSS)
for selector in (
"#app-toast", "#gpx-loading", ".gpx-row",
".gpx-stats-grid", "#gpx-elevation-canvas",
):
assert selector in css, f"в app.css нет стилей для {selector}"
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие JS unit-тесты через Node (U-01..U-21)
# ──────────────────────────────────────────────────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_unit_tests_pass():
"""U-01..U-21: запускает behavioral JS-тесты gpx.js через `node --test`."""
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
node = which("node")
result = subprocess.run(
[node, "--test", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"JS unit-тесты GPX упали (код {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)