style: format Map Source component boundary

This commit is contained in:
Sameh Khamis committed 2026-09-12 22:25:05 -07:00
1 parent 72d531b18f
commit da7496b7eb
6 files changed
+249 -64

No files matched your search

+6 -1
View File
@@ -318,7 +318,12 @@
}, },
"map-source-controls": { "map-source-controls": {
"exports": ["./ui/maps"], "exports": ["./ui/maps"],
"modules": ["src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/mapStackChips.js", "src/keySetupCore.mjs"], "modules": [
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/mapStackChips.js",
"src/keySetupCore.mjs"
],
"external": [] "external": []
} }
} }
+94 -24
View File
@@ -3,73 +3,143 @@
import fs from 'node:fs'; import fs from 'node:fs';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] }); const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage(); const page = await browser.newPage();
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
let failures = 0; let failures = 0;
const check = (name, ok) => { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; }; const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
try { try {
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0'); url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => { await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen'); const cover = document.getElementById('loading-screen');
return window.__godsEyeView?.styleManager?._mapSourceControls && cover?.classList.contains('hidden') && Number(getComputedStyle(cover).opacity) === 0; return (
}, { timeout: 60_000 }); window.__godsEyeView?.styleManager?._mapSourceControls &&
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
await page.evaluate(() => { await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const controller = manager.mapStackController; const controller = manager.mapStackController;
const original = controller.setStack; const original = controller.setStack;
window.__qaMapSelections = []; window.__qaMapSelections = [];
controller.setStack = function(id) { window.__qaMapSelections.push(id); return original.call(this, id); }; controller.setStack = function (id) {
manager.setPanelCollapsed('control-panel', false, { persist: false, syncShare: false }); window.__qaMapSelections.push(id);
return original.call(this, id);
};
manager.setPanelCollapsed('control-panel', false, {
persist: false,
syncShare: false,
});
}); });
await page.click('[data-stack-id="osm"]'); await page.click('[data-stack-id="osm"]');
await page.waitForFunction(() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'osm'); await page.waitForFunction(
check('a native chip click requests OSM exactly once', await page.evaluate(() => window.__qaMapSelections.length === 1 && window.__qaMapSelections[0] === 'osm')); () =>
check('active chip and status follow the displayed OSM source', await page.evaluate(() => { window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
'osm',
);
check(
'a native chip click requests OSM exactly once',
await page.evaluate(
() =>
window.__qaMapSelections.length === 1 &&
window.__qaMapSelections[0] === 'osm',
),
);
check(
'active chip and status follow the displayed OSM source',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
return manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' && manager._mapStackStatus.textContent === manager.mapStackController.getState().activeStack.shortLabel; return (
})); manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
check('the public map action uses the same component and truthful result', await page.evaluate(async () => { .stackId === 'osm' &&
manager._mapStackStatus.textContent ===
manager.mapStackController.getState().activeStack.shortLabel
);
}),
);
check(
'the public map action uses the same component and truthful result',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const result = await manager.setMapStack('esri-imagery'); const result = await manager.setMapStack('esri-imagery');
const active = manager.mapStackController.getActiveId(); const active = manager.mapStackController.getActiveId();
return window.__qaMapSelections.at(-1) === 'esri-imagery' && manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset.stackId === active && (result.ok || !!manager.mapStackController.getState().lastError); return (
})); window.__qaMapSelections.at(-1) === 'esri-imagery' &&
check('refresh revokes retained old chips', await page.evaluate(() => { manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === active &&
(result.ok || !!manager.mapStackController.getState().lastError)
);
}),
);
check(
'refresh revokes retained old chips',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]'); const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
manager._mapSourceControls.refresh(); manager._mapSourceControls.refresh();
const before = window.__qaMapSelections.length; const before = window.__qaMapSelections.length;
old.click(); old.click();
return window.__qaMapSelections.length === before; return window.__qaMapSelections.length === before;
})); }),
check('refreshed chips still select once', await page.evaluate(async () => { );
check(
'refreshed chips still select once',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length; const before = window.__qaMapSelections.length;
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
await Promise.resolve(); await Promise.resolve();
return window.__qaMapSelections.length === before + 1; return window.__qaMapSelections.length === before + 1;
})); }),
await page.waitForFunction(() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'osm'); );
await page.waitForFunction(
() =>
window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
'osm',
);
fs.mkdirSync('qa-shots/map-source-controls', { recursive: true }); fs.mkdirSync('qa-shots/map-source-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' }); await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' });
await page.setViewport({ width: 620, height: 900 }); await page.setViewport({ width: 620, height: 900 });
await page.waitForFunction(() => document.getElementById('left-panel-stack')?.dataset.layoutMode === 'mobile'); await page.waitForFunction(
() =>
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
'mobile',
);
await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' }); await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' });
check('destroyed controls cannot issue requests from retained chips', await page.evaluate(async () => { check(
'destroyed controls cannot issue requests from retained chips',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length; const before = window.__qaMapSelections.length;
manager._mapSourceControls.destroy(); manager._mapSourceControls.destroy();
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
const result = await manager._mapSourceControls.select('esri-imagery'); const result = await manager._mapSourceControls.select('esri-imagery');
return result === null && window.__qaMapSelections.length === before; return result === null && window.__qaMapSelections.length === before;
})); }),
);
check('no uncaught browser errors', errors.length === 0); check('no uncaught browser errors', errors.length === 0);
if (errors.length) console.log(JSON.stringify(errors)); if (errors.length) console.log(JSON.stringify(errors));
} finally { await browser.close(); } } finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`); console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0; process.exitCode = failures ? 1 : 0;
+25 -8
View File
@@ -40,7 +40,9 @@ export function mapStackChipModel(stack, activeId) {
const fallbackReason = requiresIon const fallbackReason = requiresIon
? keySetupRequirement('cesium-ion') ? keySetupRequirement('cesium-ion')
: `${label || 'This map stack'} is unavailable`; : `${label || 'This map stack'} is unavailable`;
const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason); const unavailableHint = available
? ''
: String(stack?.unavailableReason || fallbackReason);
return { return {
id: String(stack?.id ?? ''), id: String(stack?.id ?? ''),
label, label,
@@ -63,10 +65,10 @@ export function mapStackChipModel(stack, activeId) {
* `PRESENTED_MAP_STACK_IDS` order; unlisted stacks stay outside this presentation. * `PRESENTED_MAP_STACK_IDS` order; unlisted stacks stay outside this presentation.
*/ */
export function mapStackChipModels(stacks, activeId) { export function mapStackChipModels(stacks, activeId) {
const stacksById = new Map((Array.isArray(stacks) ? stacks : []) const stacksById = new Map(
.map((stack) => [stack?.id, stack])); (Array.isArray(stacks) ? stacks : []).map((stack) => [stack?.id, stack]),
return PRESENTED_MAP_STACK_IDS );
.map((id) => stacksById.get(id)) return PRESENTED_MAP_STACK_IDS.map((id) => stacksById.get(id))
.filter(Boolean) .filter(Boolean)
.map((stack) => mapStackChipModel(stack, activeId)); .map((stack) => mapStackChipModel(stack, activeId));
} }
@@ -82,7 +84,17 @@ export function mapStackChipModels(stacks, activeId) {
* @param {(element: HTMLElement, type: string, listener: Function) => void} [options.bind] - Listener owner override. * @param {(element: HTMLElement, type: string, listener: Function) => void} [options.bind] - Listener owner override.
* @returns {Array<object>} The rendered chip models. * @returns {Array<object>} The rendered chip models.
*/ */
export function renderMapStackChips(container, stacks, { activeId = null, onSelect = null, doc, bind = (element, type, listener) => element.addEventListener(type, listener) } = {}) { export function renderMapStackChips(
container,
stacks,
{
activeId = null,
onSelect = null,
doc,
bind = (element, type, listener) =>
element.addEventListener(type, listener),
} = {},
) {
if (!container) return []; if (!container) return [];
const ownerDoc = doc || container.ownerDocument || globalThis.document; const ownerDoc = doc || container.ownerDocument || globalThis.document;
if (!ownerDoc?.createElement) return []; if (!ownerDoc?.createElement) return [];
@@ -97,13 +109,18 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele
MAP_STACK_CHIP_CLASS, MAP_STACK_CHIP_CLASS,
model.active ? 'active' : '', model.active ? 'active' : '',
model.available ? '' : 'unavailable', model.available ? '' : 'unavailable',
].filter(Boolean).join(' '); ]
.filter(Boolean)
.join(' ');
chip.dataset.stackId = model.id; chip.dataset.stackId = model.id;
chip.title = model.title; chip.title = model.title;
chip.setAttribute('aria-pressed', String(model.active)); chip.setAttribute('aria-pressed', String(model.active));
chip.setAttribute('aria-disabled', String(!model.available)); chip.setAttribute('aria-disabled', String(!model.available));
if (!model.available) { if (!model.available) {
chip.setAttribute('aria-label', `${model.label} unavailable: ${model.unavailableHint}`); chip.setAttribute(
'aria-label',
`${model.label} unavailable: ${model.unavailableHint}`,
);
} }
const label = ownerDoc.createElement('span'); const label = ownerDoc.createElement('span');
+8 -1
View File
@@ -1,2 +1,9 @@
export { createMapSourceControls } from './mapSourceControls.js'; export { createMapSourceControls } from './mapSourceControls.js';
export { MAP_STACK_CHIP_CLASS, PRESENTED_MAP_STACK_IDS, mapStackChipModel, mapStackChipModels, renderMapStackChips, syncMapStackChips } from '../mapStackChips.js'; export {
MAP_STACK_CHIP_CLASS,
PRESENTED_MAP_STACK_IDS,
mapStackChipModel,
mapStackChipModels,
renderMapStackChips,
syncMapStackChips,
} from '../mapStackChips.js';
+19 -6
View File
@@ -5,8 +5,13 @@ import { renderMapStackChips, syncMapStackChips } from '../mapStackChips.js';
* The supplied controller remains authoritative for availability and active state. * The supplied controller remains authoritative for availability and active state.
*/ */
export function createMapSourceControls({ export function createMapSourceControls({
container, statusElement, controller, subscribe, container,
claimSelection = () => {}, onStateChanged = () => {}, onError = () => {}, statusElement,
controller,
subscribe,
claimSelection = () => {},
onStateChanged = () => {},
onError = () => {},
}) { }) {
let destroyed = false; let destroyed = false;
let generation = 0; let generation = 0;
@@ -20,7 +25,10 @@ export function createMapSourceControls({
syncMapStackChips(container, state.activeId); syncMapStackChips(container, state.activeId);
if (statusElement) { if (statusElement) {
const stack = state.activeStack; const stack = state.activeStack;
statusElement.textContent = state.status === 'switching' ? '...' : (stack?.shortLabel || stack?.label || 'MAP'); statusElement.textContent =
state.status === 'switching'
? '...'
: stack?.shortLabel || stack?.label || 'MAP';
statusElement.classList.toggle('warn', !!state.lastError); statusElement.classList.toggle('warn', !!state.lastError);
} }
} }
@@ -42,7 +50,8 @@ export function createMapSourceControls({
} }
if (destroyed || current !== generation) return state; if (destroyed || current !== generation) return state;
render(controller.getState()); render(controller.getState());
if (state?.activeId === before && stackId !== before && state?.lastError) onError(state.lastError); if (state?.activeId === before && stackId !== before && state?.lastError)
onError(state.lastError);
if (syncShare) onStateChanged(); if (syncShare) onStateChanged();
return state; return state;
} }
@@ -51,7 +60,9 @@ export function createMapSourceControls({
for (const remove of removers.splice(0)) remove(); for (const remove of removers.splice(0)) remove();
renderMapStackChips(container, controller.getStacks(), { renderMapStackChips(container, controller.getStacks(), {
activeId: controller.getActiveId(), activeId: controller.getActiveId(),
onSelect: (id) => { void select(id).catch(() => {}); }, onSelect: (id) => {
void select(id).catch(() => {});
},
bind, bind,
}); });
render(controller.getState()); render(controller.getState());
@@ -63,7 +74,9 @@ export function createMapSourceControls({
}); });
refresh(); refresh();
return { return {
render, select, refresh, render,
select,
refresh,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
+97 -24
View File
@@ -16,7 +16,9 @@ function makeElement(tagName = 'div') {
children: [], children: [],
classList: { classList: {
toggle(name, force) { toggle(name, force) {
const classes = new Set(String(element.className).split(/\s+/).filter(Boolean)); const classes = new Set(
String(element.className).split(/\s+/).filter(Boolean),
);
const next = force === undefined ? !classes.has(name) : !!force; const next = force === undefined ? !classes.has(name) : !!force;
if (next) classes.add(name); if (next) classes.add(name);
else classes.delete(name); else classes.delete(name);
@@ -26,16 +28,35 @@ function makeElement(tagName = 'div') {
return String(element.className).split(/\s+/).includes(name); return String(element.className).split(/\s+/).includes(name);
}, },
}, },
appendChild(child) { element.children.push(child); return child; }, appendChild(child) {
setAttribute(name, value) { element.attributes[name] = String(value); }, element.children.push(child);
getAttribute(name) { return element.attributes[name] ?? null; }, return child;
addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); }, },
removeEventListener(type, handler) { element.listeners[type] = (element.listeners[type] || []).filter((current) => current !== handler); }, setAttribute(name, value) {
click() { for (const handler of element.listeners.click || []) handler(); }, element.attributes[name] = String(value);
},
getAttribute(name) {
return element.attributes[name] ?? null;
},
addEventListener(type, handler) {
(element.listeners[type] ||= []).push(handler);
},
removeEventListener(type, handler) {
element.listeners[type] = (element.listeners[type] || []).filter(
(current) => current !== handler,
);
},
click() {
for (const handler of element.listeners.click || []) handler();
},
}; };
Object.defineProperty(element, 'innerHTML', { Object.defineProperty(element, 'innerHTML', {
get() { return ''; }, get() {
set() { element.children.length = 0; }, return '';
},
set() {
element.children.length = 0;
},
}); });
return element; return element;
} }
@@ -44,7 +65,16 @@ function fixture() {
const container = makeElement(); const container = makeElement();
container.ownerDocument = { createElement: (tag) => makeElement(tag) }; container.ownerDocument = { createElement: (tag) => makeElement(tag) };
const statusElement = makeElement(); const statusElement = makeElement();
const sources = [{ id: 'osm', label: 'OSM' }, { id: 'esri-imagery', label: 'Esri' }, { id: 'bing-aerial', label: 'Bing', available: false, unavailableReason: 'Unavailable for this test' }]; const sources = [
{ id: 'osm', label: 'OSM' },
{ id: 'esri-imagery', label: 'Esri' },
{
id: 'bing-aerial',
label: 'Bing',
available: false,
unavailableReason: 'Unavailable for this test',
},
];
let state = { activeId: 'osm', activeStack: sources[0], status: 'ready' }; let state = { activeId: 'osm', activeStack: sources[0], status: 'ready' };
const listeners = new Set(); const listeners = new Set();
const requests = []; const requests = [];
@@ -55,20 +85,49 @@ function fixture() {
getState: (status) => ({ ...state, status: status || state.status }), getState: (status) => ({ ...state, status: status || state.status }),
setStack: (id) => { setStack: (id) => {
calls.push(['select', id]); calls.push(['select', id]);
return new Promise((resolve, reject) => requests.push({ id, resolve, reject })); return new Promise((resolve, reject) =>
requests.push({ id, resolve, reject }),
);
}, },
}; };
const controls = createMapSourceControls({ container, statusElement, controller, const controls = createMapSourceControls({
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, container,
claimSelection: () => calls.push(['claim']), onStateChanged: () => calls.push(['state']), onError: (message) => calls.push(['error', message]), statusElement,
controller,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
claimSelection: () => calls.push(['claim']),
onStateChanged: () => calls.push(['state']),
onError: (message) => calls.push(['error', message]),
}); });
const chip = (id) => container.children.find((el) => el.dataset.stackId === id); const chip = (id) =>
container.children.find((el) => el.dataset.stackId === id);
function change(id, lastError = null) { function change(id, lastError = null) {
state = { activeId: id, activeStack: sources.find((source) => source.id === id), status: 'ready', lastError }; state = {
activeId: id,
activeStack: sources.find((source) => source.id === id),
status: 'ready',
lastError,
};
return controller.getState(); return controller.getState();
} }
const emit = () => { for (const listener of listeners) listener(); }; const emit = () => {
return { container, statusElement, controller, controls, requests, calls, listeners, chip, change, emit }; for (const listener of listeners) listener();
};
return {
container,
statusElement,
controller,
controls,
requests,
calls,
listeners,
chip,
change,
emit,
};
} }
test('initial presentation uses actual state and retains unavailable chip semantics', () => { test('initial presentation uses actual state and retains unavailable chip semantics', () => {
@@ -76,7 +135,11 @@ test('initial presentation uses actual state and retains unavailable chip semant
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.textContent, 'OSM'); assert.equal(f.statusElement.textContent, 'OSM');
const unavailable = f.chip('bing-aerial'); const unavailable = f.chip('bing-aerial');
assert.equal(unavailable.disabled, false, 'unavailable chips remain focusable'); assert.equal(
unavailable.disabled,
false,
'unavailable chips remain focusable',
);
assert.equal(unavailable.getAttribute('aria-disabled'), 'true'); assert.equal(unavailable.getAttribute('aria-disabled'), 'true');
unavailable.click(); unavailable.click();
assert.equal(f.requests.length, 0); assert.equal(f.requests.length, 0);
@@ -98,8 +161,10 @@ test('selection claims authority before requesting and never lights a pending so
test('provider-driven fallback updates the active chip and durable state notification', () => { test('provider-driven fallback updates the active chip and durable state notification', () => {
const f = fixture(); const f = fixture();
f.change('esri-imagery'); f.emit(); f.change('esri-imagery');
f.change('osm', 'Source unavailable; using OSM'); f.emit(); f.emit();
f.change('osm', 'Source unavailable; using OSM');
f.emit();
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.textContent, 'OSM'); assert.equal(f.statusElement.textContent, 'OSM');
assert.equal(f.statusElement.classList.contains('warn'), true); assert.equal(f.statusElement.classList.contains('warn'), true);
@@ -114,7 +179,11 @@ test('a late superseded response cannot overwrite the latest displayed selection
f.requests[1].resolve(f.change('osm')); f.requests[1].resolve(f.change('osm'));
await second; await second;
const count = f.calls.length; const count = f.calls.length;
f.requests[0].resolve({ activeId: 'esri-imagery', activeStack: { label: 'old' }, lastError: 'obsolete failure' }); f.requests[0].resolve({
activeId: 'esri-imagery',
activeStack: { label: 'old' },
lastError: 'obsolete failure',
});
await first; await first;
assert.equal(f.statusElement.textContent, 'OSM'); assert.equal(f.statusElement.textContent, 'OSM');
assert.equal(f.calls.length, count); assert.equal(f.calls.length, count);
@@ -128,7 +197,9 @@ test('failed selection keeps the real source lit and reports its error', async (
await pending; await pending;
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.classList.contains('warn'), true); assert.equal(f.statusElement.classList.contains('warn'), true);
assert.ok(f.calls.some((call) => call[0] === 'error' && call[1] === 'Unavailable')); assert.ok(
f.calls.some((call) => call[0] === 'error' && call[1] === 'Unavailable'),
);
f.controls.destroy(); f.controls.destroy();
}); });
@@ -138,7 +209,9 @@ test('rejected selection leaves switching presentation and propagates a useful e
f.requests[0].reject(new Error('Request failed')); f.requests[0].reject(new Error('Request failed'));
await assert.rejects(pending, /Request failed/); await assert.rejects(pending, /Request failed/);
assert.equal(f.statusElement.textContent, 'OSM'); assert.equal(f.statusElement.textContent, 'OSM');
assert.ok(f.calls.some((call) => call[0] === 'error' && call[1] === 'Request failed')); assert.ok(
f.calls.some((call) => call[0] === 'error' && call[1] === 'Request failed'),
);
f.controls.destroy(); f.controls.destroy();
}); });