Клиентская загрузка 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>
242 lines
12 KiB
Python
242 lines
12 KiB
Python
"""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}"
|
||
)
|