Files
enduro-trails/tests/unit/test_base_layer.py
claude-bot 475d42187d
All checks were successful
CI / lint (push) Successful in 3s
CI / test (push) Successful in 5s
CI / build (push) Successful in 1s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 5s
CI / build (pull_request) Successful in 1s
feat(web): спутниковая подложка с переключателем Схема/Спутник
ET-007: добавлен сегментированный переключатель «Подложка» в попап
слоёв; ленивое создание Esri World Imagery raster-source при первом
включении «Спутник»; восстановление выбора из localStorage и переживание
смены темы через rebuildMapOverlays().

- src/web/index.html: блок .terrain-base-row в #terrain-popup
- src/web/app.css: стили .terrain-base-row / .terrain-base-label / .base-seg
- src/web/app.js: блок ET-007 с onBaseLayerToggle, applyBaseLayer,
  restoreBaseLayerState, syncBaseLayerUI; хук в rebuildMapOverlays()
  первым, чтобы terrain/trails/POI лежали поверх спутника
- src/web/style.json, style-dark.json: halo-underlay-слои
  trails-track-halo-satellite и trails-path-bridleway-halo-satellite
  (visibility:none по умолчанию, включаются на спутнике для контраста)
- tests/unit/base_layer.test.js: 28 behavioural JS-тестов (U-01..U-05,
  U-10..U-11, I-01..I-07, halo, z-order, private mode, тёмная тема)
- tests/unit/test_base_layer.py: 22 pytest-проверки (HTML/CSS/app.js/
  style.json структурные + node --test runner)

Refs: ET-007
ADR: docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:09:19 +00:00

302 lines
15 KiB
Python
Raw 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-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("<button", 0, start)
tag_end = html.index(">", 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("<div", 0, start)
container_open_end = html.index(">", 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}"
)