Files
enduro-trails/tests/unit/base_layer.test.js
claude-bot 1984b0bde6
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 5s
CI / build (push) Successful in 4s
CI / build (pull_request) Successful in 2s
fix(ET-007): address 6 P1 findings from review (docs + code)
12-review.md (REQUEST_CHANGES, attempt 2/3) flagged 6 must-fix items
in the analysis/architecture artefacts plus matching bugs that had
already leaked into the committed implementation. This patch lands
both: documents corrected, code aligned with corrected specs, tests
updated.

P1-1: TRZ/ADR/Data/Risks referenced fictional layer ids
(`trails-grade1..5-halo-satellite`, `paths-bridleway-halo-satellite`).
Actual style*.json has only `trails-track-halo-satellite` and
`trails-path-bridleway-halo-satellite`; grade differentiation lives
inside one `match` expression on `tracktype` within `trails-track`.
Docs rewritten to operate on real ids.

P1-2: POI labels contrast was broken — spec changed only halo-color
to black, leaving `text-color: #333333` (light theme baseline)
unreadable over the new black halo. Code+docs now switch BOTH
`text-color` (-> `#ffffff` on satellite) AND halo together, with
per-theme baselines (`#333333` light / `#e0e0e0` dark) restored on
return to Schematic.

P1-3: BRD §5 hillshade risk said «hillshade auto-disabled on
satellite», contradicting TRZ/ADR/AC. BRD wording aligned: hillshade
keeps working over satellite; visual check is AC-04.

P1-4: background-color had four divergent sources (`#1a1a1a`,
`#2a2a2a`, `#1a1a2e`, `#f0ede6`), incl. an inverted-theme typo and a
baseline `#1a1a1a` that didn't match the actual `style-dark.json:28`
value `#1a1a2e`. Settled on ADR-004's single-constant model: `#2a2a2a`
on satellite for both themes; on Schematic restore per-theme baselines
`#f0ede6` (light) / `#1a1a2e` (dark). `_applyBackgroundForSatellite`
fixed accordingly.

P1-5: app.js already had `layerState.basemap` and `toggleLayer
('basemap')` (legacy «Базовая карта» switch). Neither TRZ nor ADR
specified the interaction. Added save&restore contract: on entering
Satellite save `layerState.basemap` to `_savedBasemapState` and
force-hide `osm-base`; on returning to Schematic restore osm-base
visibility from the saved value. CSS hook `body.satellite-active
#btn-basemap { display:none }` keeps the user from trying to enable
a hybrid mode (out of scope, BRD §3). TRZ §5.6, ADR-004 §8.

P1-6: `restoreTrailsState()` and `onTrailsCheckbox()` only managed
visibility of `trails-track` / `trails-path-bridleway`, leaving
their halo-underlay siblings as «phantom» halos when the user
unchecked grunты/тропы under Satellite. Introduced
`_applyTrailHaloVisibility(map, base)` reading checkbox state from
DOM; called from `onTrailsCheckbox`, `restoreTrailsState`, and both
branches of `applyBaseLayer`. Rule: halo visible ⇔ (base ===
satellite) AND (checkbox ON). TRZ §5.7, ADR-004 §9.

Docs bumped: BRD v2, TRZ v2, AC v2, Data v2, Risks v2; ADR-004
получает «Ревизии»-секцию (status remains accepted — only editorial
fixes, no decision change).

Tests:
- tests/unit/base_layer.test.js: rewritten 2 background-color
  assertions (#1a1a1a expectation removed), added 6 new tests for
  P1-2 / P1-4 (POI text-color per-theme baselines, single satellite
  bg #2a2a2a, baseline restore on Schematic).
- All 33 JS unit tests + 22 pytest static checks green.
- Full pytest suite: 76 passed (excluding pre-existing
  shapely-import skipped collection in tests/unit/test_health.py).

Refs: ET-007
Review: docs/work-items/ET-007/12-review.md (P1-1..P1-6)
ADR: docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md (rev. 2026-05-31)

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

469 lines
22 KiB
JavaScript
Raw Permalink 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 — единая 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);
});