feat(web): спутниковая подложка с переключателем Схема/Спутник
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

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>
This commit is contained in:
2026-05-31 20:09:19 +00:00
parent 29d8461c0c
commit 475d42187d
7 changed files with 1047 additions and 0 deletions

View File

@@ -0,0 +1,411 @@
'use strict';
/**
* ET-007 — поведенческие unit-тесты переключателя подложки «Схема / Спутник».
*
* Покрывают U-01..U-05 и U-10..U-11 из docs/work-items/ET-007/04-test-plan.yaml,
* а также часть интеграционных кейсов (I-01..I-04, I-06, I-07, I-24, I-25),
* проверяемых на мок-карте.
*
* Тесты исполняют РЕАЛЬНЫЙ код из блока ET-007 в src/web/app.js: блок
* извлекается по маркерам `>>> ET-007 base layer toggle block` и
* оборачивается в фабрику через `new Function`, которой передаются
* мок-зависимости (window, document, localStorage). Так монолитный
* browser-скрипт проверяется без полной загрузки в Node.
*
* Запуск: `node --test tests/unit/base_layer.test.js`
* (в CI оборачивается pytest-тестом tests/unit/test_base_layer.py).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'src', 'web', 'app.js');
/**
* Извлекает блок ET-007 из app.js и собирает из него модуль,
* подставляя переданные зависимости.
*/
function loadBaseLayerModule(deps) {
const src = fs.readFileSync(APP_JS, 'utf8');
const m = src.match(
/\/\/ >>> ET-007 base layer toggle block[^\n]*\n([\s\S]*?)\/\/ <<< ET-007 base layer toggle block/
);
assert.ok(m, 'ET-007-блок не найден в app.js (маркеры отсутствуют)');
const factory = new Function(
'window', 'document',
m[1] + '\nreturn { getStoredBaseLayer, onBaseLayerToggle, applyBaseLayer, restoreBaseLayerState, syncBaseLayerUI, _firstOverlayLayerId, SATELLITE_SOURCE_ID, SATELLITE_LAYER_ID, SATELLITE_TILE_URL, SATELLITE_ATTRIBUTION, SATELLITE_HALO_LAYER_IDS };'
);
return factory(deps.window, deps.document);
}
/**
* Готовит изолированное мок-окружение для одного теста.
*
* Создаёт мок-карту с журналом вызовов (addSource/addLayer/
* setLayoutProperty/setPaintProperty), мок-DOM с кнопками
* #base-btn-schematic и #base-btn-satellite, мок-localStorage и
* мок-document.body.classList для определения активной темы.
*/
function makeEnv({
stored,
noStorage = false,
layers = ['background', 'osm-base', 'trails-track-halo-satellite', 'trails-track', 'trails-path-bridleway-halo-satellite', 'trails-path-bridleway', 'poi-circles', 'poi-labels'],
themeDark = false,
noMap = false,
} = {}) {
const calls = {
addSource: [],
addLayer: [],
setLayoutProperty: [],
setPaintProperty: [],
setItem: [],
};
const store = {};
if (stored !== undefined) store['map-base-layer'] = stored;
const localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); calls.setItem.push([k, String(v)]); },
};
const sourceSet = new Set();
const layerSet = new Set(layers);
const layoutByLayer = {};
const map = {
getSource: (id) => (sourceSet.has(id) ? { id } : undefined),
addSource: (id, spec) => { sourceSet.add(id); calls.addSource.push([id, spec]); },
getLayer: (id) => (layerSet.has(id) ? { id } : undefined),
addLayer: (spec, before) => {
layerSet.add(spec.id);
if (spec.layout && spec.layout.visibility) {
layoutByLayer[spec.id] = layoutByLayer[spec.id] || {};
layoutByLayer[spec.id].visibility = spec.layout.visibility;
}
calls.addLayer.push([spec, before]);
},
setLayoutProperty: (id, prop, val) => {
layoutByLayer[id] = layoutByLayer[id] || {};
layoutByLayer[id][prop] = val;
calls.setLayoutProperty.push([id, prop, val]);
},
setPaintProperty: (id, prop, val) => {
calls.setPaintProperty.push([id, prop, val]);
},
getLayoutProperty: (id, prop) => (layoutByLayer[id] || {})[prop],
getStyle: () => ({ layers: layers.map((id) => ({ id })) }),
};
const schBtn = { classList: { _classes: new Set(['seg-btn', 'active']),
toggle(name, on) { if (on) this._classes.add(name); else this._classes.delete(name); },
contains(n) { return this._classes.has(n); } } };
const satBtn = { classList: { _classes: new Set(['seg-btn']),
toggle(name, on) { if (on) this._classes.add(name); else this._classes.delete(name); },
contains(n) { return this._classes.has(n); } } };
const bodyClasses = new Set(themeDark ? ['theme-dark'] : ['theme-light']);
const document = {
getElementById: (id) => {
if (id === 'base-btn-schematic') return schBtn;
if (id === 'base-btn-satellite') return satBtn;
return null;
},
body: {
classList: {
contains: (c) => bodyClasses.has(c),
},
},
};
const win = noMap
? { localStorage }
: (noStorage
? { _map: map, get localStorage() { throw new Error('localStorage disabled'); } }
: { _map: map, localStorage });
const mod = loadBaseLayerModule({ window: win, document });
return { mod, calls, store, schBtn, satBtn, map, sourceSet, layerSet, layoutByLayer };
}
// ── U-01: Default — Схема, если localStorage пуст ─────────────────────
test('U-01: без сохранённого значения getStoredBaseLayer() возвращает "schematic"', () => {
const env = makeEnv();
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
test('U-01: restoreBaseLayerState() при пустом localStorage активирует «Схему»', () => {
const env = makeEnv();
env.mod.restoreBaseLayerState();
assert.ok(env.schBtn.classList.contains('active'));
assert.ok(!env.satBtn.classList.contains('active'));
// На схеме спутниковый источник НЕ создаётся.
assert.deepEqual(env.calls.addSource, []);
assert.deepEqual(env.calls.addLayer, []);
});
// ── U-02: Чтение значения 'satellite' из localStorage ─────────────────
test('U-02: restoreBaseLayerState() при stored=satellite активирует «Спутник»', () => {
const env = makeEnv({ stored: 'satellite' });
env.mod.restoreBaseLayerState();
assert.ok(env.satBtn.classList.contains('active'));
assert.ok(!env.schBtn.classList.contains('active'));
// Создан спутниковый source/layer.
assert.equal(env.calls.addSource.length, 1);
assert.equal(env.calls.addSource[0][0], 'satellite-raster');
assert.equal(env.calls.addLayer.length, 1);
assert.equal(env.calls.addLayer[0][0].id, 'satellite-base');
});
// ── U-03: Запись значения при переключении ────────────────────────────
test('U-03: onBaseLayerToggle("satellite") пишет в localStorage', () => {
const env = makeEnv();
env.mod.onBaseLayerToggle('satellite');
assert.deepEqual(env.calls.setItem, [['map-base-layer', 'satellite']]);
assert.equal(env.store['map-base-layer'], 'satellite');
});
// ── U-04: Игнор некорректного значения в localStorage ─────────────────
test('U-04: некорректное stored значение даёт дефолт "schematic"', () => {
const env = makeEnv({ stored: 'unknown' });
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
test('U-04: пустая строка трактуется как дефолт', () => {
const env = makeEnv({ stored: '' });
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
// ── U-05: Toggle на уже активный режим — no-op ────────────────────────
test('U-05: повторный onBaseLayerToggle("schematic") при активной схеме — no-op', () => {
const env = makeEnv();
env.mod.onBaseLayerToggle('schematic');
assert.deepEqual(env.calls.setItem, []);
assert.deepEqual(env.calls.addSource, []);
assert.deepEqual(env.calls.addLayer, []);
assert.deepEqual(env.calls.setLayoutProperty, []);
});
test('U-05: повторный onBaseLayerToggle("satellite") при активном спутнике — no-op', () => {
const env = makeEnv({ stored: 'satellite' });
// первый вызов вернёт source/layer
env.mod.restoreBaseLayerState();
const setLayoutBefore = env.calls.setLayoutProperty.length;
const setItemBefore = env.calls.setItem.length;
env.mod.onBaseLayerToggle('satellite');
// никаких новых обращений
assert.equal(env.calls.setLayoutProperty.length, setLayoutBefore);
assert.equal(env.calls.setItem.length, setItemBefore);
});
// ── U-10..U-11: syncBaseLayerUI() ─────────────────────────────────────
test('U-10: syncBaseLayerUI("satellite") переносит .active на «Спутник»', () => {
const env = makeEnv();
env.mod.syncBaseLayerUI('satellite');
assert.ok(env.satBtn.classList.contains('active'));
assert.ok(!env.schBtn.classList.contains('active'));
});
test('U-11: syncBaseLayerUI("schematic") переносит .active на «Схему»', () => {
const env = makeEnv();
// сначала сделаем спутник активным
env.mod.syncBaseLayerUI('satellite');
env.mod.syncBaseLayerUI('schematic');
assert.ok(env.schBtn.classList.contains('active'));
assert.ok(!env.satBtn.classList.contains('active'));
});
// ── I-01..I-02: спутниковый source/layer создаются при первом включении ─
test('I-01: applyBaseLayer("satellite") добавляет source satellite-raster (Esri URL)', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
assert.equal(env.calls.addSource.length, 1);
const [id, spec] = env.calls.addSource[0];
assert.equal(id, 'satellite-raster');
assert.equal(spec.type, 'raster');
assert.equal(spec.tileSize, 256);
assert.ok(spec.tiles[0].includes('arcgisonline.com'));
});
test('I-02: applyBaseLayer("satellite") добавляет layer satellite-base типа raster', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const [spec, before] = env.calls.addLayer[0];
assert.equal(spec.id, 'satellite-base');
assert.equal(spec.type, 'raster');
assert.equal(spec.source, 'satellite-raster');
// beforeId — первый terrain/trails/poi слой; в наборе по умолчанию это
// trails-track-halo-satellite (первый с префиксом trails-/poi-/terrain-).
assert.equal(before, 'trails-track-halo-satellite');
});
// ── I-03: visibility osm-base скрыт после переключения на спутник ──────
test('I-03: osm-base скрывается при включении спутника', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const osmCalls = env.calls.setLayoutProperty.filter((c) => c[0] === 'osm-base');
assert.ok(osmCalls.some((c) => c[1] === 'visibility' && c[2] === 'none'));
});
// ── I-04: возврат на схему — osm-base видим, satellite-base скрыт ──────
test('I-04: возврат на «Схему» возвращает osm-base в visible и скрывает satellite-base', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.calls.setLayoutProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const osmVisible = env.calls.setLayoutProperty.find(
(c) => c[0] === 'osm-base' && c[1] === 'visibility' && c[2] === 'visible'
);
const satHidden = env.calls.setLayoutProperty.find(
(c) => c[0] === 'satellite-base' && c[1] === 'visibility' && c[2] === 'none'
);
assert.ok(osmVisible, 'osm-base не возвращён в visible');
assert.ok(satHidden, 'satellite-base не скрыт при возврате на схему');
// source НЕ удаляется (TRZ §1 REQ-F-03).
assert.ok(env.sourceSet.has('satellite-raster'));
});
// ── I-07: атрибуция Esri зарегистрирована ──────────────────────────────
test('I-07: source содержит атрибуцию Esri', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const [, spec] = env.calls.addSource[0];
assert.ok(/Esri/.test(spec.attribution),
'attribution source не упоминает Esri');
});
// ── I-23/halo: halo-underlay-слои включаются на спутнике ──────────────
test('halo-underlay-слои включаются при «Спутнике» и скрываются при «Схеме»', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const trackHaloOn = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-track-halo-satellite' && c[2] === 'visible'
);
const pathHaloOn = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-path-bridleway-halo-satellite' && c[2] === 'visible'
);
assert.ok(trackHaloOn, 'halo для trails-track не включён');
assert.ok(pathHaloOn, 'halo для path/bridleway не включён');
env.calls.setLayoutProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const trackHaloOff = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-track-halo-satellite' && c[2] === 'none'
);
const pathHaloOff = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-path-bridleway-halo-satellite' && c[2] === 'none'
);
assert.ok(trackHaloOff && pathHaloOff, 'halo не скрыт при возврате на схему');
});
// ── I-24: POI text-halo на спутнике становится чёрным ─────────────────
test('I-24: POI labels на спутнике получают чёрный halo и width=2', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const haloColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
);
const haloWidth = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-width'
);
assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#000000']);
assert.deepEqual(haloWidth, ['poi-labels', 'text-halo-width', 2]);
});
// ── I-25: POI text-halo на схеме возвращается к значениям из style.json ─
test('I-25: возврат на «Схему» восстанавливает POI halo из светлого style.json', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const haloColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
);
assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#ffffff']);
});
test('I-25/dark: возврат на «Схему» в тёмной теме даёт halo из style-dark.json', () => {
const env = makeEnv({ themeDark: true });
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const haloColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
);
assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#1a1a2e']);
});
// ── background — тёмно-серый под спутником ────────────────────────────
test('фон под спутником в светлой теме — #1a1a1a', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const bg = env.calls.setPaintProperty.find(
(c) => c[0] === 'background' && c[1] === 'background-color'
);
assert.deepEqual(bg, ['background', 'background-color', '#1a1a1a']);
});
test('фон под спутником в тёмной теме — #2a2a2a', () => {
const env = makeEnv({ themeDark: true });
env.mod.applyBaseLayer('satellite');
const bg = env.calls.setPaintProperty.find(
(c) => c[0] === 'background' && c[1] === 'background-color'
);
assert.deepEqual(bg, ['background', 'background-color', '#2a2a2a']);
});
// ── валидация входа onBaseLayerToggle() ───────────────────────────────
test('onBaseLayerToggle() игнорирует некорректное значение', () => {
const env = makeEnv();
env.mod.onBaseLayerToggle('hybrid');
assert.deepEqual(env.calls.setItem, []);
assert.deepEqual(env.calls.addSource, []);
});
// ── устойчивость: отсутствует window._map ─────────────────────────────
test('applyBaseLayer() без window._map не падает', () => {
const env = makeEnv({ noMap: true });
assert.doesNotThrow(() => env.mod.applyBaseLayer('satellite'));
});
// ── устойчивость: недоступный localStorage (private mode) ─────────────
test('getStoredBaseLayer() при недоступном localStorage возвращает "schematic"', () => {
const env = makeEnv({ noStorage: true });
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
test('onBaseLayerToggle() не падает при недоступном localStorage', () => {
const env = makeEnv({ noStorage: true });
assert.doesNotThrow(() => env.mod.onBaseLayerToggle('satellite'));
});
// ── z-order: спутник вставляется ПОД terrain/trails/POI ───────────────
test('Z-order: satellite-base вставляется beforeId=первый terrain/trails/poi слой', () => {
const env = makeEnv({
layers: ['background', 'osm-base', 'terrain-hillshade', 'trails-track', 'poi-circles'],
});
env.mod.applyBaseLayer('satellite');
const [, before] = env.calls.addLayer[0];
assert.equal(before, 'terrain-hillshade');
});
test('Z-order: если overlay-слоёв ещё нет — addLayer вызывается без beforeId', () => {
const env = makeEnv({ layers: ['background', 'osm-base'] });
env.mod.applyBaseLayer('satellite');
const [, before] = env.calls.addLayer[0];
assert.equal(before, undefined);
});
// ── повторный applyBaseLayer('satellite') не пересоздаёт source/layer ─
test('повторный applyBaseLayer("satellite") не дублирует addSource/addLayer', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.mod.applyBaseLayer('schematic');
env.mod.applyBaseLayer('satellite');
assert.equal(env.calls.addSource.length, 1);
assert.equal(env.calls.addLayer.length, 1);
});

View File

@@ -0,0 +1,301 @@
"""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}"
)