"""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("", 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}" )