refactor: isolate Location controls and cancellable search

This commit is contained in:
Sameh Khamis committed 2026-09-12 22:55:16 -07:00
1 parent 20c1611354
commit ea77b49482
16 files changed
+604 -245

No files matched your search

+24 -31
View File
@@ -243,22 +243,22 @@ test('validated voice camera destinations share the UI navigation authority faca
});
test('deferred search releases only after its final authority check', () => {
const handler = body(
ui,
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
'search handler',
);
ordered(handler, [
"this._beginDeferredNavigation('location')",
'this._activeLocationSearchGeneration = generation;',
'searchAndFlyTo(this.viewer, query',
'beforeFly: () => this._reassertNavigationHandoff(generation)',
'generation !== this._navigationGeneration',
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'const authority = this.begin();',
'this.onStart(authority);',
'await this.search(query',
'beforeFly: () => current() && this.beforeFly(authority)',
'if (!current() || controller.signal.aborted) return;',
'destination?.cancelled',
'finally',
'this._settleLocationSearchUi(generation)',
], 'deferred search');
assert.doesNotMatch(handler.slice(0, handler.indexOf('searchAndFlyTo')), /_releaseFollowCamera/);
'this.onSettled(authority)',
], 'deferred search component');
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) => !this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /onSettled: \(generation\) => this\._settleLocationSearchUi\(generation\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
});
test('a direct globe gesture retires delayed camera and selection restore only', () => {
@@ -337,25 +337,18 @@ test('teardown synchronously closes immediate camera entry points', () => {
});
test('teardown refuses deferred location work before geocoding begins', () => {
const deferred = body(
ui,
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
'deferred navigation',
);
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
assert.match(deferred, /disposed: this\._disposed/);
const handler = body(
ui,
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
'search handler',
);
ordered(handler, [
"const generation = this._beginDeferredNavigation('location');",
'if (generation === false)',
'this._locationSearch.blur();',
'searchAndFlyTo(this.viewer, query',
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input.blur();',
'this.onStart(authority);',
'await this.search(query',
], 'disposed search refusal');
assert.match(handler, /if \(generation === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\._activeLocationSearchGeneration/);
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
});
test('refused canned destinations commit no location or POI state', () => {
+4 -2
View File
@@ -44,11 +44,13 @@ test('a free-text search records its destination for the LOCATION mini-status',
});
test('the mini-status reads its copy from the shared formatter', () => {
assert.match(ui, /import \{ locationMiniStatus \} from '\.\/locationStatus\.js';/);
const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8');
assert.match(controls, /import \{ locationMiniStatus \} from '\.\.\/locationStatus\.js';/);
assert.match(controls, /const lines = locationMiniStatus\(state\)/);
const start = ui.indexOf(' _updateLocationMiniStatus() {');
assert.ok(start > 0, '_updateLocationMiniStatus is missing');
const body = ui.slice(start, ui.indexOf('\n }', start));
assert.match(body, /locationMiniStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/);
assert.match(body, /_locationControls\?\.renderStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/);
// No second copy of the placeholder strings to drift out of sync.
assert.doesNotMatch(body, /Location: --/);
});
+2
View File
@@ -693,6 +693,8 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
});
+49 -159
View File
@@ -1,3 +1,4 @@
import { LocationControls, LocationSearch } from './ui/location.js';
import { bindClearLayersControl } from './ui/layers.js';
import { createMapSourceControls } from './ui/mapSource.js';
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
@@ -13,7 +14,6 @@ import {
decodeBloomIntensity,
} from './bloom.js';
import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js';
import { locationMiniStatus } from './locationStatus.js';
import { interruptCameraMotion } from './cameraVerbs.js';
import {
aircraftTrackingTarget,
@@ -2085,7 +2085,6 @@ export class StyleManager {
this._shareTrackingAcquiringKey = null;
this._shareTrackingNoticeGeneration = 0;
this._globeResetPromise = null;
this._globeResetHandler = null;
this._clearSelectedLayersPromise = null;
this._clearSelectedLayersManagerPromise = null;
this._dataManager = null;
@@ -2565,7 +2564,6 @@ export class StyleManager {
this._initLocationBar();
this._initShareButton();
this._initClearSelectedLayersButton();
this._initResetGlobeButton();
this._initHUDToggle();
this._initModels3dToggle();
this._applyGlobalPostDefaults();
@@ -8528,97 +8526,45 @@ export class StyleManager {
* @returns {void}
*/
_initLocationBar() {
const QWERTY_KEYS = ['Q', 'W', 'E', 'R', 'T'];
// Render city pills (no submenu wrappers — POI row is separate)
for (const [cityId, city] of Object.entries(CITY_POIS)) {
const pill = document.createElement('button');
pill.className = 'location-pill';
pill.dataset.locationId = cityId;
pill.textContent = city.name;
pill.addEventListener('click', () => this._onCityPillClick(cityId));
this._locationPills.appendChild(pill);
}
// QWERTY keyboard navigation for POIs
this._poiKeydownHandler = (e) => {
if (!this._expandedCityId) return;
// Bail while a form control is focused so POI hotkeys don't fire from a
// <select> dropdown's type-ahead or while typing in a field (M9).
const isFormControl = e.target?.matches?.('select, input, textarea')
|| e.target === this._locationSearch;
if (isFormControl) return;
const keyIndex = QWERTY_KEYS.indexOf(e.key.toUpperCase());
if (keyIndex === -1) return;
const city = CITY_POIS[this._expandedCityId];
if (city && keyIndex < city.pois.length) {
this._onPoiClick(this._expandedCityId, keyIndex);
}
};
document.addEventListener('keydown', this._poiKeydownHandler);
// Search toggle (expand/collapse)
this._searchToggle.addEventListener('click', () => {
this._locationSearch.classList.toggle('expanded');
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.focus();
}
this._locationControls?.destroy();
this._locationLookup?.destroy();
this._locationLookup = new LocationSearch({
input: this._locationSearch,
begin: () => this._beginDeferredNavigation('location'),
isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration,
beforeFly: (generation) => this._reassertNavigationHandoff(generation),
search: (query, options) => searchAndFlyTo(this.viewer, query, {
placeSearch: this.placeSearch,
...options,
}),
onStart: (generation) => { this._activeLocationSearchGeneration = generation; },
onResult: (destination, query) => {
this._searchedLocationLabel = destination.label || query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
},
onMissing: () => this._showToast('Location not found'),
onError: (error) => {
console.error('[Search] Geocoding failed:', error);
this._showToast('Search failed');
},
onSettled: (generation) => this._settleLocationSearchUi(generation),
});
// Search submit on Enter
this._locationSearch.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const query = this._locationSearch.value.trim();
if (!query) return;
const generation = this._beginDeferredNavigation('location');
if (generation === false) {
this._locationSearch.classList.remove('searching');
this._locationSearch.blur();
return;
}
this._activeLocationSearchGeneration = generation;
this._locationSearchController?.abort();
const searchController = new AbortController();
this._locationSearchController = searchController;
this._locationSearch.classList.add('searching');
try {
const destination = await searchAndFlyTo(this.viewer, query, {
placeSearch: this.placeSearch,
signal: searchController.signal,
beforeFly: () => this._reassertNavigationHandoff(generation),
});
if (this._disposed || generation !== this._navigationGeneration) return;
if (destination?.cancelled) {
// Authority changed while the lookup was resolving; remain inert.
} else if (destination) {
// The ACTIVE STYLE indicator reports the STYLE and nothing else.
// Writing the searched city here made the top-right corner read
// "ACTIVE STYLE / TOKYO"; where the camera is belongs to the
// LOCATION panel's own readout, which is updated below.
//
// Set before _setActiveLocation(null) so its own mini-status
// refresh already sees the destination — the readout never blinks
// through "Location: --" on the way to the searched place.
this._searchedLocationLabel = destination.label || query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else {
this._showToast('Location not found');
}
} catch (err) {
if (searchController.signal.aborted) return;
console.error('[Search] Geocoding failed:', err);
if (this._disposed || generation !== this._navigationGeneration) return;
this._showToast('Search failed');
} finally {
if (this._locationSearchController === searchController) this._locationSearchController = null;
this._settleLocationSearchUi(generation);
}
}
this._locationControls = new LocationControls({
elements: {
pills: this._locationPills, poiRow: this._poiRow, divider: this._locationBarDivider,
search: this._locationSearch, searchToggle: this._searchToggle,
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
statusCity: this._locationMiniCity, statusPoi: this._locationMiniPoi,
},
cities: CITY_POIS,
getExpandedCity: () => this._expandedCityId,
onCity: (id) => this._onCityPillClick(id),
onPoi: (id, index) => this._onPoiClick(id, index),
onSearch: (query) => this._locationLookup.run(query),
onReset: () => this.resetToGlobeView(),
});
}
@@ -8745,28 +8691,9 @@ export class StyleManager {
* @returns {void}
*/
_expandPOIRow(cityId) {
const QWERTY_KEYS = ['Q', 'W', 'E', 'R', 'T'];
const city = CITY_POIS[cityId];
if (!city) return;
if (!CITY_POIS[cityId]) return;
this._expandedCityId = cityId;
// Build POI pill buttons
this._poiRow.innerHTML = '';
city.pois.forEach((poi, idx) => {
const pill = document.createElement('button');
pill.className = 'poi-pill';
pill.dataset.poiIndex = idx;
pill.innerHTML = `<span class="poi-pill-key">${QWERTY_KEYS[idx] || idx + 1}</span><span class="poi-pill-name">${poi.name}</span>`;
pill.addEventListener('click', () => this._onPoiClick(cityId, idx));
this._poiRow.appendChild(pill);
});
// Animate expansion
requestAnimationFrame(() => {
this._poiRow.classList.add('expanded');
this._locationBarDivider.classList.add('visible');
});
this._locationControls.showPois(cityId);
}
/**
@@ -8776,8 +8703,7 @@ export class StyleManager {
_collapsePOIRow() {
this._expandedCityId = null;
this._activePoiIndex = null;
this._poiRow.classList.remove('expanded');
this._locationBarDivider.classList.remove('visible');
this._locationControls.hidePois();
}
/**
@@ -8785,9 +8711,7 @@ export class StyleManager {
* @returns {void}
*/
_updatePoiHighlight() {
this._poiRow.querySelectorAll('.poi-pill').forEach(pill => {
pill.classList.toggle('active', parseInt(pill.dataset.poiIndex) === this._activePoiIndex);
});
this._locationControls.highlightPoi(this._activePoiIndex);
}
/**
@@ -8813,9 +8737,7 @@ export class StyleManager {
// free-text destination has been superseded. Clearing only on a real id
// leaves the search path's own _setActiveLocation(null) untouched.
if (locationId) this._searchedLocationLabel = null;
this._locationPills.querySelectorAll('.location-pill').forEach(pill => {
pill.classList.toggle('active', pill.dataset.locationId === locationId);
});
this._locationControls?.highlightCity(locationId);
this._updateLocationMiniStatus();
}
@@ -8825,14 +8747,11 @@ export class StyleManager {
* @returns {void}
*/
_updateLocationMiniStatus() {
if (!this._locationMiniCity || !this._locationMiniPoi) return;
const lines = locationMiniStatus({
this._locationControls?.renderStatus({
city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null,
currentPoi: this._currentPoi,
searchedLabel: this._searchedLocationLabel,
});
this._locationMiniCity.textContent = lines.city;
this._locationMiniPoi.textContent = lines.poi;
}
/**
@@ -8852,11 +8771,7 @@ export class StyleManager {
* @returns {void}
*/
_initOrbit() {
// Create orbit indicator element
this._orbitIndicator = document.createElement('div');
this._orbitIndicator.id = 'orbit-indicator';
this._orbitIndicator.innerHTML = '<span class="orbit-icon">&#x21BB;</span> ORBIT';
document.body.appendChild(this._orbitIndicator);
this._orbitIndicator = this._locationControls.createOrbitIndicator();
}
/**
@@ -8889,14 +8804,6 @@ export class StyleManager {
}
}
/** Wire the persistent reset control to the same route used by voice. */
_initResetGlobeButton() {
this._globeResetHandler = () => { this.resetToGlobeView(); };
for (const button of [this._resetGlobeBtn, this._cockpitResetGlobeBtn]) {
button?.addEventListener('click', this._globeResetHandler);
}
}
/** Wire the top-center action that clears only manager-owned data layers. */
_initClearSelectedLayersButton() {
if (!this._clearSelectedLayersBtn) return;
@@ -9374,13 +9281,14 @@ export class StyleManager {
this._displayControls?.destroy();
this._mapSourceControls?.destroy();
this._clearLayersControl?.destroy();
this._locationControls?.destroy();
this._visualEffects.stop();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
this._panelDisclosureControls = [];
this._hoverPanelControls?.forEach((control) => control.destroy());
this._hoverPanelControls?.clear();
this._locationSearchController?.abort();
this._locationLookup?.destroy();
this._cancelMapSourceFocus?.();
// Revoke persistence/hash authority before teardown can emit manager changes.
this._layerStateCoordinator?.destroy();
@@ -9458,31 +9366,13 @@ export class StyleManager {
this._dataManagerVisibilityRequestUnsubscribe = null;
this._dataManagerUnsubscribe?.();
this._dataManagerUnsubscribe = null;
if (this._globeResetHandler) {
this._resetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
this._globeResetHandler = null;
}
this._cctvUnsubscribe?.();
this._cctvUnsubscribe = null;
this._commandDockTrayObserver?.disconnect?.();
this._commandDockTrayObserver = null;
this._draggableResizeObserver?.disconnect();
this._draggableResizeObserver = null;
if (this._windowResizeHandler) {
window.removeEventListener('resize', this._windowResizeHandler);
this._windowResizeHandler = null;
}
if (this._loadingVisibilityHandler) {
document.removeEventListener('visibilitychange', this._loadingVisibilityHandler);
this._loadingVisibilityHandler = null;
}
this._stopLoadingFeedbackTicker();
if (this._poiKeydownHandler) {
document.removeEventListener('keydown', this._poiKeydownHandler);
this._poiKeydownHandler = null;
}
// Stop the independently owned traffic and loading feedback tickers.
if (this._trafficChipTicker) {
+3
View File
@@ -0,0 +1,3 @@
export { LocationControls } from './locationControls.js';
export { LocationSearch } from './locationSearch.js';
export { locationMiniStatus } from '../locationStatus.js';
+119
View File
@@ -0,0 +1,119 @@
import { locationMiniStatus } from '../locationStatus.js';
const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
/** Location DOM, keyboard handling and pending row animation over supplied actions. */
export class LocationControls {
constructor({ elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc = document, requestFrame = requestAnimationFrame, cancelFrame = cancelAnimationFrame }) {
Object.assign(this, { elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc, requestFrame, cancelFrame });
this.removers = [];
this.poiRemovers = [];
this.frame = null;
this.destroyed = false;
this.rowGeneration = 0;
elements.pills.replaceChildren();
for (const [id, city] of Object.entries(cities)) {
const pill = doc.createElement('button');
pill.type = 'button';
pill.className = 'location-pill';
pill.dataset.locationId = id;
pill.textContent = city.name;
this.bind(pill, 'click', () => onCity(id));
elements.pills.appendChild(pill);
}
this.bind(doc, 'keydown', event => {
const cityId = getExpandedCity();
if (!cityId || event.target?.matches?.('select, input, textarea') || event.target === elements.search) return;
const index = POI_KEYS.indexOf(event.key.toUpperCase());
if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) onPoi(cityId, index);
});
this.bind(elements.searchToggle, 'click', () => {
elements.search.classList.toggle('expanded');
if (elements.search.classList.contains('expanded')) elements.search.focus();
});
this.bind(elements.search, 'keydown', event => {
if (event.key === 'Enter') void onSearch(elements.search.value);
});
for (const button of elements.resetButtons) this.bind(button, 'click', onReset);
}
bind(element, event, handler, removers = this.removers) {
if (!element) return;
const listener = (...args) => { if (!this.destroyed) return handler(...args); };
element.addEventListener(event, listener);
removers.push(() => element.removeEventListener(event, listener));
}
cancelExpansion() {
this.rowGeneration++;
if (this.frame !== null) this.cancelFrame(this.frame);
this.frame = null;
}
showPois(cityId) {
if (this.destroyed) return;
const city = this.cities[cityId];
if (!city) return;
this.cancelExpansion();
const generation = this.rowGeneration;
for (const remove of this.poiRemovers.splice(0)) remove();
this.elements.poiRow.replaceChildren();
city.pois.forEach((poi, index) => {
const pill = this.doc.createElement('button');
pill.type = 'button';
pill.className = 'poi-pill';
pill.dataset.poiIndex = index;
const key = this.doc.createElement('span');
key.className = 'poi-pill-key';
key.textContent = POI_KEYS[index] || index + 1;
const label = this.doc.createElement('span');
label.className = 'poi-pill-name';
label.textContent = poi.name;
pill.append(key, label);
this.bind(pill, 'click', () => this.onPoi(cityId, index), this.poiRemovers);
this.elements.poiRow.appendChild(pill);
});
this.frame = this.requestFrame(() => {
if (this.destroyed || generation !== this.rowGeneration) return;
this.frame = null;
this.elements.poiRow.classList.add('expanded');
this.elements.divider.classList.add('visible');
});
}
hidePois() {
if (this.destroyed) return;
this.cancelExpansion();
this.elements.poiRow.classList.remove('expanded');
this.elements.divider.classList.remove('visible');
}
highlightPoi(index) {
if (this.destroyed) return;
for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index);
}
highlightCity(id) {
if (this.destroyed) return;
for (const pill of this.elements.pills.querySelectorAll('.location-pill')) pill.classList.toggle('active', pill.dataset.locationId === id);
}
renderStatus(state) {
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) return;
const lines = locationMiniStatus(state);
this.elements.statusCity.textContent = lines.city;
this.elements.statusPoi.textContent = lines.poi;
}
createOrbitIndicator() {
if (this.destroyed) return null;
if (!this.orbitIndicator) {
this.orbitIndicator = this.doc.createElement('div');
this.orbitIndicator.id = 'orbit-indicator';
const icon = this.doc.createElement('span');
icon.className = 'orbit-icon';
icon.textContent = '↻';
this.orbitIndicator.append(icon, ' ORBIT');
this.doc.body.appendChild(this.orbitIndicator);
}
return this.orbitIndicator;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.cancelExpansion();
for (const remove of [...this.poiRemovers.splice(0), ...this.removers.splice(0)]) remove();
this.orbitIndicator?.remove();
}
}
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { LocationControls } from './locationControls.js';
function node() {
const classes = new Set();
return {
children: [], dataset: {}, listeners: new Map(), className: '', textContent: '',
classList: { add: (...names) => names.forEach(name => classes.add(name)), remove: (...names) => names.forEach(name => classes.delete(name)), contains: name => classes.has(name), toggle(name, enabled = !classes.has(name)) { if(enabled) classes.add(name); else classes.delete(name); } },
addEventListener(name, callback) { if(!this.listeners.has(name)) this.listeners.set(name,new Set()); this.listeners.get(name).add(callback); },
removeEventListener(name, callback) { this.listeners.get(name)?.delete(callback); },
fire(name,event={}) { for(const callback of this.listeners.get(name)||[]) callback(event); },
appendChild(child) { this.children.push(child); child.parent=this; },
append(...children) { for(const child of children) if(typeof child!=='string') this.appendChild(child); },
replaceChildren() { this.children=[]; },
remove() { if(this.parent) this.parent.children=this.parent.children.filter(child=>child!==this); },
querySelectorAll(selector) { return this.children.flatMap(child=>[...(child.className===selector.slice(1)?[child]:[]),...child.querySelectorAll(selector)]); },
focus() { this.focused=true; },
};
}
function fixture() {
const elements={pills:node(),poiRow:node(),divider:node(),search:node(),searchToggle:node(),resetButtons:[node(),node()],statusCity:node(),statusPoi:node()};
const doc=node();doc.createElement=node;doc.body=node();
const cities={a:{name:'City A',pois:[{name:'First'},{name:'Second'}]},b:{name:'City B',pois:[{name:'Elsewhere'}]}};
const calls=[];const frames=new Map();const cancelled=[];let next=0;
const controls=new LocationControls({elements,cities,getExpandedCity:()=> 'a',onCity:id=>calls.push(['city',id]),onPoi:(id,index)=>calls.push(['poi',id,index]),onSearch:query=>calls.push(['search',query]),onReset:()=>calls.push(['reset']),doc,requestFrame:fn=>{const id=next++;frames.set(id,fn);return id},cancelFrame:id=>cancelled.push(id)});
return {elements,doc,controls,calls,frames,cancelled};
}
test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => {
const f=fixture();f.controls.showPois('a');const callback=f.frames.get(0);f.controls.hidePois();callback();
assert.deepEqual(f.cancelled,[0]);assert.equal(f.elements.poiRow.classList.contains('expanded'),false);assert.equal(f.elements.divider.classList.contains('visible'),false);
});
test('replacing POIs removes old click actions and presents the final row', () => {
const f=fixture();f.controls.showPois('a');const old=f.elements.poiRow.children[0];f.controls.showPois('b');old.fire('click');
assert.deepEqual(f.calls,[]);f.elements.poiRow.children[0].fire('click');assert.deepEqual(f.calls,[['poi','b',0]]);
f.frames.get(0)();assert.equal(f.elements.poiRow.classList.contains('expanded'),false);f.frames.get(1)();assert.equal(f.elements.poiRow.classList.contains('expanded'),true);
});
test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => {
const f=fixture();f.controls.showPois('a');f.controls.createOrbitIndicator();assert.equal(f.doc.body.children.length,1);const city=f.elements.pills.children[0];const poi=f.elements.poiRow.children[0];f.controls.destroy();f.controls.destroy();
city.fire('click');poi.fire('click');f.elements.search.value='Place';f.elements.search.fire('keydown',{key:'Enter'});f.doc.fire('keydown',{key:'Q'});f.elements.resetButtons[0].fire('click');f.frames.get(0)();assert.deepEqual(f.calls,[]);assert.equal(f.doc.body.children.length,0);
});
test('location and POI keys route once while form controls retain typing', () => {
const f=fixture();f.doc.fire('keydown',{key:'W',target:{matches:()=>false}});f.doc.fire('keydown',{key:'Q',target:{matches:()=>true}});f.elements.resetButtons[1].fire('click');assert.deepEqual(f.calls,[['poi','a',1],['reset']]);
});
+49
View File
@@ -0,0 +1,49 @@
/** One cancellable place lookup at a time, under the caller's camera authority. */
export class LocationSearch {
constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) {
Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled });
this.controller = null;
this.generation = 0;
this.destroyed = false;
}
async run(query) {
query = String(query || '').trim();
if (!query || this.destroyed) return;
const authority = this.begin();
if (authority === false) {
this.input.classList.remove('searching');
this.input.blur();
return;
}
this.controller?.abort();
const controller = new AbortController();
this.controller = controller;
const generation = ++this.generation;
const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority);
this.onStart(authority);
this.input.classList.add('searching');
try {
const destination = await this.search(query, {
signal: controller.signal,
beforeFly: () => current() && this.beforeFly(authority),
});
if (!current() || controller.signal.aborted) return;
if (destination?.cancelled) return;
if (destination) this.onResult(destination, query);
else this.onMissing();
} catch (error) {
if (controller.signal.aborted || !current()) return;
this.onError(error);
} finally {
if (this.controller === controller) this.controller = null;
if (!this.destroyed) this.onSettled(authority);
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.generation++;
this.controller?.abort();
this.controller = null;
}
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { LocationSearch } from './locationSearch.js';
function fixture() {
const calls = [];
const requests = [];
let authority = 0;
const input = { classList: { add: name => calls.push(['add',name]), remove: name => calls.push(['remove',name]) }, blur: () => calls.push(['blur']) };
const search = new LocationSearch({
input,
begin: () => ++authority,
isCurrent: ticket => ticket === authority,
beforeFly: ticket => { calls.push(['fly',ticket]); return true; },
search: (query, options) => new Promise((resolve,reject) => requests.push({query,options,resolve,reject})),
onStart: ticket => calls.push(['start',ticket]),
onResult: (result,query) => calls.push(['result',result,query]),
onMissing: () => calls.push(['missing']),
onError: error => calls.push(['error',error.message]),
onSettled: ticket => calls.push(['settled',ticket]),
});
return {search,requests,calls,takeCamera:()=>++authority};
}
test('a newer lookup aborts its predecessor and only its result is presented', async () => {
const f=fixture();const first=f.search.run(' First ');const second=f.search.run('Second');
assert.equal(f.requests[0].query,'First');assert.equal(f.requests[0].options.signal.aborted,true);
assert.equal(f.requests[0].options.beforeFly(),false);
assert.equal(f.requests[1].options.beforeFly(),true);
f.requests[1].resolve({label:'Second'});await second;
f.requests[0].resolve({label:'First'});await first;
assert.deepEqual(f.calls.filter(x=>x[0]==='result'),[['result',{label:'Second'},'Second']]);
assert.equal(f.search.controller,null);
});
test('another camera action revokes a pending lookup without presenting an error', async () => {
const f=fixture();const pending=f.search.run('Place');f.takeCamera();
assert.equal(f.requests[0].options.beforeFly(),false);
f.requests[0].reject(new Error('old failure'));await pending;
assert.equal(f.calls.some(x=>x[0]==='error'||x[0]==='result'),false);
});
test('missing and failed current lookups settle the exact owning generation', async () => {
const f=fixture();let pending=f.search.run('Missing');f.requests[0].resolve(null);await pending;
assert.ok(f.calls.some(x=>x[0]==='missing'));
pending=f.search.run('Failure');f.requests[1].reject(new Error('unavailable'));await pending;
assert.deepEqual(f.calls.filter(x=>x[0]==='error'),[['error','unavailable']]);
assert.deepEqual(f.calls.filter(x=>x[0]==='settled'),[['settled',1],['settled',2]]);
});
test('destruction cancels work and rejects late presentation and future requests', async () => {
const f=fixture();const pending=f.search.run('Place');f.search.destroy();f.search.destroy();
assert.equal(f.requests[0].options.signal.aborted,true);
f.requests[0].resolve({label:'Place'});await pending;await f.search.run('Later');
assert.equal(f.requests.length,1);assert.equal(f.calls.some(x=>x[0]==='result'||x[0]==='settled'),false);
});
test('empty queries and refused navigation do not issue a lookup', async () => {
const f=fixture();await f.search.run(' ');f.search.begin=()=>false;await f.search.run('Place');
assert.equal(f.requests.length,0);assert.ok(f.calls.some(x=>x[0]==='blur'));
});