All checks were successful
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>
302 lines
15 KiB
Python
302 lines
15 KiB
Python
"""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}"
|
||
)
|