diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 232869182..9804b619e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,19 +69,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -120,19 +86,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -140,50 +100,30 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -192,19 +132,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -413,14 +314,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index bc761a913..20b0bf861 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -2,53 +2,213 @@ /** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */ 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',e=>errors.push(e.message));let failures=0; -const check=(name,ok)=>{console.log(`[${ok?'PASS':'FAIL'}] ${name}`);if(!ok)failures++}; +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', (e) => errors.push(e.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}); - await page.goto(`${process.env.QA_BASE_URL||'http://localhost:4173'}/?welcome=0`,{waitUntil:'domcontentloaded'}); - await page.waitForFunction(()=>window.__godsEyeView?.styleManager?._locationControls&&document.getElementById('loading-screen')?.classList.contains('hidden'),{timeout:60000}); + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._locationControls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); await page.click('#location-bar-toggle'); await page.click('[data-pin-target="location-bar"]'); - const cityId=await page.$eval('.location-pill',node=>node.dataset.locationId); + const cityId = await page.$eval( + '.location-pill', + (node) => node.dataset.locationId, + ); await page.click('.location-pill'); - await page.waitForFunction(()=>document.getElementById('poi-row').classList.contains('expanded')); - check('native city selection highlights and expands its POIs',await page.evaluate(id=>window.__godsEyeView.styleManager._activeLocationId===id&&document.querySelector('.location-pill.active')?.dataset.locationId===id,cityId)); + await page.waitForFunction(() => + document.getElementById('poi-row').classList.contains('expanded'), + ); + check( + 'native city selection highlights and expands its POIs', + await page.evaluate( + (id) => + window.__godsEyeView.styleManager._activeLocationId === id && + document.querySelector('.location-pill.active')?.dataset.locationId === + id, + cityId, + ), + ); await page.keyboard.press('w'); - check('POI keyboard action selects the second landmark',await page.evaluate(()=>window.__godsEyeView.styleManager._activePoiIndex===1&&document.querySelector('.poi-pill.active')?.dataset.poiIndex==='1')); - check('orbit controls and indicator agree',await page.evaluate(()=>{const ui=window.__godsEyeView.styleManager;ui.setOrbit(true);const active=ui.orbitController.active&&document.getElementById('orbit-indicator').classList.contains('active');ui.setOrbit(false);return active&&!ui.orbitController.active&&!document.getElementById('orbit-indicator').classList.contains('active')})); + check( + 'POI keyboard action selects the second landmark', + await page.evaluate( + () => + window.__godsEyeView.styleManager._activePoiIndex === 1 && + document.querySelector('.poi-pill.active')?.dataset.poiIndex === '1', + ), + ); + check( + 'orbit controls and indicator agree', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + ui.setOrbit(true); + const active = + ui.orbitController.active && + document.getElementById('orbit-indicator').classList.contains('active'); + ui.setOrbit(false); + return ( + active && + !ui.orbitController.active && + !document.getElementById('orbit-indicator').classList.contains('active') + ); + }), + ); await page.click('#search-toggle'); - await page.type('#location-search','qwerty'); - check('typing in search leaves POI selection unchanged',await page.evaluate(()=>window.__godsEyeView.styleManager._activePoiIndex===1)); - await page.evaluate(()=>{ - const lookup=window.__godsEyeView.styleManager._locationLookup; - window.__qaSearchRequests=[]; - lookup.search=(query,options)=>new Promise(resolve=>window.__qaSearchRequests.push({query,options,resolve})); - document.getElementById('location-search').value='First'; + await page.type('#location-search', 'qwerty'); + check( + 'typing in search leaves POI selection unchanged', + await page.evaluate( + () => window.__godsEyeView.styleManager._activePoiIndex === 1, + ), + ); + await page.evaluate(() => { + const lookup = window.__godsEyeView.styleManager._locationLookup; + window.__qaSearchRequests = []; + lookup.search = (query, options) => + new Promise((resolve) => + window.__qaSearchRequests.push({ query, options, resolve }), + ); + document.getElementById('location-search').value = 'First'; }); await page.keyboard.press('Enter'); - await page.waitForFunction(()=>window.__qaSearchRequests.length===1); - await page.evaluate(()=>{document.getElementById('location-search').value='Second';document.getElementById('location-search').focus()}); + await page.waitForFunction(() => window.__qaSearchRequests.length === 1); + await page.evaluate(() => { + document.getElementById('location-search').value = 'Second'; + document.getElementById('location-search').focus(); + }); await page.keyboard.press('Enter'); - await page.waitForFunction(()=>window.__qaSearchRequests.length===2); - check('second native submit aborts the first request and keeps current busy state',await page.evaluate(()=>window.__qaSearchRequests[0].options.signal.aborted&&document.getElementById('location-search').classList.contains('searching'))); - await page.evaluate(()=>window.__qaSearchRequests[1].resolve({label:'Second landmark, Test city'})); - await page.waitForFunction(()=>window.__godsEyeView.styleManager._searchedLocationLabel==='Second landmark, Test city'); - await page.evaluate(()=>window.__qaSearchRequests[0].resolve({label:'First landmark, Test city'})); - check('late result cannot overwrite the current location readout or style',await page.evaluate(()=>{const ui=window.__godsEyeView.styleManager;return ui._searchedLocationLabel==='Second landmark, Test city'&&ui._locationMiniPoi.textContent.includes('Second landmark')&&ui.activeStyle==='normal'&&!ui._locationSearch.classList.contains('searching')})); - check('cancelled POI expansion stays closed after a frame',await page.evaluate(async id=>{const ui=window.__godsEyeView.styleManager;ui._expandPOIRow(id);ui._collapsePOIRow();await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));return !ui._poiRow.classList.contains('expanded')&&!ui._locationBarDivider.classList.contains('visible')},cityId)); - await page.evaluate(id=>window.__godsEyeView.styleManager._onCityPillClick(id),cityId); - await page.waitForFunction(()=>document.getElementById('poi-row').classList.contains('expanded')); + await page.waitForFunction(() => window.__qaSearchRequests.length === 2); + check( + 'second native submit aborts the first request and keeps current busy state', + await page.evaluate( + () => + window.__qaSearchRequests[0].options.signal.aborted && + document + .getElementById('location-search') + .classList.contains('searching'), + ), + ); + await page.evaluate(() => + window.__qaSearchRequests[1].resolve({ + label: 'Second landmark, Test city', + }), + ); + await page.waitForFunction( + () => + window.__godsEyeView.styleManager._searchedLocationLabel === + 'Second landmark, Test city', + ); + await page.evaluate(() => + window.__qaSearchRequests[0].resolve({ + label: 'First landmark, Test city', + }), + ); + check( + 'late result cannot overwrite the current location readout or style', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + return ( + ui._searchedLocationLabel === 'Second landmark, Test city' && + ui._locationMiniPoi.textContent.includes('Second landmark') && + ui.activeStyle === 'normal' && + !ui._locationSearch.classList.contains('searching') + ); + }), + ); + check( + 'cancelled POI expansion stays closed after a frame', + await page.evaluate(async (id) => { + const ui = window.__godsEyeView.styleManager; + ui._expandPOIRow(id); + ui._collapsePOIRow(); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return ( + !ui._poiRow.classList.contains('expanded') && + !ui._locationBarDivider.classList.contains('visible') + ); + }, cityId), + ); + await page.evaluate( + (id) => window.__godsEyeView.styleManager._onCityPillClick(id), + cityId, + ); + await page.waitForFunction(() => + document.getElementById('poi-row').classList.contains('expanded'), + ); // Finish the camera's existing city flight before inspecting both layouts. - await page.waitForFunction(()=>!window.__godsEyeView.viewer.camera._currentFlight,{timeout:10000}); - fs.mkdirSync('qa-shots/location-controls',{recursive:true}); - await page.screenshot({path:'qa-shots/location-controls/desktop.png'}); - await page.setViewport({width:620,height:900}); - await page.waitForFunction(()=>document.getElementById('left-panel-stack')?.dataset.layoutMode==='mobile'); - await page.screenshot({path:'qa-shots/location-controls/narrow.png'}); - check('native reset returns through the existing camera handoff',await page.evaluate(async()=>{const ui=window.__godsEyeView.styleManager;ui._resetGlobeBtn.click();const pending=ui._globeResetPromise;if(!pending)return false;await pending;return !ui.orbitController.active&&!window.__godsEyeView.viewer.trackedEntity})); - check('destroyed controls cannot navigate or submit another lookup',await page.evaluate(()=>{const ui=window.__godsEyeView.styleManager;const count=window.__qaSearchRequests.length;ui._locationControls.destroy();ui._locationLookup.destroy();const before=ui._navigationGeneration;ui._locationPills.querySelector('button').click();ui._resetGlobeBtn.click();ui._locationSearch.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));return before===ui._navigationGeneration&&count===window.__qaSearchRequests.length})); - check('no uncaught browser errors',errors.length===0); -}finally{await browser.close()} -console.log(`RESULT: ${failures} failures`);process.exitCode=failures?1:0; + await page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 10000 }, + ); + fs.mkdirSync('qa-shots/location-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction( + () => + document.getElementById('left-panel-stack')?.dataset.layoutMode === + 'mobile', + ); + await page.screenshot({ path: 'qa-shots/location-controls/narrow.png' }); + check( + 'native reset returns through the existing camera handoff', + await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + ui._resetGlobeBtn.click(); + const pending = ui._globeResetPromise; + if (!pending) return false; + await pending; + return ( + !ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity + ); + }), + ); + check( + 'destroyed controls cannot navigate or submit another lookup', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + const count = window.__qaSearchRequests.length; + ui._locationControls.destroy(); + ui._locationLookup.destroy(); + const before = ui._navigationGeneration; + ui._locationPills.querySelector('button').click(); + ui._resetGlobeBtn.click(); + ui._locationSearch.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + return ( + before === ui._navigationGeneration && + count === window.__qaSearchRequests.length + ); + }), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/locationStatus.js b/src/locationStatus.js index 2dcc80fcc..1d90582c9 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -55,7 +55,10 @@ export function locationMiniStatus({ // The remaining address is the place's context ("Japan", "Minato City, // Tokyo, Japan"); the readout is ellipsised in CSS, so a long tail is // safe. A one-segment geocode ("Japan") has no context to show. - poi: segments.length > 1 ? segments.slice(1).join(', ') : 'Searched location', + poi: + segments.length > 1 + ? segments.slice(1).join(', ') + : 'Searched location', }; } diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 21f905816..51fadf752 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -3,8 +3,30 @@ 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 }); + 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; @@ -20,24 +42,34 @@ export class LocationControls { this.bind(pill, 'click', () => onCity(id)); elements.pills.appendChild(pill); } - this.bind(doc, 'keydown', event => { + this.bind(doc, 'keydown', (event) => { const cityId = getExpandedCity(); - if (!cityId || event.target?.matches?.('select, input, textarea') || event.target === elements.search) return; + 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); + 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(); + if (elements.search.classList.contains('expanded')) + elements.search.focus(); }); - this.bind(elements.search, 'keydown', event => { + 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); + 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); }; + const listener = (...args) => { + if (!this.destroyed) return handler(...args); + }; element.addEventListener(event, listener); removers.push(() => element.removeEventListener(event, listener)); } @@ -66,7 +98,12 @@ export class LocationControls { label.className = 'poi-pill-name'; label.textContent = poi.name; pill.append(key, label); - this.bind(pill, 'click', () => this.onPoi(cityId, index), this.poiRemovers); + this.bind( + pill, + 'click', + () => this.onPoi(cityId, index), + this.poiRemovers, + ); this.elements.poiRow.appendChild(pill); }); this.frame = this.requestFrame(() => { @@ -84,14 +121,17 @@ export class LocationControls { } 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); + 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); + 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; + 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; @@ -113,7 +153,11 @@ export class LocationControls { if (this.destroyed) return; this.destroyed = true; this.cancelExpansion(); - for (const remove of [...this.poiRemovers.splice(0), ...this.removers.splice(0)]) remove(); + for (const remove of [ + ...this.poiRemovers.splice(0), + ...this.removers.splice(0), + ]) + remove(); this.orbitIndicator?.remove(); } } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 86c2465a3..928e48554 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -5,40 +5,145 @@ 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; }, + 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}; + 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); + 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); + 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); + 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']]); + 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']]); }); diff --git a/src/ui/locationSearch.js b/src/ui/locationSearch.js index 5f883ec41..b7db9c37c 100644 --- a/src/ui/locationSearch.js +++ b/src/ui/locationSearch.js @@ -1,7 +1,29 @@ /** 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 }); + 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; @@ -19,7 +41,10 @@ export class LocationSearch { const controller = new AbortController(); this.controller = controller; const generation = ++this.generation; - const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority); + const current = () => + !this.destroyed && + generation === this.generation && + this.isCurrent(authority); this.onStart(authority); this.input.classList.add('searching'); try { diff --git a/src/ui/locationSearch.test.mjs b/src/ui/locationSearch.test.mjs index 795d32768..37b107039 100644 --- a/src/ui/locationSearch.test.mjs +++ b/src/ui/locationSearch.test.mjs @@ -6,52 +6,105 @@ 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 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]), + 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]), + onError: (error) => calls.push(['error', error.message]), + onSettled: (ticket) => calls.push(['settled', ticket]), }); - return {search,requests,calls,takeCamera:()=>++authority}; + 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); + 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); + 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]]); + 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); + 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')); + 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')); });