Merge pull request #394 from bilawalsidhu/refactor/map-source-controls

refactor: isolate Map Source controls
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-12 22:31:36 -07:00
commit 31f1923633
14 files changed
+654 -79

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback.
- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
- Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior.
+9
View File
@@ -260,3 +260,12 @@ releasing UI consumers, then destroy to remove owned stages and restore the
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers
without loading the renderer. UI presentation and product-action coordination
remain in their callers.
## Map Source controls
`ui/maps` owns source-chip presentation, selection feedback and its subscription
lifetime. It receives the existing controller and explicit state/action callbacks;
it imports no renderer or application. Source construction and availability policy
remain with the map controller. Rebuilding controls removes their previous chip
listeners, and destruction suppresses late completions without owning or destroying
the supplied controller.
+9
View File
@@ -1,5 +1,14 @@
# God's Eye View Current State
## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection
feedback. Loading remains with the supplied map controller. The active chip
follows the source actually displayed, including fallback; obsolete completions
cannot overwrite a newer selection. Refresh and destruction revoke old listeners
and destruction suppresses late UI updates. Available choices and setup behavior
remain unchanged.
## Visual effects ownership
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
+2 -1
View File
@@ -125,6 +125,7 @@
"./ui/input": "./src/ui/visualInput.js",
"./ui/display": "./src/ui/displayControls.js",
"./ui/effects": "./src/ui/effects.js",
"./ui/effects/bloom": "./src/bloom.js"
"./ui/effects/bloom": "./src/bloom.js",
"./ui/maps": "./src/ui/mapSource.js"
}
}
+6 -1
View File
@@ -148,5 +148,10 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs"
"scripts/qa-visual-effects.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
"src/mapStackChips.js",
"scripts/qa-map-source-controls.mjs"
]
+10
View File
@@ -315,5 +315,15 @@
"src/styles/snow.js"
],
"external": ["cesium"]
},
"map-source-controls": {
"exports": ["./ui/maps"],
"modules": [
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/mapStackChips.js",
"src/keySetupCore.mjs"
],
"external": []
}
}
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
/** Browser acceptance for Map Source control lifecycle and truthful selection. */
import fs from 'node:fs';
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 page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
window.__godsEyeView?.styleManager?._mapSourceControls &&
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const controller = manager.mapStackController;
const original = controller.setStack;
window.__qaMapSelections = [];
controller.setStack = function (id) {
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.waitForFunction(
() =>
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;
return (
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.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 result = await manager.setMapStack('esri-imagery');
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)
);
}),
);
check(
'refresh revokes retained old chips',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
manager._mapSourceControls.refresh();
const before = window.__qaMapSelections.length;
old.click();
return window.__qaMapSelections.length === before;
}),
);
check(
'refreshed chips still select once',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
await Promise.resolve();
return window.__qaMapSelections.length === before + 1;
}),
);
await page.waitForFunction(
() =>
window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
'osm',
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
await manager.setMapStack('photoreal');
manager.setPanelCollapsed('control-panel', false, {
persist: false,
syncShare: false,
});
document.querySelector('.map-stack-chip.active')?.focus();
});
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (-97.7431 * Math.PI) / 180,
latitude: (30.2568 * Math.PI) / 180,
height: 1200,
}),
orientation: { heading: 0, pitch: -0.85, roll: 0 },
});
viewer.scene.requestRender();
});
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const settled = await page
.waitForFunction(
() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 60_000 },
)
.then(
() => true,
() => false,
);
check('visible tile content settles before visual captures', settled);
fs.mkdirSync('qa-shots/map-source-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
await page.waitForFunction(
() =>
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
'mobile',
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed(
'control-panel',
false,
{ persist: false, syncShare: false },
),
);
await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' });
check(
'destroyed controls cannot issue requests from retained chips',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapSourceControls.destroy();
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
const result = await manager._mapSourceControls.select('esri-imagery');
return result === null && window.__qaMapSelections.length === before;
}),
);
check('no uncaught browser errors', errors.length === 0);
if (errors.length) console.log(JSON.stringify(errors));
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+30 -12
View File
@@ -1,8 +1,8 @@
// MAP STACK source chips — the always-visible replacement for the `<select>`
// that used to sit in the Map Stack panel. One button per stack, rendered from
// `MapStackController.getStacks()`. The four owner-approved sources below are
// `MapStackController.getStacks()`. The approved sources below are
// the whole shipped set; keeping the allowlist explicit means a stack added to
// `MAP_STACKS` for internal use cannot reach the tray until someone names it
// `MAP_STACKS` for another purpose cannot reach the tray until someone names it
// here.
//
// The chips are a control SURFACE only: selecting one calls back into the same
@@ -40,7 +40,9 @@ export function mapStackChipModel(stack, activeId) {
const fallbackReason = requiresIon
? keySetupRequirement('cesium-ion')
: `${label || 'This map stack'} is unavailable`;
const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason);
const unavailableHint = available
? ''
: String(stack?.unavailableReason || fallbackReason);
return {
id: String(stack?.id ?? ''),
label,
@@ -60,13 +62,13 @@ export function mapStackChipModel(stack, activeId) {
* @param {Array<object>} stacks - `MapStackController.getStacks()` output.
* @param {string|null} activeId - Currently active stack id.
* @returns {Array<object>} One chip model per approved presentation id, in
* `PRESENTED_MAP_STACK_IDS` order; internal and future stacks stay hidden.
* `PRESENTED_MAP_STACK_IDS` order; unlisted stacks stay outside this presentation.
*/
export function mapStackChipModels(stacks, activeId) {
const stacksById = new Map((Array.isArray(stacks) ? stacks : [])
.map((stack) => [stack?.id, stack]));
return PRESENTED_MAP_STACK_IDS
.map((id) => stacksById.get(id))
const stacksById = new Map(
(Array.isArray(stacks) ? stacks : []).map((stack) => [stack?.id, stack]),
);
return PRESENTED_MAP_STACK_IDS.map((id) => stacksById.get(id))
.filter(Boolean)
.map((stack) => mapStackChipModel(stack, activeId));
}
@@ -79,9 +81,20 @@ export function mapStackChipModels(stacks, activeId) {
* @param {string|null} [options.activeId] - Currently active stack id.
* @param {(stackId: string) => void} [options.onSelect] - Selection callback.
* @param {Document} [options.doc] - Document override (tests).
* @param {(element: HTMLElement, type: string, listener: Function) => void} [options.bind] - Listener owner override.
* @returns {Array<object>} The rendered chip models.
*/
export function renderMapStackChips(container, stacks, { activeId = null, onSelect = null, doc } = {}) {
export function renderMapStackChips(
container,
stacks,
{
activeId = null,
onSelect = null,
doc,
bind = (element, type, listener) =>
element.addEventListener(type, listener),
} = {},
) {
if (!container) return [];
const ownerDoc = doc || container.ownerDocument || globalThis.document;
if (!ownerDoc?.createElement) return [];
@@ -96,13 +109,18 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele
MAP_STACK_CHIP_CLASS,
model.active ? 'active' : '',
model.available ? '' : 'unavailable',
].filter(Boolean).join(' ');
]
.filter(Boolean)
.join(' ');
chip.dataset.stackId = model.id;
chip.title = model.title;
chip.setAttribute('aria-pressed', String(model.active));
chip.setAttribute('aria-disabled', String(!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');
@@ -117,7 +135,7 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele
chip.appendChild(requirement);
}
chip.addEventListener('click', () => {
bind(chip, 'click', () => {
if (!model.available) return;
onSelect?.(model.id);
});
+16 -20
View File
@@ -347,26 +347,22 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panel\.querySelector\('\.map-stack-chip'\)/);
assert.match(
ui,
/renderMapStackChips\(this\._mapStackChips, this\.mapStackController\.getStacks\(\), \{[\s\S]*?onSelect: \(stackId\) => \{ this\._setMapStack\(stackId\); \}/,
'chips must dispatch through the same _setMapStack path the dropdown used',
);
assert.match(
ui,
/_renderMapStackState\(state\) \{[\s\S]*?syncMapStackChips\(this\._mapStackChips, state\.activeId\)/,
'the active chip must be re-synced from controller state',
);
assert.match(
ui,
/window\.addEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/,
'provider-driven fallback must re-sync the UI without a user click',
);
assert.match(
ui,
/window\.removeEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/,
'the provider-driven state listener must be released with StyleManager',
);
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path');
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path');
assert.match(controls, /await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller');
assert.match(controls, /render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state');
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click');
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover');
assert.match(controls, /unsubscribe\?\.\(\)/,
'component destruction releases its subscription');
});
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
+1
View File
@@ -691,6 +691,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
const synchronous = disposal.slice(0, firstAwait);
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
});
+18 -45
View File
@@ -1,3 +1,4 @@
import { createMapSourceControls } from './ui/mapSource.js';
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
import { bindDisplayControls } from './ui/displayControls.js';
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
@@ -23,7 +24,6 @@ import {
isExplicitLayerStateOrigin,
LayerStateCoordinator,
} from './data/layerState.js';
import { renderMapStackChips, syncMapStackChips } from './mapStackChips.js';
import { OrbitController } from './orbit.js';
import {
CelestialRing,
@@ -3329,27 +3329,20 @@ export class StyleManager {
* @returns {void}
*/
_initMapStackControl() {
if (!this._mapStackChips || !this.mapStackController) return;
if (!this._mapStackChangeHandler) {
// Provider-driven transitions (notably Esri tile-error fallback) do not
// pass through `_setMapStack()`. Follow the controller's existing public
// event so the lit tile, the status line, AND the durable share state all
// describe the rendered source — without the share sync, a silent
// fallback leaves copyLink() encoding a stack that is no longer shown.
this._mapStackChangeHandler = (event) => {
this._renderMapStackState(event.detail);
this._syncShareState();
};
window.addEventListener('gev:map-stack-changed', this._mapStackChangeHandler);
}
renderMapStackChips(this._mapStackChips, this.mapStackController.getStacks(), {
activeId: this.mapStackController.getActiveId(),
onSelect: (stackId) => { this._setMapStack(stackId); },
if (!this.mapStackController) return;
this._mapSourceControls?.destroy();
this._mapSourceControls = createMapSourceControls({
container: this._mapStackChips,
statusElement: this._mapStackStatus,
controller: this.mapStackController,
subscribe: (onChange) => {
window.addEventListener('gev:map-stack-changed', onChange);
return () => window.removeEventListener('gev:map-stack-changed', onChange);
},
claimSelection: () => this.shareLinkManager?.claimRestoreLane?.('map'),
onStateChanged: () => this._syncShareState(),
onError: (message) => this._showToast(message),
});
this._renderMapStackState(this.mapStackController.getState());
}
/**
@@ -3361,16 +3354,7 @@ export class StyleManager {
*/
async _setMapStack(stackId, { syncShare = true } = {}) {
if (!this.mapStackController) return;
if (syncShare) this.shareLinkManager?.claimRestoreLane?.('map');
const before = this.mapStackController.getActiveId();
this._renderMapStackState(this.mapStackController.getState('switching'));
const state = await this.mapStackController.setStack(stackId);
this._renderMapStackState(state);
if (state?.activeId === before && stackId !== before && state?.lastError) {
this._showToast(state.lastError);
}
if (syncShare) this._syncShareState();
return this._mapSourceControls.select(stackId, { syncShare });
}
/**
@@ -3381,16 +3365,7 @@ export class StyleManager {
* @returns {void}
*/
_renderMapStackState(state) {
if (!state) return;
syncMapStackChips(this._mapStackChips, state.activeId);
if (this._mapStackStatus) {
const stack = state.activeStack;
const label = state.status === 'switching'
? '...'
: (stack?.shortLabel || stack?.label || 'MAP');
this._mapStackStatus.textContent = label;
this._mapStackStatus.classList.toggle('warn', !!state.lastError);
}
this._mapSourceControls?.render(state);
}
/**
@@ -9401,6 +9376,7 @@ export class StyleManager {
this._disposed = true;
this._applicationShortcuts?.destroy();
this._displayControls?.destroy();
this._mapSourceControls?.destroy();
this._visualEffects.stop();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
@@ -9430,10 +9406,7 @@ export class StyleManager {
window.removeEventListener('gev:awareness-subject-cleared', this._awarenessClearedHandler);
this._awarenessClearedHandler = null;
}
if (this._mapStackChangeHandler) {
window.removeEventListener('gev:map-stack-changed', this._mapStackChangeHandler);
this._mapStackChangeHandler = null;
}
// Invalidate any in-flight Context transaction the same way a newer request
// would. Without this, a reinstatement already past its awaits could
// re-enable a mode's entry layer and republish `_contextMode` while the
+9
View File
@@ -0,0 +1,9 @@
export { createMapSourceControls } from './mapSourceControls.js';
export {
MAP_STACK_CHIP_CLASS,
PRESENTED_MAP_STACK_IDS,
mapStackChipModel,
mapStackChipModels,
renderMapStackChips,
syncMapStackChips,
} from '../mapStackChips.js';
+88
View File
@@ -0,0 +1,88 @@
import { renderMapStackChips, syncMapStackChips } from '../mapStackChips.js';
/**
* Own Map Source presentation and selection without constructing map providers.
* The supplied controller remains authoritative for availability and active state.
*/
export function createMapSourceControls({
container,
statusElement,
controller,
subscribe,
claimSelection = () => {},
onStateChanged = () => {},
onError = () => {},
}) {
let destroyed = false;
let generation = 0;
const removers = [];
const bind = (element, type, listener) => {
element.addEventListener(type, listener);
removers.push(() => element.removeEventListener(type, listener));
};
function render(state) {
if (destroyed || !state) return;
syncMapStackChips(container, state.activeId);
if (statusElement) {
const stack = state.activeStack;
statusElement.textContent =
state.status === 'switching'
? '...'
: stack?.shortLabel || stack?.label || 'MAP';
statusElement.classList.toggle('warn', !!state.lastError);
}
}
async function select(stackId, { syncShare = true } = {}) {
if (destroyed) return null;
const current = ++generation;
if (syncShare) claimSelection();
const before = controller.getActiveId();
render(controller.getState('switching'));
let state;
try {
state = await controller.setStack(stackId);
} catch (error) {
if (!destroyed && current === generation) {
render(controller.getState());
onError(error?.message || String(error));
}
throw error;
}
if (destroyed || current !== generation) return state;
render(controller.getState());
if (state?.activeId === before && stackId !== before && state?.lastError)
onError(state.lastError);
if (syncShare) onStateChanged();
return state;
}
function refresh() {
if (destroyed) return;
for (const remove of removers.splice(0)) remove();
renderMapStackChips(container, controller.getStacks(), {
activeId: controller.getActiveId(),
onSelect: (id) => {
void select(id).catch(() => {});
},
bind,
});
render(controller.getState());
}
const unsubscribe = subscribe(() => {
if (destroyed) return;
render(controller.getState());
onStateChanged();
});
refresh();
return {
render,
select,
refresh,
destroy() {
if (destroyed) return;
destroyed = true;
generation++;
unsubscribe?.();
for (const remove of removers.splice(0)) remove();
},
};
}
+252
View File
@@ -0,0 +1,252 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMapSourceControls } from './mapSourceControls.js';
function makeElement(tagName = 'div') {
const element = {
tagName,
type: '',
className: '',
title: '',
disabled: false,
textContent: '',
dataset: {},
attributes: {},
listeners: {},
children: [],
classList: {
toggle(name, force) {
const classes = new Set(
String(element.className).split(/\s+/).filter(Boolean),
);
const next = force === undefined ? !classes.has(name) : !!force;
if (next) classes.add(name);
else classes.delete(name);
element.className = [...classes].join(' ');
},
contains(name) {
return String(element.className).split(/\s+/).includes(name);
},
},
appendChild(child) {
element.children.push(child);
return child;
},
setAttribute(name, value) {
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', {
get() {
return '';
},
set() {
element.children.length = 0;
},
});
return element;
}
function fixture() {
const container = makeElement();
container.ownerDocument = { createElement: (tag) => makeElement(tag) };
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',
},
];
let state = { activeId: 'osm', activeStack: sources[0], status: 'ready' };
const listeners = new Set();
const requests = [];
const calls = [];
const controller = {
getStacks: () => sources,
getActiveId: () => state.activeId,
getState: (status) => ({ ...state, status: status || state.status }),
setStack: (id) => {
calls.push(['select', id]);
return new Promise((resolve, reject) =>
requests.push({ id, resolve, reject }),
);
},
};
const controls = createMapSourceControls({
container,
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);
function change(id, lastError = null) {
state = {
activeId: id,
activeStack: sources.find((source) => source.id === id),
status: 'ready',
lastError,
};
return controller.getState();
}
const 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', () => {
const f = fixture();
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.textContent, 'OSM');
const unavailable = f.chip('bing-aerial');
assert.equal(
unavailable.disabled,
false,
'unavailable chips remain focusable',
);
assert.equal(unavailable.getAttribute('aria-disabled'), 'true');
unavailable.click();
assert.equal(f.requests.length, 0);
f.controls.destroy();
});
test('selection claims authority before requesting and never lights a pending source optimistically', async () => {
const f = fixture();
const pending = f.controls.select('esri-imagery');
assert.deepEqual(f.calls, [['claim'], ['select', 'esri-imagery']]);
assert.equal(f.statusElement.textContent, '...');
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
f.requests[0].resolve(f.change('esri-imagery'));
await pending;
assert.equal(f.chip('esri-imagery').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.textContent, 'Esri');
f.controls.destroy();
});
test('provider-driven fallback updates the active chip and durable state notification', () => {
const f = fixture();
f.change('esri-imagery');
f.emit();
f.change('osm', 'Source unavailable; using OSM');
f.emit();
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.textContent, 'OSM');
assert.equal(f.statusElement.classList.contains('warn'), true);
assert.deepEqual(f.calls, [['state'], ['state']]);
f.controls.destroy();
});
test('a late superseded response cannot overwrite the latest displayed selection', async () => {
const f = fixture();
const first = f.controls.select('esri-imagery');
const second = f.controls.select('osm');
f.requests[1].resolve(f.change('osm'));
await second;
const count = f.calls.length;
f.requests[0].resolve({
activeId: 'esri-imagery',
activeStack: { label: 'old' },
lastError: 'obsolete failure',
});
await first;
assert.equal(f.statusElement.textContent, 'OSM');
assert.equal(f.calls.length, count);
f.controls.destroy();
});
test('failed selection keeps the real source lit and reports its error', async () => {
const f = fixture();
const pending = f.controls.select('esri-imagery');
f.requests[0].resolve(f.change('osm', 'Unavailable'));
await pending;
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
assert.equal(f.statusElement.classList.contains('warn'), true);
assert.ok(
f.calls.some((call) => call[0] === 'error' && call[1] === 'Unavailable'),
);
f.controls.destroy();
});
test('rejected selection leaves switching presentation and propagates a useful error', async () => {
const f = fixture();
const pending = f.controls.select('esri-imagery');
f.requests[0].reject(new Error('Request failed'));
await assert.rejects(pending, /Request failed/);
assert.equal(f.statusElement.textContent, 'OSM');
assert.ok(
f.calls.some((call) => call[0] === 'error' && call[1] === 'Request failed'),
);
f.controls.destroy();
});
test('restoration may select without claiming or publishing a user gesture', async () => {
const f = fixture();
const pending = f.controls.select('esri-imagery', { syncShare: false });
f.requests[0].resolve(f.change('esri-imagery'));
await pending;
assert.deepEqual(f.calls, [['select', 'esri-imagery']]);
f.controls.destroy();
});
test('refresh removes detached chip listeners and destruction removes all subscriptions', () => {
const f = fixture();
const old = f.chip('esri-imagery');
f.controls.refresh();
old.click();
assert.equal(f.requests.length, 0);
const current = f.chip('esri-imagery');
f.controls.destroy();
f.controls.destroy();
current.click();
assert.equal(f.requests.length, 0);
assert.equal(f.listeners.size, 0);
});
test('completion after destruction cannot paint or notify', async () => {
const f = fixture();
const pending = f.controls.select('esri-imagery');
f.controls.destroy();
const count = f.calls.length;
const label = f.statusElement.textContent;
f.requests[0].resolve(f.change('esri-imagery'));
await pending;
assert.equal(f.calls.length, count);
assert.equal(f.statusElement.textContent, label);
assert.equal(await f.controls.select('osm'), null);
});