"""ET-007 — тесты переключателя базовой подложки (Схема / Спутник). Изменение ET-007 — исключительно фронтендовое: правки `src/web/index.html`, `src/web/app.js`, `src/web/app.css`, `src/web/style.json`, `src/web/style-dark.json` (см. ADR-004). В CI исполняется только `pytest tests/`, поэтому файл покрывает фичу двумя способами: 1. Статические проверки структуры файлов — выполняются всегда, без внешних зависимостей. 2. Поведенческие JS unit-тесты (U-01..U-05, U-10..U-11, часть I-*) — запускаются через встроенный тест-раннер Node (`node --test`). Если `node` в системе отсутствует — эта часть помечается `skip`. Браузерные e2e-сценарии (E-01..E-10, TC-UI-01..14) требуют Playwright- инфраструктуры, которой в репозитории нет (см. ET-002 ADR-0001, 07-infra-requirements.md). Их поведенческая суть покрыта JS unit-тестами и статическими проверками ниже. """ from __future__ import annotations import json 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" APP_CSS = REPO_ROOT / "src" / "web" / "app.css" STYLE_LIGHT = REPO_ROOT / "src" / "web" / "style.json" STYLE_DARK = REPO_ROOT / "src" / "web" / "style-dark.json" JS_TEST = REPO_ROOT / "tests" / "unit" / "base_layer.test.js" def _read(path: Path) -> str: assert path.is_file(), f"не найден {path}" return path.read_text(encoding="utf-8") # ────────────────────────────────────────────────────────────────────────────── # Статические проверки index.html (TRZ §3, AC-01) # ────────────────────────────────────────────────────────────────────────────── def test_base_toggle_present_in_html(): """AC-01: сегментированный переключатель «Подложка» в попапе слоёв.""" html = _read(INDEX_HTML) assert 'id="base-seg"' in html, "нет переключателя base-seg" assert 'id="base-btn-schematic"' in html, "нет кнопки «Схема»" assert 'id="base-btn-satellite"' in html, "нет кнопки «Спутник»" assert "onclick=\"onBaseLayerToggle('schematic')\"" in html assert "onclick=\"onBaseLayerToggle('satellite')\"" in html def test_base_toggle_default_active_schematic(): """AC-01/Default: кнопка «Схема» отрисована с классом active.""" html = _read(INDEX_HTML) start = html.index('id="base-btn-schematic"') # Открывающий тег button начинается до id="..." tag_start = html.rfind("", start) assert "active" in html[tag_start:tag_end], ( "у кнопки «Схема» нет начального класса active (Default — Схема)" ) def test_base_toggle_reuses_seg_control_component(): """ADR-004 §M-A: переключатель использует общий .seg-control.""" html = _read(INDEX_HTML) start = html.index('id="base-seg"') container_start = html.rfind("", container_start) assert "seg-control" in html[container_start:container_open_end], ( "переключатель подложки должен использовать класс seg-control" ) def test_base_toggle_placed_at_top_of_terrain_popup(): """TRZ §3.1/3.2: блок «Подложка» — первая секция попапа слоёв.""" html = _read(INDEX_HTML) popup_pos = html.index('id="terrain-popup"') base_pos = html.index('id="base-seg"') title_pos = html.index('class="terrain-popup-title"') assert base_pos > popup_pos, "блок «Подложка» вне попапа слоёв" assert base_pos < title_pos, ( "блок «Подложка» должен идти ВЫШЕ заголовка «Эндуро» (TRZ §3.1)" ) # ────────────────────────────────────────────────────────────────────────────── # Статические проверки app.css (TRZ §3.3) # ────────────────────────────────────────────────────────────────────────────── def test_base_toggle_styles_defined(): """TRZ §3.3: стили .terrain-base-row, .terrain-base-label, .base-seg.""" css = _read(APP_CSS) assert ".terrain-base-row" in css, "нет стилей строки переключателя подложки" assert ".terrain-base-label" in css, "нет стилей метки «Подложка»" assert ".base-seg" in css, "нет селектора .base-seg" # ────────────────────────────────────────────────────────────────────────────── # Статические проверки app.js (TRZ §5, ADR-004 §2-4) # ────────────────────────────────────────────────────────────────────────────── def test_app_js_base_layer_functions_defined(): """TRZ §5: определены публичные функции фичи.""" js = _read(APP_JS) for fn in ( "onBaseLayerToggle", "applyBaseLayer", "restoreBaseLayerState", "syncBaseLayerUI", "getStoredBaseLayer", ): assert f"function {fn}(" in js, f"не определена функция {fn}()" def test_app_js_has_et007_block_markers(): """Блок ET-007 обрамлён маркерами (как POI-блок ET-002, единичный блок).""" js = _read(APP_JS) assert "// >>> ET-007 base layer toggle block" in js, ( "нет открывающего маркера блока ET-007" ) assert "// <<< ET-007 base layer toggle block <<<" in js, ( "нет закрывающего маркера блока ET-007" ) def test_app_js_uses_localstorage_key(): """TRZ §4.3: персистентность через localStorage ключ map-base-layer.""" js = _read(APP_JS) assert "'map-base-layer'" in js, ( "состояние подложки не использует ключ map-base-layer" ) def test_app_js_uses_esri_world_imagery(): """ADR-004 §P: провайдер — Esri World Imagery без API-ключа.""" js = _read(APP_JS) assert "server.arcgisonline.com" in js, ( "URL спутниковых тайлов не Esri World Imagery" ) assert "/World_Imagery/MapServer/" in js, ( "URL не соответствует Esri World Imagery service" ) assert "Esri" in js, "атрибуция Esri не упоминается в коде" def test_app_js_satellite_source_and_layer_ids(): """TRZ §1 REQ-F-02: id источника satellite-raster, id слоя satellite-base.""" js = _read(APP_JS) assert "'satellite-raster'" in js, "не используется id source 'satellite-raster'" assert "'satellite-base'" in js, "не используется id layer 'satellite-base'" def test_app_js_lazy_source_creation(): """ADR-004 §S-B: source/layer создаются лениво при первом включении.""" js = _read(APP_JS) assert "map.getSource(SATELLITE_SOURCE_ID)" in js or \ "getSource('satellite-raster')" in js, ( "проверка существования source не выполняется (ADR-004 S-B)" ) def test_rebuild_overlays_calls_restore_base_layer_first(): """TRZ §5.5, ADR-004 §O-A: restoreBaseLayerState() — первый вызов.""" js = _read(APP_JS) assert "restoreBaseLayerState" in js, ( "restoreBaseLayerState() не подключён" ) # В rebuildMapOverlays() restoreBaseLayerState идёт перед restoreTerrainState. start = js.index("function rebuildMapOverlays(") body = js[start:start + 800] base_pos = body.find("restoreBaseLayerState") terrain_pos = body.find("restoreTerrainState") assert 0 <= base_pos < terrain_pos, ( "restoreBaseLayerState() должен вызываться ДО restoreTerrainState() " "в rebuildMapOverlays() (TRZ §5.5)" ) def test_restore_base_layer_state_wired_into_init(): """TRZ §5.5: restoreBaseLayerState() вызывается в инициализации страницы. Покрывает обе ветки IIFE-инициализатора: когда карта уже готова и когда мы дожидаемся её через setInterval. Плюс вызов из rebuildMapOverlays(). """ js = _read(APP_JS) # Один def + минимум 3 вызова (rebuildMapOverlays + 2 ветки init). assert js.count("restoreBaseLayerState()") >= 4, ( "restoreBaseLayerState() не подключён ко всем точкам восстановления" ) def test_app_js_uses_setpaint_for_poi_halo(): """ADR-004 §H-B: POI text-halo меняется через setPaintProperty.""" js = _read(APP_JS) block_start = js.index("// >>> ET-007 base layer toggle block") block_end = js.index("// <<< ET-007 base layer toggle block") block = js[block_start:block_end] assert "setPaintProperty" in block, ( "блок ET-007 не использует setPaintProperty для POI halo" ) assert "'text-halo-color'" in block, ( "POI text-halo не настраивается в режиме «Спутник»" ) def test_app_js_uses_visibility_for_trails_halo(): """ADR-004 §H-B: halo trails — через visibility у underlay-слоёв.""" js = _read(APP_JS) assert "'trails-track-halo-satellite'" in js, ( "halo-слой trails-track не упомянут в коде" ) assert "'trails-path-bridleway-halo-satellite'" in js, ( "halo-слой path/bridleway не упомянут в коде" ) # ────────────────────────────────────────────────────────────────────────────── # Статические проверки style.json / style-dark.json (ADR-004 §5/H-B) # ────────────────────────────────────────────────────────────────────────────── def _layer_ids(style_path: Path) -> list[str]: style = json.loads(style_path.read_text(encoding="utf-8")) return [layer["id"] for layer in style.get("layers", [])] @pytest.mark.parametrize("style_path", [STYLE_LIGHT, STYLE_DARK]) def test_style_contains_halo_layers(style_path: Path): """ADR-004 §H-B: halo-underlay-слои объявлены декларативно.""" layers = _layer_ids(style_path) assert "trails-track-halo-satellite" in layers, ( f"в {style_path.name} нет слоя trails-track-halo-satellite" ) assert "trails-path-bridleway-halo-satellite" in layers, ( f"в {style_path.name} нет слоя trails-path-bridleway-halo-satellite" ) @pytest.mark.parametrize("style_path", [STYLE_LIGHT, STYLE_DARK]) def test_halo_layers_hidden_by_default(style_path: Path): """ADR-004 §H-B: halo-слои по умолчанию скрыты (visibility: none).""" style = json.loads(style_path.read_text(encoding="utf-8")) halos = { l["id"]: l for l in style["layers"] if l["id"].endswith("-halo-satellite") } assert len(halos) == 2, f"в {style_path.name} должны быть 2 halo-слоя" for layer_id, layer in halos.items(): layout = layer.get("layout", {}) assert layout.get("visibility") == "none", ( f"{layer_id} в {style_path.name} не скрыт по умолчанию" ) @pytest.mark.parametrize("style_path", [STYLE_LIGHT, STYLE_DARK]) def test_halo_layers_below_real_trails(style_path: Path): """ADR-004 §H-B: halo должен идти ПЕРЕД соответствующим trails-слоем (рисуется снизу — обводка под линией).""" layers = _layer_ids(style_path) track_halo = layers.index("trails-track-halo-satellite") track = layers.index("trails-track") path_halo = layers.index("trails-path-bridleway-halo-satellite") path = layers.index("trails-path-bridleway") assert track_halo < track, ( f"halo для trails-track в {style_path.name} должен идти ПЕРЕД trails-track" ) assert path_halo < path, ( f"halo для path/bridleway в {style_path.name} должен идти ПЕРЕД trails-path-bridleway" ) # ────────────────────────────────────────────────────────────────────────────── # Поведенческие JS unit-тесты через Node (U-01..U-05, U-10..U-11, I-*) # ────────────────────────────────────────────────────────────────────────────── node_required = pytest.mark.skipif( which("node") is None, reason="node не установлен — поведенческие JS unit-тесты пропущены", ) @node_required def test_js_unit_tests_pass(): """U-01..U-05, U-10..U-11, I-*: behavioral 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-тесты подложки упали (код {result.returncode}):\n" f"{result.stdout}\n{result.stderr}" )