Files
enduro-trails/tests/unit/base_layer.test.js
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

412 lines
19 KiB
JavaScript
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.
'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);
});