'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 — единая satellite-константа #2a2a2a для обеих тем ───── // (P1-4: ранее в спецификации был расходящийся набор констант, в т.ч. // ошибочный #1a1a1a для светлой темы тёмнее, чем #2a2a2a для тёмной. // ADR-004 §6 — одна константа #2a2a2a на обе темы.) test('фон под спутником в светлой теме — единая константа #2a2a2a (P1-4)', () => { 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', '#2a2a2a']); }); test('фон под спутником в тёмной теме — та же константа #2a2a2a (P1-4)', () => { 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']); }); test('фон при возврате на «Схему» (light) — baseline #f0ede6 (Data §5)', () => { const env = makeEnv(); env.mod.applyBaseLayer('satellite'); env.calls.setPaintProperty.length = 0; env.mod.applyBaseLayer('schematic'); const bg = env.calls.setPaintProperty.find( (c) => c[0] === 'background' && c[1] === 'background-color' ); assert.deepEqual(bg, ['background', 'background-color', '#f0ede6']); }); test('фон при возврате на «Схему» (dark) — baseline #1a1a2e, не #1a1a1a (P1-4 / P2-3)', () => { const env = makeEnv({ themeDark: true }); env.mod.applyBaseLayer('satellite'); env.calls.setPaintProperty.length = 0; env.mod.applyBaseLayer('schematic'); const bg = env.calls.setPaintProperty.find( (c) => c[0] === 'background' && c[1] === 'background-color' ); assert.deepEqual(bg, ['background', 'background-color', '#1a1a2e']); }); // ── P1-2: POI text-color синхронно с halo ───────────────────────────── test('P1-2: на спутнике poi-labels text-color === #ffffff (читаемо поверх чёрного halo)', () => { const env = makeEnv(); env.mod.applyBaseLayer('satellite'); const textColor = env.calls.setPaintProperty.find( (c) => c[0] === 'poi-labels' && c[1] === 'text-color' ); assert.deepEqual(textColor, ['poi-labels', 'text-color', '#ffffff']); }); test('P1-2: возврат на «Схему» (light) восстанавливает poi-labels text-color === #333333', () => { const env = makeEnv(); env.mod.applyBaseLayer('satellite'); env.calls.setPaintProperty.length = 0; env.mod.applyBaseLayer('schematic'); const textColor = env.calls.setPaintProperty.find( (c) => c[0] === 'poi-labels' && c[1] === 'text-color' ); assert.deepEqual(textColor, ['poi-labels', 'text-color', '#333333']); }); test('P1-2: возврат на «Схему» (dark) восстанавливает poi-labels text-color === #e0e0e0', () => { const env = makeEnv({ themeDark: true }); env.mod.applyBaseLayer('satellite'); env.calls.setPaintProperty.length = 0; env.mod.applyBaseLayer('schematic'); const textColor = env.calls.setPaintProperty.find( (c) => c[0] === 'poi-labels' && c[1] === 'text-color' ); assert.deepEqual(textColor, ['poi-labels', 'text-color', '#e0e0e0']); }); // ── валидация входа 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); });