diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e8a5663d8..e065676c4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1064,6 +1064,12 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/worldFocus.js", + "src/geo/index.js", + "src/geo/vector.js", + "src/geo/ellipsoid.js", + "src/geo/frames.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", "src/data/feedState.js", "src/data/layerSnapshot.js", "src/ui/aircraftDisplay.js", @@ -1574,6 +1580,7 @@ "modules": [ "src/i18n/marker.js", "src/layers/recentImagery/index.js", + "src/render/rendererOf.js", "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", @@ -1714,6 +1721,7 @@ "src/layers/launches/paths.js", "src/layers/launches/policy.js", "src/layers/launches/policyHelpers.js", + "src/render/rendererOf.js", "src/layers/launches/rendering.js", "src/layers/launches/replay.js", "src/layers/launches/selection.js", @@ -1753,6 +1761,7 @@ "src/data/militaryInstallationData.js", "src/data/installationNames.js", "src/layers/installations/controls.js", + "src/render/rendererOf.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", @@ -1820,6 +1829,7 @@ "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", + "src/render/rendererOf.js", "src/layers/awareness/history.js", "src/layers/awareness/index.js", "src/layers/awareness/ingestion.js", @@ -1986,6 +1996,7 @@ "src/geoDistance.js", "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", + "src/render/rendererOf.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", @@ -2045,6 +2056,7 @@ "src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/cameraFraming.js", + "src/render/rendererOf.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", @@ -2120,6 +2132,7 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", + "src/render/rendererOf.js", "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", "src/layers/alpr/markerState.js", @@ -2179,6 +2192,7 @@ "src/layers/radio/lifecycle.js", "src/layers/radio/model.js", "src/layers/radio/navigation.js", + "src/render/rendererOf.js", "src/layers/radio/playback.js", "src/layers/radio/policy.js", "src/layers/radio/presentation.js", @@ -2224,6 +2238,7 @@ "src/layers/submarineCables/index.js", "src/layers/submarineCables/ingestion.js", "src/layers/submarineCables/interaction.js", + "src/render/rendererOf.js", "src/layers/submarineCables/pickState.js", "src/data/entityRecords.js", "src/layers/submarineCables/lifecycle.js", @@ -3821,6 +3836,12 @@ "src/ui/panelRailGeometry.js", "src/ui/radioTunerModel.js", "src/worldFocus.js", + "src/geo/index.js", + "src/geo/vector.js", + "src/geo/ellipsoid.js", + "src/geo/frames.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 9f381db96..d5f7739b5 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import alprCamerasLayer, { createAlprCamerasLayer, configureAlprSource, @@ -342,6 +343,9 @@ function cameraHarness(layer = alprCamerasLayer) { remove() {}, }, }; + bindFakeCesiumCamera(viewer); + // The renderer's camera port relays moveEnd; the layer's own come on top. + const portMoveEndListeners = viewer.camera.moveEnd.numberOfListeners; const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function ( fn, @@ -364,6 +368,7 @@ function cameraHarness(layer = alprCamerasLayer) { layer.enable(); return { viewer, + portMoveEndListeners, requests, credits, get source() { @@ -932,7 +937,10 @@ test('two factories keep their requests, records and destruction independent', a b.source.entities.values[0], )?.labelModel.details.includes('Source: Test directory'), ); - assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0); + assert.equal( + a.viewer.camera.moveEnd.numberOfListeners, + a.portMoveEndListeners, + ); assert.equal(a.viewer.scene.postRender.numberOfListeners, 0); } finally { b.restore(); diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 80920bd22..d8a16cccf 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -13,6 +13,7 @@ // // computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under // plain node:test. +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import { test } from 'node:test'; // Preserve structural pins across component qualification and formatter wrapping. function componentFunctionSource(fn) { @@ -868,6 +869,7 @@ test('CCTV focus distinguishes tracking ownership from a missing active camera', }, }, }; + bindFakeCesiumCamera(viewer); const record = { camera: { rangeM: 210, headingDeg: 41 }, position: { x: 1, y: 2, z: 3 }, @@ -899,6 +901,7 @@ test('CCTV focus reports when the camera flight starts', () => { }, }, }; + bindFakeCesiumCamera(viewer); const record = { camera: { rangeM: 210, headingDeg: 41 }, position: { x: 1, y: 2, z: 3 }, @@ -919,6 +922,7 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => { }, }, }; + bindFakeCesiumCamera(viewer); const record = { camera: { rangeM: 210, headingDeg: 41 }, position: { x: 1, y: 2, z: 3 }, @@ -1397,19 +1401,16 @@ test('CCTV drag-then-release over a camera is inert, while a clean tap activates target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail), ); - bindCctvWorldClickGesture( - input, - () => { - activateCctvCameraFromWorldClick( - 'atx-cam-3', - () => { - activationCalls += 1; - return CCTV_ACTIVATION_RESULT.ACTIVATED; - }, - target, - ); - }, - ); + bindCctvWorldClickGesture(input, () => { + activateCctvCameraFromWorldClick( + 'atx-cam-3', + () => { + activationCalls += 1; + return CCTV_ACTIVATION_RESULT.ACTIVATED; + }, + target, + ); + }); handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); timeMs = 20; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 641c241fc..ec401b3c9 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -48,6 +48,7 @@ import { import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; import { withEntityRecord } from './entityRecords.js'; import { framePortFor } from '../testSupport/fakeFramePort.mjs'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), @@ -183,6 +184,7 @@ function installAwarenessRuntime({ }, container: new FakeElement(), }; + bindFakeCesiumCamera(viewer); const layers = new Map( AWARENESS_DEPENDENCIES.map((layerId) => [ layerId, @@ -825,7 +827,10 @@ for (const fixture of [ let retrackCalls = 0; try { - const trackedEntity = withEntityRecord({}, { trackedId: `${subject.layerId}:${subject.id}` }); + const trackedEntity = withEntityRecord( + {}, + { trackedId: `${subject.layerId}:${subject.id}` }, + ); runtime.viewer.trackedEntity = trackedEntity; replaceMethod( fixture.layer, @@ -927,7 +932,10 @@ test('Contacts activation adopts the production military tracked-subject descrip ), 'mutating one accessor result must not alter the tracked source position', ); - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: `military:${id}` }, + ); replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); replaceMethod( flightsLayer, @@ -1032,7 +1040,10 @@ test('production military tracked-subject label falls back to registration befor 'getAllPositions id stays the identity hex', ); - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: `military:${id}` }, + ); replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); replaceMethod(flightsLayer, 'trackById', () => true, restores); replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores); @@ -1104,7 +1115,10 @@ test('a cached Context subject re-reads its label when enrichment lands after se meta, }); - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: `flights:${id}` }, + ); replaceMethod( militaryFlightsLayer, 'getTrackedSubject', @@ -1247,7 +1261,10 @@ test('Contacts activation adopts the production civilian tracked-subject descrip ), 'mutating one accessor result must not alter the tracked source position', ); - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: `flights:${id}` }, + ); replaceMethod( militaryFlightsLayer, 'getTrackedSubject', @@ -1334,7 +1351,10 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after const restores = []; try { - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:first' }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: 'flights:first' }, + ); replaceMethod( flightsLayer, 'getTrackedSubject', @@ -1357,7 +1377,10 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after civilianTracked = null; militaryTracked = awarenessSubject('military', 'latest', positions.latest); - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'military:latest' }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: 'military:latest' }, + ); releaseDependencies(); await nextTurn(); await nextTurn(); @@ -1401,7 +1424,10 @@ test('tracked flight cleared during Contacts activation suppresses fallback auto const focused = []; try { - runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:selected' }); + runtime.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: 'flights:selected' }, + ); replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores); replaceMethod( militaryFlightsLayer, @@ -1999,7 +2025,7 @@ test('vessel entry and selection framing both use the 3 km focus radius', () => ); const focusRadiusUses = militaryAwarenessSource.match( - /new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, + /\{\s*center:\s*vessel\.position,\s*radius:\s*VESSEL_FOCUS_RADIUS_M\s*\}/g, ) || []; assert.equal(focusRadiusUses.length, 2); }); diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index 89ae37cc8..bd914a03c 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -1,3 +1,4 @@ +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -24,12 +25,26 @@ import { installRenderGovernor, } from '../renderGovernor.js'; import * as Cesium from 'cesium'; -import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js'; +import { + registerEntityContext, + selectEntityContext, + getSelectedEntityContext, + clearSelectedEntityContextForLayer, +} from './contextStore.js'; import { readEntityRecord } from './entityRecords.js'; test('clicking a selected installation again or empty map clears it through refresh', async () => { - const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42, - lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] }); + const run = await runInstallationLoad({ + elements: [ + { + type: 'node', + id: 42, + lat: 30.2, + lon: -97.7, + tags: { military: 'base', name: 'Site' }, + }, + ], + }); try { for (const nextPick of ['osm:node:42', null]) { run.click('osm:node:42'); @@ -37,66 +52,136 @@ test('clicking a selected installation again or empty map clears it through refr run.click(nextPick); assert.equal(getSelectedEntityContext(), null); await militaryInstallationsLayer.update(); - assert.equal(getSelectedEntityContext(), null, 'refresh must not resurrect selection'); + assert.equal( + getSelectedEntityContext(), + null, + 'refresh must not resurrect selection', + ); assert.equal(run.entities()[0].point.pixelSize.getValue(), 9); } - } finally { run.restore(); } + } finally { + run.restore(); + } }); test('background installation paints neither reopen Contacts nor resurrect a cleared selection', async () => { - const run = await runInstallationLoad({ elements: [{ type:'node',id:42,lat:30.2,lon:-97.7,tags:{military:'base',name:'Site'} }] }); + const run = await runInstallationLoad({ + elements: [ + { + type: 'node', + id: 42, + lat: 30.2, + lon: -97.7, + tags: { military: 'base', name: 'Site' }, + }, + ], + }); try { - const selected=()=>run.contextLabels().length; + const selected = () => run.contextLabels().length; run.click('osm:node:42'); - assert.equal(selected(),1); + assert.equal(selected(), 1); await militaryInstallationsLayer.update(); - assert.equal(getSelectedEntityContext()?.id,'osm:node:42'); - assert.equal(selected(),1,'data and floor refreshes must not publish a fresh selection'); + assert.equal(getSelectedEntityContext()?.id, 'osm:node:42'); + assert.equal( + selected(), + 1, + 'data and floor refreshes must not publish a fresh selection', + ); clearSelectedEntityContextForLayer('military-installations'); await militaryInstallationsLayer.update(); - assert.equal(getSelectedEntityContext(),null); - assert.equal(selected(),1,'an external clear must survive the next paint'); - assert.equal(run.entities()[0].point.pixelSize.getValue(),9); - } finally { run.restore(); } + assert.equal(getSelectedEntityContext(), null); + assert.equal( + selected(), + 1, + 'an external clear must survive the next paint', + ); + assert.equal(run.entities()[0].point.pixelSize.getValue(), 9); + } finally { + run.restore(); + } }); test('clearing a stale installation highlight does not clear or reclaim another layer selection', async () => { - const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42, - lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] }); + const run = await runInstallationLoad({ + elements: [ + { + type: 'node', + id: 42, + lat: 30.2, + lon: -97.7, + tags: { military: 'base', name: 'Site' }, + }, + ], + }); try { run.click('osm:node:42'); const aircraft = { id: 'aircraft:test' }; - registerEntityContext(aircraft, { id: aircraft.id, layerId: 'military', label: 'Aircraft' }); + registerEntityContext(aircraft, { + id: aircraft.id, + layerId: 'military', + label: 'Aircraft', + }); selectEntityContext(aircraft); await militaryInstallationsLayer.update(); - assert.equal(getSelectedEntityContext()?.id, aircraft.id, 'non-canvas selection survives a repaint'); + assert.equal( + getSelectedEntityContext()?.id, + aircraft.id, + 'non-canvas selection survives a repaint', + ); run.click('osm:node:42'); selectEntityContext(aircraft); run.click(aircraft); assert.equal(getSelectedEntityContext()?.id, aircraft.id); await militaryInstallationsLayer.update(); assert.equal(getSelectedEntityContext()?.id, aircraft.id); - } finally { run.restore(); } + } finally { + run.restore(); + } }); test('switching sites keeps the new selection through refresh and disable clears it', async () => { - const run = await runInstallationLoad({ elements: [42, 43].map(id => ({ type: 'node', id, - lat: 30.2, lon: -97.7, tags: { military: 'base', name: `Site ${id}` } })) }); + const run = await runInstallationLoad({ + elements: [42, 43].map((id) => ({ + type: 'node', + id, + lat: 30.2, + lon: -97.7, + tags: { military: 'base', name: `Site ${id}` }, + })), + }); try { run.click('osm:node:42'); run.click('osm:node:43'); assert.equal(getSelectedEntityContext()?.id, 'osm:node:43'); await militaryInstallationsLayer.update(); assert.equal(getSelectedEntityContext()?.id, 'osm:node:43'); - assert.equal(run.entities().find(e => e.id === 'osm:node:42').point.pixelSize.getValue(), 9); - assert.equal(run.entities().find(e => e.id === 'osm:node:43').point.pixelSize.getValue(), 13); + assert.equal( + run + .entities() + .find((e) => e.id === 'osm:node:42') + .point.pixelSize.getValue(), + 9, + ); + assert.equal( + run + .entities() + .find((e) => e.id === 'osm:node:43') + .point.pixelSize.getValue(), + 13, + ); militaryInstallationsLayer.disable(); run.click('osm:node:42'); - assert.equal(getSelectedEntityContext(), null, 'disabled layer ignores clicks'); + assert.equal( + getSelectedEntityContext(), + null, + 'disabled layer ignores clicks', + ); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); assert.equal(getSelectedEntityContext(), null); - } finally { run.restore(); } + } finally { + run.restore(); + } }); test('cheap installation distance prefilter is local and antimeridian-safe', () => { @@ -112,22 +197,50 @@ test('cheap installation distance prefilter is local and antimeridian-safe', () }); test('keeps generic Places hits distinct from explicitly typed military facilities', () => { - assert.equal(classifyGoogleMilitaryPlace({ primaryType: 'military_base' }), 'military_land'); - assert.equal(classifyGoogleMilitaryPlace({ types: ['point_of_interest', 'military_base'] }), 'military_land'); - assert.equal(classifyGoogleMilitaryPlace({ name: 'Army Recruiting Office', types: ['government_office'] }), 'places_candidate'); - assert.equal(classifyGoogleMilitaryPlace({ name: 'Military Museum', types: ['museum'] }), 'places_candidate'); + assert.equal( + classifyGoogleMilitaryPlace({ primaryType: 'military_base' }), + 'military_land', + ); + assert.equal( + classifyGoogleMilitaryPlace({ + types: ['point_of_interest', 'military_base'], + }), + 'military_land', + ); + assert.equal( + classifyGoogleMilitaryPlace({ + name: 'Army Recruiting Office', + types: ['government_office'], + }), + 'places_candidate', + ); + assert.equal( + classifyGoogleMilitaryPlace({ name: 'Military Museum', types: ['museum'] }), + 'places_candidate', + ); }); test('reports the record source instead of attributing Places records to OpenStreetMap', () => { - assert.equal(installationSourceLabel({ sources: [{ name: 'Google Maps Places' }] }), 'Google Maps Places'); - assert.equal(installationSourceLabel({ sources: [{ name: 'OpenStreetMap' }, { name: 'OpenStreetMap' }] }), 'OpenStreetMap'); + assert.equal( + installationSourceLabel({ sources: [{ name: 'Google Maps Places' }] }), + 'Google Maps Places', + ); + assert.equal( + installationSourceLabel({ + sources: [{ name: 'OpenStreetMap' }, { name: 'OpenStreetMap' }], + }), + 'OpenStreetMap', + ); }); test('places installation anchors on the shared cached rendered floor', () => { setMeshFloorPreferred(true); _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2, -97.7, 182.25); - assert.equal(installationSurfaceHeightM({ latitude: 30.2, longitude: -97.7 }), 183.75); + assert.equal( + installationSurfaceHeightM({ latitude: 30.2, longitude: -97.7 }), + 183.75, + ); _clearMeshFloorCellsForTest(); }); @@ -146,7 +259,11 @@ test('real enabled installation entities carry no native label graphics', async globalThis.window = { dispatchEvent() {} }; globalThis.fetch = async (url) => { if (String(url).includes('/api/terrain/heights')) { - return { ok: true, status: 200, json: async () => ({ results: [{ ellipsoid: 100 }] }) }; + return { + ok: true, + status: 200, + json: async () => ({ results: [{ ellipsoid: 100 }] }), + }; } return { ok: true, @@ -154,13 +271,15 @@ test('real enabled installation entities carry no native label graphics', async json: async () => ({ status: 'fresh', retrievedAt: '2026-08-02T00:00:00.000Z', - elements: [{ - type: 'node', - id: 42, - lat: 30.2, - lon: -97.7, - tags: { military: 'base', name: 'Runtime Installation' }, - }], + elements: [ + { + type: 'node', + id: 42, + lat: 30.2, + lon: -97.7, + tags: { military: 'base', name: 'Runtime Installation' }, + }, + ], }), }; }; @@ -186,10 +305,15 @@ test('real enabled installation entities carry no native label graphics', async scene: { canvas, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, + pick() { + return null; + }, }, dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, + add(dataSource) { + dataSources.push(dataSource); + return dataSource; + }, remove(dataSource) { const index = dataSources.indexOf(dataSource); if (index >= 0) dataSources.splice(index, 1); @@ -197,13 +321,17 @@ test('real enabled installation entities carry no native label graphics', async }, }, }; + bindFakeCesiumCamera(viewer); try { militaryInstallationsLayer.init(viewer); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); const entities = dataSources[0].entities.values; - assert.ok(entities.length > 0, 'runtime guard requires rendered installation records'); + assert.ok( + entities.length > 0, + 'runtime guard requires rendered installation records', + ); assert.ok(entities.every((entity) => entity.label === undefined)); } finally { militaryInstallationsLayer.destroy(viewer); @@ -248,7 +376,9 @@ test('Context focus past the render cap selects for real instead of flying blind assert.equal(focused, true, 'focus succeeds'); // The proof: a real entity now backs the selection, so the Context subject // actually changes rather than the camera moving over a stale subject. - const nowRendered = run.entities().find((entity) => entity.id === beyondCap.id); + const nowRendered = run + .entities() + .find((entity) => entity.id === beyondCap.id); assert.ok(nowRendered, 'the focused record was rendered on demand'); assert.equal( run.contextLabels().at(-1), @@ -284,7 +414,12 @@ async function runInstallationLoad({ dispatchEvent(event) { if (event?.detail?.label) contextEvents.push(event.detail.label); }, - CustomEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } }, + CustomEvent: class { + constructor(type, init) { + this.type = type; + Object.assign(this, init); + } + }, }; globalThis.fetch = async (url) => { const href = String(url); @@ -293,7 +428,11 @@ async function runInstallationLoad({ } requests.push(href); if (failWith) { - return { ok: false, status: 503, json: async () => ({ error: failWith }) }; + return { + ok: false, + status: 503, + json: async () => ({ error: failWith }), + }; } const exact = href.includes('exact=1'); const payload = { @@ -312,8 +451,14 @@ async function runInstallationLoad({ let clickAction; const viewer = { camera: { - moveEnd: { addEventListener() { return () => {}; } }, - flyToBoundingSphere(sphere, options) { cameraFlights.push({ sphere, options }); }, + moveEnd: { + addEventListener() { + return () => {}; + }, + }, + flyToBoundingSphere(sphere, options) { + cameraFlights.push({ sphere, options }); + }, computeViewRectangle() { return { south: Cesium.Math.toRadians(VIEWPORT.south), @@ -326,7 +471,9 @@ async function runInstallationLoad({ scene: { canvas: { addEventListener() {}, removeEventListener() {} }, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return picked; }, + pick() { + return picked; + }, // Enough surface for the real render governor to drive this viewer, so // one-shot render requests are observable. requestRenderMode: false, @@ -334,7 +481,10 @@ async function runInstallationLoad({ requestRender() {}, }, dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, + add(dataSource) { + dataSources.push(dataSource); + return dataSource; + }, remove(dataSource) { const index = dataSources.indexOf(dataSource); if (index >= 0) dataSources.splice(index, 1); @@ -342,15 +492,29 @@ async function runInstallationLoad({ }, }, }; + bindFakeCesiumCamera(viewer); - const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; - Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) { + const originalSetInputAction = + Cesium.ScreenSpaceEventHandler.prototype.setInputAction; + Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function ( + action, + type, + modifier, + ) { // The unmodified click: the input port binds one per modifier. - if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK && modifier === undefined) clickAction = action; + if ( + type === Cesium.ScreenSpaceEventType.LEFT_CLICK && + modifier === undefined + ) + clickAction = action; return originalSetInputAction.call(this, action, type, modifier); }; - try { militaryInstallationsLayer.init(viewer); } - finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; } + try { + militaryInstallationsLayer.init(viewer); + } finally { + Cesium.ScreenSpaceEventHandler.prototype.setInputAction = + originalSetInputAction; + } installRenderGovernor(framePortFor(viewer.scene)); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); @@ -362,11 +526,15 @@ async function runInstallationLoad({ contextLabels: () => contextEvents, stats: () => militaryInstallationsLayer.getStats(), click(target) { - const entity = typeof target === 'string' ? dataSources[0].entities.getById(target) : target; + const entity = + typeof target === 'string' + ? dataSources[0].entities.getById(target) + : target; picked = entity ? { id: entity } : undefined; clickAction({ position: { x: 0, y: 0 } }); }, - renderRequests: () => getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason), + renderRequests: () => + getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason), restore() { militaryInstallationsLayer.destroy(viewer); _resetRenderGovernorForTest(); @@ -380,8 +548,18 @@ async function runInstallationLoad({ } test('viewport membership keeps intersecting footprints and drops the snap ring', () => { - const inside = { osmType: 'node', latitude: 30.5, longitude: -97.5, footprint: null }; - const outside = { osmType: 'node', latitude: 30.5, longitude: -96.5, footprint: null }; + const inside = { + osmType: 'node', + latitude: 30.5, + longitude: -97.5, + footprint: null, + }; + const outside = { + osmType: 'node', + latitude: 30.5, + longitude: -96.5, + footprint: null, + }; assert.equal(installationWithinViewport(inside, VIEWPORT), true); assert.equal(installationWithinViewport(outside, VIEWPORT), false); @@ -391,7 +569,12 @@ test('viewport membership keeps intersecting footprints and drops the snap ring' osmType: 'way', latitude: 30.5, longitude: -96.95, - footprint: [[-97.05, 30.4], [-96.9, 30.4], [-96.9, 30.6], [-97.05, 30.6]], + footprint: [ + [-97.05, 30.4], + [-96.9, 30.4], + [-96.9, 30.6], + [-97.05, 30.6], + ], }; assert.equal(installationWithinViewport(straddling, VIEWPORT), true); @@ -399,7 +582,12 @@ test('viewport membership keeps intersecting footprints and drops the snap ring' osmType: 'way', latitude: 30.5, longitude: -96.5, - footprint: [[-96.6, 30.4], [-96.4, 30.4], [-96.4, 30.6], [-96.6, 30.6]], + footprint: [ + [-96.6, 30.4], + [-96.4, 30.4], + [-96.4, 30.6], + [-96.6, 30.6], + ], }; assert.equal(installationWithinViewport(farWithFootprint, VIEWPORT), false); assert.equal(installationWithinViewport(null, VIEWPORT), false); @@ -411,17 +599,36 @@ test('extended features with unknown extent are kept, not centre-tested', () => // are normalized without one, so their true extent is unknown here. Overpass // already proved they intersect the queried bbox; centre-testing them would // erase exactly the biggest installations. - const relation = { osmType: 'relation', latitude: 30.5, longitude: -96.99, footprint: null }; - const hugeWay = { osmType: 'way', latitude: 29.5, longitude: -97.5, footprint: null }; + const relation = { + osmType: 'relation', + latitude: 30.5, + longitude: -96.99, + footprint: null, + }; + const hugeWay = { + osmType: 'way', + latitude: 29.5, + longitude: -97.5, + footprint: null, + }; assert.equal(installationWithinViewport(relation, VIEWPORT), true); assert.equal(installationWithinViewport(hugeWay, VIEWPORT), true); // A node IS its geometry, so excluding it on centre loses nothing. assert.equal( - installationWithinViewport({ osmType: 'node', latitude: 30.5, longitude: -96.99, footprint: null }, VIEWPORT), + installationWithinViewport( + { osmType: 'node', latitude: 30.5, longitude: -96.99, footprint: null }, + VIEWPORT, + ), false, ); // Unknown provenance is treated inclusively, same as an extended feature. - assert.equal(installationWithinViewport({ latitude: 30.5, longitude: -96.99, footprint: null }, VIEWPORT), true); + assert.equal( + installationWithinViewport( + { latitude: 30.5, longitude: -96.99, footprint: null }, + VIEWPORT, + ), + true, + ); }); test('a footprint-less relation just outside the viewport still renders', async () => { @@ -430,14 +637,27 @@ test('a footprint-less relation just outside the viewport still renders', async // A relation whose CENTER sits outside the viewport and whose geometry // lives on members Overpass did not inline. It was returned because it // intersects the queried bbox, so it must survive the viewport filter. - { type: 'relation', id: 91, center: { lat: 30.5, lon: -96.995 }, tags: { military: 'range', name: 'Straddling Range' } }, + { + type: 'relation', + id: 91, + center: { lat: 30.5, lon: -96.995 }, + tags: { military: 'range', name: 'Straddling Range' }, + }, // A NODE at the same off-view spot has no extent and must still be cut. - { type: 'node', id: 92, lat: 30.5, lon: -96.995, tags: { military: 'range', name: 'Off View Node' } }, + { + type: 'node', + id: 92, + lat: 30.5, + lon: -96.995, + tags: { military: 'range', name: 'Off View Node' }, + }, ], }); try { assert.deepEqual( - harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title), + harness + .entities() + .map((entity) => readEntityRecord(entity)?.labelModel?.title), ['Straddling Range'], ); } finally { @@ -446,38 +666,75 @@ test('a footprint-less relation just outside the viewport still renders', async }); test('a legacy cached response with no saturation flag still triggers the exact retry', () => { - const atCap = { elements: new Array(700).fill({ type: 'node' }), elementCap: 700 }; - assert.equal(installationResponseSaturated(atCap), true, 'derived from the reported cap'); + const atCap = { + elements: new Array(700).fill({ type: 'node' }), + elementCap: 700, + }; assert.equal( - installationResponseSaturated({ elements: new Array(699).fill({ type: 'node' }), elementCap: 700 }), + installationResponseSaturated(atCap), + true, + 'derived from the reported cap', + ); + assert.equal( + installationResponseSaturated({ + elements: new Array(699).fill({ type: 'node' }), + elementCap: 700, + }), false, ); // An explicit flag always wins over the derivation. - assert.equal(installationResponseSaturated({ ...atCap, saturated: false }), false); - assert.equal(installationResponseSaturated({ elements: [], saturated: true }), true); + assert.equal( + installationResponseSaturated({ ...atCap, saturated: false }), + false, + ); + assert.equal( + installationResponseSaturated({ elements: [], saturated: true }), + true, + ); // Nothing to derive from: do not invent saturation. - assert.equal(installationResponseSaturated({ elements: new Array(700).fill({}) }), false); + assert.equal( + installationResponseSaturated({ elements: new Array(700).fill({}) }), + false, + ); assert.equal(installationResponseSaturated(null), false); }); test('a legacy-shaped payload at the cap fires the exact-viewport retry end to end', async () => { const elements = []; for (let index = 0; index < 700; index += 1) { - elements.push({ type: 'node', id: 3000 + index, lat: 30.5, lon: -96.2, tags: { military: 'range' } }); + elements.push({ + type: 'node', + id: 3000 + index, + lat: 30.5, + lon: -96.2, + tags: { military: 'range' }, + }); } const harness = await runInstallationLoad({ elements, // Pre-fix cache shape: no `saturated` field at all, but at the cap. legacyPayload: true, exactElements: [ - { type: 'node', id: 8, lat: 30.5, lon: -97.5, tags: { military: 'range', name: 'Rescued From Legacy' } }, + { + type: 'node', + id: 8, + lat: 30.5, + lon: -97.5, + tags: { military: 'range', name: 'Rescued From Legacy' }, + }, ], }); try { - assert.equal(harness.requests.length, 2, 'a legacy entry must not skip the retry'); + assert.equal( + harness.requests.length, + 2, + 'a legacy entry must not skip the retry', + ); assert.equal(harness.requests[1].includes('exact=1'), true); assert.deepEqual( - harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title), + harness + .entities() + .map((entity) => readEntityRecord(entity)?.labelModel?.title), ['Rescued From Legacy'], ); } finally { @@ -486,11 +743,15 @@ test('a legacy-shaped payload at the cap fires the exact-viewport retry end to e }); test('a failed load buys the frame its status change needs', async () => { - const harness = await runInstallationLoad({ failWith: 'Installation feed HTTP 503' }); + const harness = await runInstallationLoad({ + failWith: 'Installation feed HTTP 503', + }); try { assert.equal(harness.stats().status, 'unavailable'); assert.ok( - harness.renderRequests().some((reason) => reason === 'installations-status'), + harness + .renderRequests() + .some((reason) => reason === 'installations-status'), 'an idle governor would otherwise leave the last healthy readout on screen', ); } finally { @@ -503,14 +764,32 @@ test('off-viewport records from the snapped superset never render or enter conte // The snapped bbox reaches ~5.5 km beyond the viewport; this node sits a // full degree outside it. elements: [ - { type: 'node', id: 1, lat: 30.5, lon: -97.5, tags: { military: 'range', name: 'In View' } }, - { type: 'node', id: 2, lat: 30.5, lon: -96.2, tags: { military: 'range', name: 'Off View' } }, + { + type: 'node', + id: 1, + lat: 30.5, + lon: -97.5, + tags: { military: 'range', name: 'In View' }, + }, + { + type: 'node', + id: 2, + lat: 30.5, + lon: -96.2, + tags: { military: 'range', name: 'Off View' }, + }, ], }); try { - const titles = harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title); + const titles = harness + .entities() + .map((entity) => readEntityRecord(entity)?.labelModel?.title); assert.deepEqual(titles, ['In View'], 'only the in-viewport site renders'); - assert.equal(harness.contextLabels().includes('Off View'), false, 'and none enters context'); + assert.equal( + harness.contextLabels().includes('Off View'), + false, + 'and none enters context', + ); assert.equal(harness.stats().count, 1); } finally { harness.restore(); @@ -522,21 +801,47 @@ test('a saturated snapped tile refetches the exact viewport before rendering', a for (let index = 0; index < 700; index += 1) { // A saturated snapped response full of OFF-viewport sites: the in-view ones // were crowded out upstream. - elements.push({ type: 'node', id: 1000 + index, lat: 30.5, lon: -96.2, tags: { military: 'range' } }); + elements.push({ + type: 'node', + id: 1000 + index, + lat: 30.5, + lon: -96.2, + tags: { military: 'range' }, + }); } const harness = await runInstallationLoad({ elements, saturated: true, exactElements: [ - { type: 'node', id: 7, lat: 30.5, lon: -97.5, tags: { military: 'range', name: 'Rescued' } }, + { + type: 'node', + id: 7, + lat: 30.5, + lon: -97.5, + tags: { military: 'range', name: 'Rescued' }, + }, ], }); try { - assert.equal(harness.requests.length, 2, 'saturation triggers exactly one retry'); - assert.equal(harness.requests[0].includes('exact=1'), false, 'first ask uses the shared snapped tile'); - assert.equal(harness.requests[1].includes('exact=1'), true, 'retry opts out of the snap'); + assert.equal( + harness.requests.length, + 2, + 'saturation triggers exactly one retry', + ); + assert.equal( + harness.requests[0].includes('exact=1'), + false, + 'first ask uses the shared snapped tile', + ); + assert.equal( + harness.requests[1].includes('exact=1'), + true, + 'retry opts out of the snap', + ); assert.deepEqual( - harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title), + harness + .entities() + .map((entity) => readEntityRecord(entity)?.labelModel?.title), ['Rescued'], 'the in-viewport site is no longer starved by off-view ones', ); @@ -547,7 +852,15 @@ test('a saturated snapped tile refetches the exact viewport before rendering', a test('an unsaturated response never pays for a second upstream ask', async () => { const harness = await runInstallationLoad({ - elements: [{ type: 'node', id: 3, lat: 30.5, lon: -97.5, tags: { military: 'range' } }], + elements: [ + { + type: 'node', + id: 3, + lat: 30.5, + lon: -97.5, + tags: { military: 'range' }, + }, + ], }); try { assert.equal(harness.requests.length, 1); @@ -560,9 +873,19 @@ test('an unsaturated response never pays for a second upstream ask', async () => test('a still-saturated exact viewport is reported honestly instead of implied complete', async () => { const elements = []; for (let index = 0; index < 700; index += 1) { - elements.push({ type: 'node', id: 2000 + index, lat: 30.5, lon: -97.5, tags: { military: 'range' } }); + elements.push({ + type: 'node', + id: 2000 + index, + lat: 30.5, + lon: -97.5, + tags: { military: 'range' }, + }); } - const harness = await runInstallationLoad({ elements, saturated: true, exactSaturated: true }); + const harness = await runInstallationLoad({ + elements, + saturated: true, + exactSaturated: true, + }); try { assert.equal(harness.stats().saturated, true); assert.match(harness.stats().error, /Too many mapped sites/); @@ -591,9 +914,15 @@ test('a floor that lands after the render deadline lifts the dots off the ellips // The production failure: the bounded pre-render resolve gives up before // Re:Earth answers, so the first paint has no floor to stand on. if (terrainCalls === 1) { - await new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS + 200)); + await new Promise((resolve) => + setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS + 200), + ); } - return { ok: true, status: 200, json: async () => ({ results: [{ ellipsoid: 2400 }] }) }; + return { + ok: true, + status: 200, + json: async () => ({ results: [{ ellipsoid: 2400 }] }), + }; } return { ok: true, @@ -601,14 +930,20 @@ test('a floor that lands after the render deadline lifts the dots off the ellips json: async () => ({ status: 'fresh', retrievedAt: '2026-08-18T00:00:00.000Z', - elements: [{ type: 'node', id: 77, lat, lon, tags: { military: 'range' } }], + elements: [ + { type: 'node', id: 77, lat, lon, tags: { military: 'range' } }, + ], }), }; }; const dataSources = []; const viewer = { camera: { - moveEnd: { addEventListener() { return () => {}; } }, + moveEnd: { + addEventListener() { + return () => {}; + }, + }, computeViewRectangle() { return { south: Cesium.Math.toRadians(44), @@ -621,10 +956,15 @@ test('a floor that lands after the render deadline lifts the dots off the ellips scene: { canvas: { addEventListener() {}, removeEventListener() {} }, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, + pick() { + return null; + }, }, dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, + add(dataSource) { + dataSources.push(dataSource); + return dataSource; + }, remove(dataSource) { const index = dataSources.indexOf(dataSource); if (index >= 0) dataSources.splice(index, 1); @@ -632,10 +972,12 @@ test('a floor that lands after the render deadline lifts the dots off the ellips }, }, }; + bindFakeCesiumCamera(viewer); - const heightOf = (entity) => Cesium.Cartographic.fromCartesian( - entity.position.getValue(Cesium.JulianDate.now()), - ).height; + const heightOf = (entity) => + Cesium.Cartographic.fromCartesian( + entity.position.getValue(Cesium.JulianDate.now()), + ).height; try { militaryInstallationsLayer.init(viewer); @@ -644,13 +986,24 @@ test('a floor that lands after the render deadline lifts the dots off the ellips const buried = dataSources[0].entities.values[0]; assert.ok(buried, 'the cold-floor pass still renders the record'); - assert.ok(Math.abs(heightOf(buried)) < 1, 'a cold floor anchors at the ellipsoid, as before'); + assert.ok( + Math.abs(heightOf(buried)) < 1, + 'a cold floor anchors at the ellipsoid, as before', + ); // The warm chain resolves out of band; wait for the floor to land. - for (let attempt = 0; attempt < 50 && cachedGroundFloor(lat, lon) == null; attempt += 1) { + for ( + let attempt = 0; + attempt < 50 && cachedGroundFloor(lat, lon) == null; + attempt += 1 + ) { await new Promise((resolve) => setTimeout(resolve, 10)); } - assert.equal(cachedGroundFloor(lat, lon), 2400, 'the late floor landed in the shared cache'); + assert.equal( + cachedGroundFloor(lat, lon), + 2400, + 'the late floor landed in the shared cache', + ); const lifted = dataSources[0].entities.values[0]; assert.ok( @@ -674,7 +1027,9 @@ test('reports bounded installation requests as loading and clears on settlement' const originalWindow = globalThis.window; const originalFetch = globalThis.fetch; let resolveInstallations; - const installationsResponse = new Promise((resolve) => { resolveInstallations = resolve; }); + const installationsResponse = new Promise((resolve) => { + resolveInstallations = resolve; + }); globalThis.document = { addEventListener() {}, removeEventListener() {} }; globalThis.window = { dispatchEvent() {} }; globalThis.fetch = async (url) => { @@ -685,7 +1040,11 @@ test('reports bounded installation requests as loading and clears on settlement' }; const viewer = { camera: { - moveEnd: { addEventListener() { return () => {}; } }, + moveEnd: { + addEventListener() { + return () => {}; + }, + }, computeViewRectangle() { return { south: Cesium.Math.toRadians(30), @@ -698,10 +1057,20 @@ test('reports bounded installation requests as loading and clears on settlement' scene: { canvas: { addEventListener() {}, removeEventListener() {} }, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, + pick() { + return null; + }, + }, + dataSources: { + add(value) { + return value; + }, + remove() { + return true; + }, }, - dataSources: { add(value) { return value; }, remove() { return true; } }, }; + bindFakeCesiumCamera(viewer); try { militaryInstallationsLayer.init(viewer); @@ -740,32 +1109,52 @@ test('zoom-out aborts an active installation request and returns non-loading gui globalThis.fetch = async (_url, options = {}) => { observedSignal = options.signal; return new Promise((_resolve, reject) => { - options.signal?.addEventListener('abort', () => { - const error = new Error('aborted'); - error.name = 'AbortError'; - reject(error); - }, { once: true }); + options.signal?.addEventListener( + 'abort', + () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }, + { once: true }, + ); }); }; const viewer = { camera: { - moveEnd: { addEventListener() { return () => {}; } }, + moveEnd: { + addEventListener() { + return () => {}; + }, + }, computeViewRectangle() { - return globalView ? null : { - south: Cesium.Math.toRadians(30), - west: Cesium.Math.toRadians(-98), - north: Cesium.Math.toRadians(31), - east: Cesium.Math.toRadians(-97), - }; + return globalView + ? null + : { + south: Cesium.Math.toRadians(30), + west: Cesium.Math.toRadians(-98), + north: Cesium.Math.toRadians(31), + east: Cesium.Math.toRadians(-97), + }; }, }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, + pick() { + return null; + }, + }, + dataSources: { + add(value) { + return value; + }, + remove() { + return true; + }, }, - dataSources: { add(value) { return value; }, remove() { return true; } }, }; + bindFakeCesiumCamera(viewer); try { militaryInstallationsLayer.init(viewer); @@ -778,8 +1167,15 @@ test('zoom-out aborts an active installation request and returns non-loading gui assert.equal(observedSignal.aborted, true); assert.equal(militaryInstallationsLayer.getStats().loading, false); assert.equal(militaryInstallationsLayer.getStats().status, 'zoom-in'); - assert.equal(militaryInstallationsLayer.getStats().error, null, 'guidance is not a fault'); - assert.match(militaryInstallationsLayer.getStats().statusMessage, /zoom in/i); + assert.equal( + militaryInstallationsLayer.getStats().error, + null, + 'guidance is not a fault', + ); + assert.match( + militaryInstallationsLayer.getStats().statusMessage, + /zoom in/i, + ); } finally { militaryInstallationsLayer.destroy(viewer); globalThis.fetch = originalFetch; @@ -803,91 +1199,194 @@ import { installationRetryDelayMs } from './militaryInstallations.js'; import { framePortFor } from '../testSupport/fakeFramePort.mjs'; const installationsSource = readLayerSource( - new URL('./militaryInstallations.js', import.meta.url), 'utf8'); + new URL('./militaryInstallations.js', import.meta.url), + 'utf8', +); test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => { - assert.equal(installationRetryDelayMs(0), 30000, 'first failure retries in 30s'); - assert.equal(installationRetryDelayMs(undefined), 30000, 'no prior delay means the minimum'); + assert.equal( + installationRetryDelayMs(0), + 30000, + 'first failure retries in 30s', + ); + assert.equal( + installationRetryDelayMs(undefined), + 30000, + 'no prior delay means the minimum', + ); assert.equal(installationRetryDelayMs(30000), 60000, 'each failure doubles'); assert.equal(installationRetryDelayMs(60000), 120000); - assert.equal(installationRetryDelayMs(120000), 240000, 'the ceiling is four minutes'); + assert.equal( + installationRetryDelayMs(120000), + 240000, + 'the ceiling is four minutes', + ); assert.equal(installationRetryDelayMs(240000), 240000, 'and it stays there'); - assert.equal(installationRetryDelayMs(-5), 30000, 'garbage restarts at the minimum'); + assert.equal( + installationRetryDelayMs(-5), + 30000, + 'garbage restarts at the minimum', + ); }); test('the retry is wired to every lifecycle edge, not just declared', () => { - assert.match(installationsSource, + assert.match( + installationsSource, /setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*if \(!isUnavailableCapability\(error\)\)\s*parts\.viewport\.scheduleUnavailableRetry\(error\?\.retryAfterMs\);/, - 'a failed load schedules the retry immediately after reporting unavailable'); - assert.match(installationsSource, + 'a failed load schedules the retry immediately after reporting unavailable', + ); + assert.match( + installationsSource, /clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/, - 'a successful load clears the pending retry and resets the backoff'); - assert.match(installationsSource, + 'a successful load clears the pending retry and resets the backoff', + ); + assert.match( + installationsSource, /clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/, - 'zooming out of range cancels the retry — moveEnd owns re-entry there'); - assert.match(installationsSource, /disable\(\) \{[^]*?clearUnavailableRetry\(\);/, - 'disabling the layer cancels the retry'); - assert.match(installationsSource, + 'zooming out of range cancels the retry — moveEnd owns re-entry there', + ); + assert.match( + installationsSource, + /disable\(\) \{[^]*?clearUnavailableRetry\(\);/, + 'disabling the layer cancels the retry', + ); + assert.match( + installationsSource, /function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/, - 'a user-driven load supersedes the retry without resetting the backoff step'); - assert.match(installationsSource, + 'a user-driven load supersedes the retry without resetting the backoff step', + ); + assert.match( + installationsSource, /layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/, - 'the fired retry re-checks enablement and never races an in-flight load'); + 'the fired retry re-checks enablement and never races an in-flight load', + ); }); - test('a merged installation stays visible when only its second fragment intersects', () => { - const far = [[-96.6,30.4],[-96.4,30.4],[-96.4,30.6],[-96.6,30.6]]; - const near = [[-97.5,30.4],[-97.4,30.4],[-97.4,30.6],[-97.5,30.6]]; - assert.equal(installationWithinViewport({latitude:30.5,longitude:-96.5,footprint:far,footprints:[[far],[near]]},VIEWPORT), true); + const far = [ + [-96.6, 30.4], + [-96.4, 30.4], + [-96.4, 30.6], + [-96.6, 30.6], + ]; + const near = [ + [-97.5, 30.4], + [-97.4, 30.4], + [-97.4, 30.6], + [-97.5, 30.6], + ]; + assert.equal( + installationWithinViewport( + { + latitude: 30.5, + longitude: -96.5, + footprint: far, + footprints: [[far], [near]], + }, + VIEWPORT, + ), + true, + ); }); test('a Contacts anchor replaces the viewport and ignores small subject moves', async () => { // Field report: a follow/Cockpit camera never fires moveEnd and often looks at // the horizon, so the Context row read "?" for the whole track. - const run = await runInstallationLoad({ elements: [{ type: 'node', id: 7, - lat: 30.3125, lon: -97.765, tags: { military: 'base', name: 'Camp Mabry' } }] }); + const run = await runInstallationLoad({ + elements: [ + { + type: 'node', + id: 7, + lat: 30.3125, + lon: -97.765, + tags: { military: 'base', name: 'Camp Mabry' }, + }, + ], + }); try { const box = (href) => { const url = new URL(href, 'https://example.test'); - return Object.fromEntries(['south', 'west', 'north', 'east'] - .map((key) => [key, Number(url.searchParams.get(key))])); + return Object.fromEntries( + ['south', 'west', 'north', 'east'].map((key) => [ + key, + Number(url.searchParams.get(key)), + ]), + ); }; const centre = (href) => (box(href).south + box(href).north) / 2; assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY'); - assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.3125, longitude: -97.765 }), true); + assert.equal( + militaryInstallationsLayer.setContextAnchor({ + latitude: 30.3125, + longitude: -97.765, + }), + true, + ); await new Promise((resolve) => setTimeout(resolve, 650)); const anchored = box(run.requests.at(-1)); assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.3125) < 0.01); - assert.ok(anchored.north - anchored.south > 1.5, 'window reaches ~100 km each way'); + assert.ok( + anchored.north - anchored.south > 1.5, + 'window reaches ~100 km each way', + ); assert.equal(run.stats().coverageLabel, 'WITHIN 100 KM'); assert.equal(run.stats().coverage.kind, 'subject'); const count = run.requests.length; - assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.35, longitude: -97.765 }), false, - 'a 4 km move keeps the window'); - assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.6, longitude: -97.765 }), true, - 'a 32 km move shifts the window'); + assert.equal( + militaryInstallationsLayer.setContextAnchor({ + latitude: 30.35, + longitude: -97.765, + }), + false, + 'a 4 km move keeps the window', + ); + assert.equal( + militaryInstallationsLayer.setContextAnchor({ + latitude: 30.6, + longitude: -97.765, + }), + true, + 'a 32 km move shifts the window', + ); await new Promise((resolve) => setTimeout(resolve, 650)); assert.equal(run.requests.length, count + 1); assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.6) < 0.01); assert.equal(militaryInstallationsLayer.setContextAnchor(null), true); await new Promise((resolve) => setTimeout(resolve, 650)); - assert.deepEqual(box(run.requests.at(-1)), { south: 30, west: -98, north: 31, east: -97 }, - 'clearing the subject returns to the viewport'); + assert.deepEqual( + box(run.requests.at(-1)), + { south: 30, west: -98, north: 31, east: -97 }, + 'clearing the subject returns to the viewport', + ); assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY'); - } finally { run.restore(); } + } finally { + run.restore(); + } }); test('unchanged installations keep their entities across reloads', async () => { - const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42, - lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] }); + const run = await runInstallationLoad({ + elements: [ + { + type: 'node', + id: 42, + lat: 30.2, + lon: -97.7, + tags: { military: 'base', name: 'Site' }, + }, + ], + }); try { const before = run.entities().slice(); assert.ok(before.length > 0); await militaryInstallationsLayer.update(); const after = run.entities(); assert.equal(after.length, before.length); - assert.ok(after.every((entity, index) => entity === before[index]), - 'a reload with the same geometry must not recreate entities'); - } finally { run.restore(); } + assert.ok( + after.every((entity, index) => entity === before[index]), + 'a reload with the same geometry must not recreate entities', + ); + } finally { + run.restore(); + } }); diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index e9a07d6d2..cdf1d9f12 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import { buildRadioTunerBand, buildRadioTunerTicks, @@ -51,7 +52,10 @@ import { radioViewIsGlobal, stationMatchesRadioCategory, } from './radio.js'; -import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs'; +import { + radioStationRow, + withRadioLayer, +} from '../testSupport/radioLayerHarness.mjs'; import { withEntityRecord } from './entityRecords.js'; const stations = [ @@ -64,12 +68,27 @@ const stations = [ test('station-level tags produce canonical and detected-genre categories', () => { const categories = buildRadioCategories(stations); assert.equal(categories.find((category) => category.id === 'all').count, 4); - assert.equal(categories.find((category) => category.id === 'weather').count, 2); - assert.equal(categories.find((category) => category.id === 'public-safety').count, 1); - assert.equal(categories.find((category) => category.id === 'genre:jazz').count, 1); + assert.equal( + categories.find((category) => category.id === 'weather').count, + 2, + ); + assert.equal( + categories.find((category) => category.id === 'public-safety').count, + 1, + ); + assert.equal( + categories.find((category) => category.id === 'genre:jazz').count, + 1, + ); assert.equal(categories.find((category) => category.id === 'other').count, 1); - assert.equal(categories.find((category) => category.id === 'music').color, '#54d17a'); - assert.equal(categories.find((category) => category.id === 'genre:jazz').color, '#54d17a'); + assert.equal( + categories.find((category) => category.id === 'music').color, + '#54d17a', + ); + assert.equal( + categories.find((category) => category.id === 'genre:jazz').color, + '#54d17a', + ); }); test('every generated Radio category is accepted by durable preferences', () => { @@ -102,26 +121,46 @@ test('cluster badges lead with a readable count and concise dominant category', test('Radio globe labels are compact and frequency-first without misreading names', () => { assert.equal( - radioGlobeLabel({ name: 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio' }), + radioGlobeLabel({ + name: 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio', + }), '93.9 FM — La Zeta', ); assert.equal( - radioGlobeLabel({ name: '100.3 The River - WQRV - Meridianville/Huntsville, AL' }), + radioGlobeLabel({ + name: '100.3 The River - WQRV - Meridianville/Huntsville, AL', + }), '100.3 FM — The River', ); - assert.equal(radioGlobeLabel({ name: "80's New Wave Radio" }), "80's New Wave Radio"); - assert.equal(radioGlobeLabel({ name: "100 GREATEST OF THE 80'S" }), "100 GREATEST OF THE 80'S"); - assert.doesNotMatch(radioGlobeLabel({ name: 'Movie Soundtracks Hits Radio @ 1.fm' }), /^1 FM/); + assert.equal( + radioGlobeLabel({ name: "80's New Wave Radio" }), + "80's New Wave Radio", + ); + assert.equal( + radioGlobeLabel({ name: "100 GREATEST OF THE 80'S" }), + "100 GREATEST OF THE 80'S", + ); + assert.doesNotMatch( + radioGlobeLabel({ name: 'Movie Soundtracks Hits Radio @ 1.fm' }), + /^1 FM/, + ); assert.equal( radioGlobeLabel({ name: ' La Zeta - 93.9 fm - XHHY ' }), '93.9 FM — La Zeta', ); assert.equal(radioGlobeLabel({ name: '93.9 FM ---' }), '93.9 FM'); - assert.equal(radioGlobeLabel({ name: 'Radio One 93.9 FM / 94.1 FM' }), '93.9 FM — Radio One'); - const truncated = radioGlobeLabel({ name: 'Christian Power Praise Dot Net Worldwide Service' }); + assert.equal( + radioGlobeLabel({ name: 'Radio One 93.9 FM / 94.1 FM' }), + '93.9 FM — Radio One', + ); + const truncated = radioGlobeLabel({ + name: 'Christian Power Praise Dot Net Worldwide Service', + }); assert.equal(truncated.length, 30); assert.ok(truncated.endsWith('…')); - const unicodeTruncated = radioGlobeLabel({ name: `${'a'.repeat(28)}📻 more` }); + const unicodeTruncated = radioGlobeLabel({ + name: `${'a'.repeat(28)}📻 more`, + }); assert.equal(unicodeTruncated, `${'a'.repeat(28)}📻…`); assert.doesNotMatch(unicodeTruncated, /�/); }); @@ -137,27 +176,45 @@ test('Radio ranks and caps cluster candidates before shared-host entry construct for (let index = 1; index < selected.length; index += 1) { assert.ok(selected[index - 1].stationCount >= selected[index].stationCount); } - assert.deepEqual(selectRadioClusterCandidates([ - { id: 'z', stationCount: 5 }, - { id: 'a', stationCount: 5 }, - ], 1).map(({ id }) => id), ['a']); + assert.deepEqual( + selectRadioClusterCandidates( + [ + { id: 'z', stationCount: 5 }, + { id: 'a', stationCount: 5 }, + ], + 1, + ).map(({ id }) => id), + ['a'], + ); }); test('Radio preserves one-to-one cluster identity across substantial membership churn', () => { const previous = [ - { id: 'stable:north', identityId: 'stable:north', stationIds: ['a', 'b', 'c', 'd'] }, - { id: 'stable:south', identityId: 'stable:south', stationIds: ['w', 'x', 'y', 'z'] }, + { + id: 'stable:north', + identityId: 'stable:north', + stationIds: ['a', 'b', 'c', 'd'], + }, + { + id: 'stable:south', + identityId: 'stable:south', + stationIds: ['w', 'x', 'y', 'z'], + }, ]; const generated = []; - const reconciled = reconcileRadioClusterCandidates([ - { id: 'b:e:4', stationIds: ['a', 'b', 'c', 'e'], text: '4 NEWS' }, - { id: 'new:weak:3', stationIds: ['d', 'm', 'n'], text: '3 OTHER' }, - { id: 'x:q:4', stationIds: ['w', 'x', 'y', 'q'], text: '4 MUSIC' }, - ], previous, (candidate) => { - const id = `new:${candidate.id}`; - generated.push(id); - return id; - }); + const reconciled = reconcileRadioClusterCandidates( + [ + { id: 'b:e:4', stationIds: ['a', 'b', 'c', 'e'], text: '4 NEWS' }, + { id: 'new:weak:3', stationIds: ['d', 'm', 'n'], text: '3 OTHER' }, + { id: 'x:q:4', stationIds: ['w', 'x', 'y', 'q'], text: '4 MUSIC' }, + ], + previous, + (candidate) => { + const id = `new:${candidate.id}`; + generated.push(id); + return id; + }, + ); assert.equal(reconciled[0].id, 'stable:north'); assert.equal(reconciled[0].text, '4 NEWS'); assert.equal(reconciled[2].id, 'stable:south'); @@ -173,31 +230,48 @@ test('directory refresh retains only identities whose represented stations still { id: 'stable:invalid', stationIds: ['x', 'y', 'z'] }, ]; const unchangedRefresh = retainRadioClusterIdentitiesForStations(previous, [ - { id: 'c' }, { id: 'a' }, { id: 'b' }, { id: 'x' }, { id: 'y' }, { id: 'z' }, + { id: 'c' }, + { id: 'a' }, + { id: 'b' }, + { id: 'x' }, + { id: 'y' }, + { id: 'z' }, ]); assert.deepEqual(unchangedRefresh, previous); const changedRefresh = retainRadioClusterIdentitiesForStations(previous, [ - { id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'x' }, { id: 'z' }, + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + { id: 'x' }, + { id: 'z' }, ]); - assert.deepEqual(changedRefresh.map(({ id }) => id), ['stable:unchanged']); + assert.deepEqual( + changedRefresh.map(({ id }) => id), + ['stable:unchanged'], + ); }); test('Radio cluster identity inheritance is deterministic for splits and disjoint replacements', () => { - const previous = [{ - id: 'stable:whole', - identityId: 'stable:whole', - stationIds: ['a', 'b', 'c', 'd', 'e', 'f'], - }]; - const reconciled = reconcileRadioClusterCandidates([ - { id: 'a:c:3', stationIds: ['a', 'b', 'c'] }, - { id: 'd:f:3', stationIds: ['d', 'e', 'f'] }, - { id: 'x:z:3', stationIds: ['x', 'y', 'z'] }, - ], previous, (candidate) => `new:${candidate.id}`); - assert.deepEqual(reconciled.map(({ id }) => id), [ - 'stable:whole', - 'new:d:f:3', - 'new:x:z:3', - ]); + const previous = [ + { + id: 'stable:whole', + identityId: 'stable:whole', + stationIds: ['a', 'b', 'c', 'd', 'e', 'f'], + }, + ]; + const reconciled = reconcileRadioClusterCandidates( + [ + { id: 'a:c:3', stationIds: ['a', 'b', 'c'] }, + { id: 'd:f:3', stationIds: ['d', 'e', 'f'] }, + { id: 'x:z:3', stationIds: ['x', 'y', 'z'] }, + ], + previous, + (candidate) => `new:${candidate.id}`, + ); + assert.deepEqual( + reconciled.map(({ id }) => id), + ['stable:whole', 'new:d:f:3', 'new:x:z:3'], + ); assert.equal(new Set(reconciled.map(({ id }) => id)).size, reconciled.length); }); @@ -208,38 +282,51 @@ test('Radio allocates fresh disjoint cluster identities in canonical membership ]; const reconcile = (input) => { let sequence = 0; - return reconcileRadioClusterCandidates(input, [], () => `stable:${++sequence}`); + return reconcileRadioClusterCandidates( + input, + [], + () => `stable:${++sequence}`, + ); }; - const identities = (result) => Object.fromEntries( - result.map(({ membershipId, identityId }) => [membershipId, identityId]), - ); + const identities = (result) => + Object.fromEntries( + result.map(({ membershipId, identityId }) => [membershipId, identityId]), + ); assert.deepEqual(identities(reconcile(current)), { 'z-membership': 'stable:2', 'a-membership': 'stable:1', }); - assert.deepEqual(identities(reconcile([...current].reverse())), identities(reconcile(current))); + assert.deepEqual( + identities(reconcile([...current].reverse())), + identities(reconcile(current)), + ); }); test('Radio split competition never falls through from a consumed majority to a historical minority', () => { const previous = [ - { id: 'stable:majority', stationIds: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'] }, + { + id: 'stable:majority', + stationIds: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], + }, { id: 'stable:minority', stationIds: ['x'] }, ]; const current = [ { id: 'strong-child', stationIds: ['a', 'b', 'c', 'd', 'e'] }, { id: 'later-child', stationIds: ['f', 'g', 'h', 'i', 'x'] }, ]; - const reconcile = (currentInput, previousInput) => reconcileRadioClusterCandidates( - currentInput, - previousInput, - (candidate) => `new:${candidate.id}`, - ); + const reconcile = (currentInput, previousInput) => + reconcileRadioClusterCandidates( + currentInput, + previousInput, + (candidate) => `new:${candidate.id}`, + ); const forward = reconcile(current, previous); const permuted = reconcile([...current].reverse(), [...previous].reverse()); - const identities = (result) => Object.fromEntries( - result.map(({ membershipId, identityId }) => [membershipId, identityId]), - ); + const identities = (result) => + Object.fromEntries( + result.map(({ membershipId, identityId }) => [membershipId, identityId]), + ); assert.deepEqual(identities(forward), { 'strong-child': 'stable:majority', @@ -250,21 +337,39 @@ test('Radio split competition never falls through from a consumed majority to a test('Radio split identities cannot migrate to weaker children to increase inherited count', () => { const previous = [ - { id: 'stable:alpha', stationIds: ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9'] }, + { + id: 'stable:alpha', + stationIds: ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9'], + }, { id: 'stable:beta', stationIds: ['b1', 'b2', 'b3', 'b4', 'b5'] }, ]; - const reconciled = reconcileRadioClusterCandidates([ - { - id: 'strong-tie', - stationIds: ['a1', 'a2', 'a3', 'a4', 'a5', 'b1', 'b2', 'b3', 'b4', 'b5'], - }, - { id: 'weaker-alpha-child', stationIds: ['a6', 'a7', 'a8', 'a9'] }, - ], previous, (candidate) => `new:${candidate.id}`); + const reconciled = reconcileRadioClusterCandidates( + [ + { + id: 'strong-tie', + stationIds: [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'b1', + 'b2', + 'b3', + 'b4', + 'b5', + ], + }, + { id: 'weaker-alpha-child', stationIds: ['a6', 'a7', 'a8', 'a9'] }, + ], + previous, + (candidate) => `new:${candidate.id}`, + ); - assert.deepEqual(reconciled.map(({ id }) => id), [ - 'stable:beta', - 'new:weaker-alpha-child', - ]); + assert.deepEqual( + reconciled.map(({ id }) => id), + ['stable:beta', 'new:weaker-alpha-child'], + ); }); test('Radio duplicate prior records cannot assign one stable identity twice', () => { @@ -276,23 +381,29 @@ test('Radio duplicate prior records cannot assign one stable identity twice', () { id: 'a-child', stationIds: ['a', 'b'] }, { id: 'z-child', stationIds: ['c', 'd'] }, ]; - const reconcile = (currentInput, previousInput) => reconcileRadioClusterCandidates( - currentInput, - previousInput, - (candidate) => `new:${candidate.id}`, - ); + const reconcile = (currentInput, previousInput) => + reconcileRadioClusterCandidates( + currentInput, + previousInput, + (candidate) => `new:${candidate.id}`, + ); const forward = reconcile(current, previous); const permuted = reconcile([...current].reverse(), [...previous].reverse()); - const identities = (result) => Object.fromEntries( - result.map(({ membershipId, identityId }) => [membershipId, identityId]), - ); + const identities = (result) => + Object.fromEntries( + result.map(({ membershipId, identityId }) => [membershipId, identityId]), + ); assert.deepEqual(identities(forward), { 'a-child': 'stable:duplicate', 'z-child': 'new:z-child', }); assert.deepEqual(identities(permuted), identities(forward)); - assert.equal(forward.filter(({ identityId }) => identityId === 'stable:duplicate').length, 1); + assert.equal( + forward.filter(({ identityId }) => identityId === 'stable:duplicate') + .length, + 1, + ); }); test('Radio cluster merges preserve the larger contributor over a fully retained minority', () => { @@ -300,32 +411,43 @@ test('Radio cluster merges preserve the larger contributor over a fully retained { id: 'stable:minority', stationIds: ['a', 'b'] }, { id: 'stable:majority', stationIds: ['c', 'd', 'e', 'f', 'g', 'h'] }, ]; - const [merged] = reconcileRadioClusterCandidates([ - { id: 'merged', stationIds: ['a', 'b', 'c', 'd', 'e'] }, - ], previous, () => 'new:merged'); + const [merged] = reconcileRadioClusterCandidates( + [{ id: 'merged', stationIds: ['a', 'b', 'c', 'd', 'e'] }], + previous, + () => 'new:merged', + ); assert.equal(merged.id, 'stable:majority'); }); test('Radio cluster inheritance never discards the greatest low-ratio contributor', () => { const previous = [ - { id: 'stable:four', stationIds: ['a', 'b', 'c', 'd', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w'] }, + { + id: 'stable:four', + stationIds: ['a', 'b', 'c', 'd', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w'], + }, { id: 'stable:two', stationIds: ['e', 'f'] }, ]; - const [merged] = reconcileRadioClusterCandidates([{ - id: 'merged', - stationIds: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], - }], previous, () => 'new:merged'); + const [merged] = reconcileRadioClusterCandidates( + [ + { + id: 'merged', + stationIds: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + }, + ], + previous, + () => 'new:merged', + ); assert.equal(merged.id, 'stable:four'); }); test('Radio cluster inheritance permits a unique one-member overlap', () => { - const [reconciled] = reconcileRadioClusterCandidates([ - { id: 'current', stationIds: ['a', 'x', 'y'] }, - ], [ - { id: 'stable:single-overlap', stationIds: ['a', 'b', 'c'] }, - ], () => 'new:current'); + const [reconciled] = reconcileRadioClusterCandidates( + [{ id: 'current', stationIds: ['a', 'x', 'y'] }], + [{ id: 'stable:single-overlap', stationIds: ['a', 'b', 'c'] }], + () => 'new:current', + ); assert.equal(reconciled.id, 'stable:single-overlap'); }); @@ -336,8 +458,16 @@ test('Radio cluster majority ties are deterministic across input permutations', { id: 'stable:alpha', stationIds: ['c', 'd', 'y'] }, ]; const current = [{ id: 'merged', stationIds: ['a', 'b', 'c', 'd'] }]; - const forward = reconcileRadioClusterCandidates(current, previous, () => 'new:merged'); - const reversed = reconcileRadioClusterCandidates(current, [...previous].reverse(), () => 'new:merged'); + const forward = reconcileRadioClusterCandidates( + current, + previous, + () => 'new:merged', + ); + const reversed = reconcileRadioClusterCandidates( + current, + [...previous].reverse(), + () => 'new:merged', + ); assert.equal(forward[0].id, 'stable:alpha'); assert.equal(reversed[0].id, 'stable:alpha'); @@ -358,8 +488,14 @@ test('active station tags own cluster labels even when overlapping tags have hig test('filters do not rewrite inputs and overlapping operational tags remain discoverable', () => { const before = structuredClone(stations); - assert.deepEqual(filterRadioStations(stations, 'public-safety').map((station) => station.id), ['safety']); - assert.equal(stationMatchesRadioCategory(stations[0], 'aviation-marine'), true); + assert.deepEqual( + filterRadioStations(stations, 'public-safety').map((station) => station.id), + ['safety'], + ); + assert.equal( + stationMatchesRadioCategory(stations[0], 'aviation-marine'), + true, + ); assert.deepEqual(stations, before); }); @@ -385,25 +521,45 @@ test('destroy and re-init reset Radio audio ownership, volume, filter, and telem audioInstances.push(this); } - addEventListener(type, listener) { this.listeners.set(type, listener); } + addEventListener(type, listener) { + this.listeners.set(type, listener); + } pause() {} - play() { return Promise.resolve(); } - removeAttribute(name) { if (name === 'src') this.srcRemoved = true; } + play() { + return Promise.resolve(); + } + removeAttribute(name) { + if (name === 'src') this.srcRemoved = true; + } load() {} }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; radioLayer.destroy(); try { - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); radioLayer.setLifecyclePresentation({ - lifecycleState: 'enabled', enabled: true, uncertain: false, + lifecycleState: 'enabled', + enabled: true, + uncertain: false, }); assert.equal(audioInstances.length, 1); assert.equal(setRadioVolume(0.25), true); @@ -425,12 +581,20 @@ test('destroy and re-init reset Radio audio ownership, volume, filter, and telem assert.equal(getRadioUIState().volume, 0.8); assert.equal(getRadioUIState().effectiveVolume, 0.8); - radioLayer.init(viewer); - assert.equal(audioInstances.length, 2, 're-init creates a clean Audio session'); + radioLayer.init(bindFakeCesiumCamera(viewer)); + assert.equal( + audioInstances.length, + 2, + 're-init creates a clean Audio session', + ); const nextState = getRadioUIState(); for (const eventName of ['playing', 'pause', 'waiting', 'error']) { oldAudio.listeners.get(eventName)?.(); - assert.deepEqual(getRadioUIState(), nextState, `discarded ${eventName} callback is inert`); + assert.deepEqual( + getRadioUIState(), + nextState, + `discarded ${eventName} callback is inert`, + ); } } finally { radioLayer.destroy(); @@ -453,14 +617,18 @@ test('late media errors after replacement or Pause cannot mutate the active stat audioInstances.push(this); } - addEventListener(type, listener) { this.listeners.set(type, listener); } + addEventListener(type, listener) { + this.listeners.set(type, listener); + } pause() {} play() { this.playCalls += 1; this.currentSrc = this.src; return new Promise(() => {}); } - removeAttribute(name) { if (name === 'src') this.src = ''; } + removeAttribute(name) { + if (name === 'src') this.src = ''; + } load() {} }; const stationRows = [ @@ -497,29 +665,42 @@ test('late media errors after replacement or Pause cannot mutate the active stat bitrate: 128, }, ]; - globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/') - ? { ok: true } - : { - ok: true, - json: async () => ({ - stations: stationRows, - updatedAt: new Date().toISOString(), - stale: false, - degraded: false, - acceptedGeneration: 1, - catalogInstance: 'qa-instance-a', - }), - }; + globalThis.fetch = async (url) => + String(url).startsWith('/api/radio/click/') + ? { ok: true } + : { + ok: true, + json: async () => ({ + stations: stationRows, + updatedAt: new Date().toISOString(), + stale: false, + degraded: false, + acceptedGeneration: 1, + catalogInstance: 'qa-instance-a', + }), + }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; radioLayer.destroy(); try { - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); await radioLayer.update(); radioLayer.setLifecyclePresentation({ @@ -528,18 +709,29 @@ test('late media errors after replacement or Pause cannot mutate the active stat uncertain: true, }); assert.equal(radioLayer.getUIState().presentationActive, false); - assert.equal(radioLayer.selectStation(stationRows[0].id, { - autoplay: true, - origin: 'voice', - }), false, 'uncertain lifecycle blocks direct station selection'); - assert.equal(radioLayer.cycleStation(1, { - stationIds: stationRows.map(({ id }) => id), - autoplay: false, - rotate: true, - }), false, 'uncertain lifecycle blocks cycling before camera or fallback mutation'); + assert.equal( + radioLayer.selectStation(stationRows[0].id, { + autoplay: true, + origin: 'voice', + }), + false, + 'uncertain lifecycle blocks direct station selection', + ); + assert.equal( + radioLayer.cycleStation(1, { + stationIds: stationRows.map(({ id }) => id), + autoplay: false, + rotate: true, + }), + false, + 'uncertain lifecycle blocks cycling before camera or fallback mutation', + ); assert.equal(await radioLayer.togglePlayback({ origin: 'user' }), false); assert.equal(radioLayer.getUIState().selected, null); - assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 0); + assert.equal( + audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), + 0, + ); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, @@ -561,28 +753,36 @@ test('late media errors after replacement or Pause cannot mutate the active stat const replacementAudio = audioInstances.at(-1); assert.notEqual(replacementAudio, primaryAudio); assert.equal(radioLayer.getUIState().selected?.id, stationRows[1].id); - assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 2); + assert.equal( + audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), + 2, + ); primaryAudio.currentSrc = ''; primaryAudio.listeners.get('error')?.(); assert.equal(radioLayer.getUIState().audioState, 'loading'); assert.equal(radioLayer.getUIState().selected?.id, stationRows[1].id); - assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 2); + assert.equal( + audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), + 2, + ); const controlEvents = []; - const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => { - const observed = radioLayer.getUIState(); - const cleanupResult = radioLayer.stopPlayback({ - origin: 'voice-cleanup', - attemptId: event.attemptId, - }); - controlEvents.push({ - action: event.action, - observedAudioState: observed.audioState, - observedStationId: observed.playingStationId, - cleanupResult, - }); - }); + const unsubscribeControls = radioLayer.subscribePlaybackControls( + (event) => { + const observed = radioLayer.getUIState(); + const cleanupResult = radioLayer.stopPlayback({ + origin: 'voice-cleanup', + attemptId: event.attemptId, + }); + controlEvents.push({ + action: event.action, + observedAudioState: observed.audioState, + observedStationId: observed.playingStationId, + cleanupResult, + }); + }, + ); assert.equal(radioLayer.pause({ origin: 'user' }), true); replacementAudio.listeners.get('error')?.(); await Promise.resolve(); @@ -592,12 +792,14 @@ test('late media errors after replacement or Pause cannot mutate the active stat assert.equal(state.selected?.id, stationRows[1].id); assert.equal(state.playingStationId, stationRows[1].id); assert.equal(replacementAudio.src, stationRows[1].streamUrl); - assert.deepEqual(controlEvents, [{ - action: 'pause', - observedAudioState: 'paused', - observedStationId: stationRows[1].id, - cleanupResult: false, - }]); + assert.deepEqual(controlEvents, [ + { + action: 'pause', + observedAudioState: 'paused', + observedStationId: stationRows[1].id, + cleanupResult: false, + }, + ]); void radioLayer.togglePlayback({ origin: 'user' }); await Promise.resolve(); @@ -613,7 +815,10 @@ test('late media errors after replacement or Pause cannot mutate the active stat assert.equal(radioLayer.getUIState().audioState, 'stopped'); assert.equal(resumedAudio.src, ''); unsubscribeControls(); - assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 3); + assert.equal( + audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), + 3, + ); } finally { radioLayer.destroy(); globalThis.fetch = originalFetch; @@ -643,21 +848,37 @@ test('unusable directory responses preserve warm client state atomically', async }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; radioLayer.destroy(); try { - radioLayer.init(viewer); - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [station], - stale: false, - degraded: false, - acceptedGeneration: 1, - catalogInstance: 'qa-instance-a', - updatedAt: now, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + radioLayer.init(bindFakeCesiumCamera(viewer)); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [station], + stale: false, + degraded: false, + acceptedGeneration: 1, + catalogInstance: 'qa-instance-a', + updatedAt: now, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); radioLayer.enable(); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1); @@ -665,13 +886,18 @@ test('unusable directory responses preserve warm client state atomically', async const acceptedSnapshot = getRadioAcceptedCatalogSnapshot(); assert.equal(acceptedSnapshot.generation, 1); assert.equal(acceptedSnapshot.stations[0].id, station.id); - assert.equal(acceptedSnapshot.stations[0].metadataTrust, 'untrusted-community'); + assert.equal( + acceptedSnapshot.stations[0].metadataTrust, + 'untrusted-community', + ); assert.equal(Object.isFrozen(acceptedSnapshot), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations[0]), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations[0].tags), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations[0].languages), true); - assert.throws(() => { acceptedSnapshot.stations[0].name = 'mutated'; }, TypeError); + assert.throws(() => { + acceptedSnapshot.stations[0].name = 'mutated'; + }, TypeError); radioLayer.selectStation(station.id, { autoplay: false, focus: false }); const replacement = { @@ -680,13 +906,26 @@ test('unusable directory responses preserve warm client state atomically', async name: 'Partial Replacement', }; for (const body of [ - { stations: [replacement], stale: false, degraded: true, acceptedGeneration: 1, updatedAt: now }, - { stations: [replacement], stale: true, degraded: true, acceptedGeneration: 1, updatedAt: now }, + { + stations: [replacement], + stale: false, + degraded: true, + acceptedGeneration: 1, + updatedAt: now, + }, + { + stations: [replacement], + stale: true, + degraded: true, + acceptedGeneration: 1, + updatedAt: now, + }, ]) { - globalThis.fetch = async () => new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + globalThis.fetch = async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); await radioLayer.update(); const preserved = getRadioUIState(); assert.equal(preserved.stationCount, 1); @@ -700,70 +939,119 @@ test('unusable directory responses preserve warm client state atomically', async for (const body of [ { stations: {} }, - { stations: [station, { id: 'malformed' }], stale: false, degraded: false, updatedAt: now }, - { stations: [station], stale: false, degraded: false, updatedAt: 'not-a-date' }, + { + stations: [station, { id: 'malformed' }], + stale: false, + degraded: false, + updatedAt: now, + }, + { + stations: [station], + stale: false, + degraded: false, + updatedAt: 'not-a-date', + }, { stations: [station], stale: 'false', degraded: false, updatedAt: now }, ]) { - globalThis.fetch = async () => new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + globalThis.fetch = async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().updatedAt, now); assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); } - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, - acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now, - })); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [replacement, { id: 'malformed' }], + stale: false, + degraded: false, + acceptedGeneration: 2, + catalogInstance: 'qa-instance-a', + updatedAt: now, + }), + ); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().rejectedStationCount, 1); assert.equal(getRadioUIState().degraded, true); assert.equal(getRadioUIState().stale, false); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); - assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + assert.equal( + getRadioAcceptedCatalogSnapshot().stations[0].id, + replacement.id, + ); // Server-side structural rejections are combined with client rejections // after admission; the healthy generation is still accepted. - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, - acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, - rejectedStationCount: 2, - })); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [replacement, { id: 'malformed' }], + stale: false, + degraded: false, + acceptedGeneration: 3, + catalogInstance: 'qa-instance-a', + updatedAt: now, + rejectedStationCount: 2, + }), + ); await radioLayer.update(); assert.equal(getRadioUIState().rejectedStationCount, 3); assert.equal(getRadioUIState().degraded, true); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3); - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [replacement], stale: false, degraded: false, - acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now, - rejectedStationCount: 10_000, - })); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [replacement], + stale: false, + degraded: false, + acceptedGeneration: 4, + catalogInstance: 'qa-instance-a', + updatedAt: now, + rejectedStationCount: 10_000, + }), + ); await radioLayer.update(); assert.equal(getRadioUIState().rejectedStationCount, 10_000); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) { - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [replacement], stale: false, degraded: false, - acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, - rejectedStationCount, - })); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [replacement], + stale: false, + degraded: false, + acceptedGeneration: 5, + catalogInstance: 'qa-instance-a', + updatedAt: now, + rejectedStationCount, + }), + ); await radioLayer.update(); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); assert.equal(getRadioUIState().stale, true); } - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [{ id: 'malformed' }], stale: false, degraded: false, - acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, - })); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [{ id: 'malformed' }], + stale: false, + degraded: false, + acceptedGeneration: 5, + catalogInstance: 'qa-instance-a', + updatedAt: now, + }), + ); await radioLayer.update(); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().rejectedStationCount, 10_000); radioLayer.destroy(); - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); assert.equal(getRadioUIState().rejectedStationCount, 0); assert.equal(getRadioUIState().degraded, false); } finally { @@ -776,9 +1064,21 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele const originalFetch = globalThis.fetch; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; const stationA = { id: '00000000-0000-4000-8000-000000000041', @@ -807,30 +1107,41 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele name: 'Generation C', }; const publish = async (stationOrStations, acceptedGeneration) => { - const stations = Array.isArray(stationOrStations) ? stationOrStations : [stationOrStations]; - globalThis.fetch = async () => new Response(JSON.stringify({ - stations, - stale: false, - degraded: false, - acceptedGeneration, - catalogInstance: 'qa-instance-a', - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const stations = Array.isArray(stationOrStations) + ? stationOrStations + : [stationOrStations]; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations, + stale: false, + degraded: false, + acceptedGeneration, + catalogInstance: 'qa-instance-a', + updatedAt: new Date().toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); await radioLayer.update(); }; radioLayer.destroy(); try { - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); await publish(stationA, 1); assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationA.id, { rotate: false }), true); + assert.equal( + radioLayer.previewTuningStation(stationA.id, { rotate: false }), + true, + ); assert.equal(getRadioUIState().tuningCatalogGeneration, 1); await publish(stationB, 2); assert.equal(getRadioUIState().tuningPreviewStationId, stationA.id); - const changed = radioLayer.commitTuningStation(stationA.id, { origin: 'user' }); + const changed = radioLayer.commitTuningStation(stationA.id, { + origin: 'user', + }); assert.deepEqual(changed, { ok: false, reason: 'station-unavailable', @@ -841,39 +1152,65 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele assert.equal(getRadioUIState().tuningUnavailableStationId, stationA.id); assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); + assert.equal( + radioLayer.previewTuningStation(stationB.id, { rotate: false }), + true, + ); await publish(stationB, 3); - const unchanged = radioLayer.commitTuningStation(stationB.id, { origin: 'user' }); + const unchanged = radioLayer.commitTuningStation(stationB.id, { + origin: 'user', + }); assert.equal(unchanged.ok, true); assert.equal(unchanged.generation, 2); assert.equal(getRadioUIState().selected?.name, 'Generation B'); assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); + assert.equal( + radioLayer.previewTuningStation(stationB.id, { rotate: false }), + true, + ); await publish(stationC, 4); - const removed = radioLayer.commitTuningStation(stationB.id, { origin: 'user' }); + const removed = radioLayer.commitTuningStation(stationB.id, { + origin: 'user', + }); assert.equal(removed.ok, false); assert.equal(removed.reason, 'station-unavailable'); assert.equal(removed.stationId, stationB.id); assert.notEqual(getRadioUIState().selected?.id, stationC.id); await publish([stationA, stationC], 5); - assert.equal(radioLayer.selectStation(stationA.id, { autoplay: false, focus: false }), true); + assert.equal( + radioLayer.selectStation(stationA.id, { autoplay: false, focus: false }), + true, + ); assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationC.id, { rotate: false }), true); + assert.equal( + radioLayer.previewTuningStation(stationC.id, { rotate: false }), + true, + ); await publish([stationA, { ...stationC, name: 'Changed target' }], 6); - const priorSelectionMismatch = radioLayer.commitTuningStation(stationC.id, { origin: 'user' }); + const priorSelectionMismatch = radioLayer.commitTuningStation(stationC.id, { + origin: 'user', + }); assert.equal(priorSelectionMismatch.ok, false); assert.equal(priorSelectionMismatch.reason, 'station-unavailable'); assert.equal(getRadioUIState().selected, null); assert.equal(getRadioUIState().tuningUnavailableStationId, stationC.id); await publish([stationB, stationC], 7); - assert.equal(radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), true); + assert.equal( + radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), + true, + ); assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); + assert.equal( + radioLayer.previewTuningStation(stationB.id, { rotate: false }), + true, + ); await publish([{ ...stationB, name: 'Same ID replacement' }, stationC], 8); - const sameIdMismatch = radioLayer.commitTuningStation(stationB.id, { origin: 'user' }); + const sameIdMismatch = radioLayer.commitTuningStation(stationB.id, { + origin: 'user', + }); assert.equal(sameIdMismatch.ok, false); assert.equal(sameIdMismatch.reason, 'station-unavailable'); assert.equal(getRadioUIState().selected, null); @@ -899,30 +1236,47 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn streamUrl: 'https://radio.example.com/fallback.mp3', }), ]; - await withRadioLayer({ - stations: stationRows, - failingUrls: [stationRows[0].streamUrl], - }, async ({ playUrls }) => { - // A playing stream, so the release plays. - assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true); - await new Promise((resolve) => setTimeout(resolve)); - assert.equal(radioLayer.cycleStation(1, { - stationIds: stationRows.map(({ id }) => id), - autoplay: false, - fallback: true, - }), true, 'a voice-style prepared cycle creates the stale-fallback precondition'); - playUrls.length = 0; - assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true); - assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true); - await new Promise((resolve) => setTimeout(resolve)); - const state = radioLayer.getUIState(); - assert.deepEqual(playUrls, [stationRows[0].streamUrl]); - assert.equal(state.selected?.id, stationRows[0].id); - assert.equal(state.audioState, 'error'); - assert.equal(state.tuningStatic, true); - assert.equal(state.tuningAwaitingStationId, stationRows[0].id); - }); + await withRadioLayer( + { + stations: stationRows, + failingUrls: [stationRows[0].streamUrl], + }, + async ({ playUrls }) => { + // A playing stream, so the release plays. + assert.equal( + radioLayer.selectStation(stationRows[1].id, { autoplay: true }), + true, + ); + await new Promise((resolve) => setTimeout(resolve)); + assert.equal( + radioLayer.cycleStation(1, { + stationIds: stationRows.map(({ id }) => id), + autoplay: false, + fallback: true, + }), + true, + 'a voice-style prepared cycle creates the stale-fallback precondition', + ); + playUrls.length = 0; + assert.equal(radioLayer.beginTuning(), true); + assert.equal( + radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), + true, + ); + assert.equal( + radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }) + .ok, + true, + ); + await new Promise((resolve) => setTimeout(resolve)); + const state = radioLayer.getUIState(); + assert.deepEqual(playUrls, [stationRows[0].streamUrl]); + assert.equal(state.selected?.id, stationRows[0].id); + assert.equal(state.audioState, 'error'); + assert.equal(state.tuningStatic, true); + assert.equal(state.tuningAwaitingStationId, stationRows[0].id); + }, + ); }); test('tuner cancellation restores the frozen start marker after catalog removal', async () => { @@ -938,23 +1292,38 @@ test('tuner cancellation restores the frozen start marker after catalog removal' addEventListener() {} pause() {} - play() { playCalls += 1; return Promise.resolve(); } + play() { + playCalls += 1; + return Promise.resolve(); + } removeAttribute() {} load() {} }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 }, - flyTo() { flyToCalls += 1; }, + flyTo() { + flyToCalls += 1; + }, }, scene: { - canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} }, + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, requestRender() {}, }, dataSources: { add() {}, remove() {} }, entities: { - add(entity) { selectedEntities.set(entity.id, entity); return entity; }, - remove(entity) { return selectedEntities.delete(entity?.id); }, + add(entity) { + selectedEntities.set(entity.id, entity); + return entity; + }, + remove(entity) { + return selectedEntities.delete(entity?.id); + }, }, }; const stationA = { @@ -982,43 +1351,67 @@ test('tuner cancellation restores the frozen start marker after catalog removal' streamUrl: 'https://radio.example.com/preview.mp3', }; const publish = async (rows, acceptedGeneration) => { - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: rows, - stale: false, - degraded: false, - acceptedGeneration, - catalogInstance: 'qa-instance-a', - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: rows, + stale: false, + degraded: false, + acceptedGeneration, + catalogInstance: 'qa-instance-a', + updatedAt: new Date().toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); await radioLayer.update(); }; radioLayer.destroy(); try { - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); radioLayer.setLifecyclePresentation({ - lifecycleState: 'enabled', enabled: true, uncertain: false, + lifecycleState: 'enabled', + enabled: true, + uncertain: false, }); let nextGeneration = 1; const restoreRemovedStartMarker = async () => { await publish([stationA, stationB], nextGeneration); nextGeneration += 1; - assert.equal(radioLayer.selectStation(stationA.id, { autoplay: false, focus: false }), true); + assert.equal( + radioLayer.selectStation(stationA.id, { + autoplay: false, + focus: false, + }), + true, + ); const startMarker = selectedEntities.get(`radio:selected:${stationA.id}`); assert.ok(startMarker); assert.equal(radioLayer.beginTuning(), true); - assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); + assert.equal( + radioLayer.previewTuningStation(stationB.id, { rotate: false }), + true, + ); await publish([stationB], nextGeneration); nextGeneration += 1; assert.equal(getRadioUIState().selected, null); assert.equal(radioLayer.cancelTuning(), true); - const restoredMarker = selectedEntities.get(`radio:selected:${stationA.id}`); - assert.ok(restoredMarker, 'the frozen start marker remains visible after cancellation'); + const restoredMarker = selectedEntities.get( + `radio:selected:${stationA.id}`, + ); + assert.ok( + restoredMarker, + 'the frozen start marker remains visible after cancellation', + ); assert.deepEqual(restoredMarker.position, startMarker.position); assert.equal(getRadioUIState().tuningRestoredStationId, stationA.id); assert.equal(getRadioUIState().tuningActive, false); - assert.equal(getRadioUIState().selected, null, 'stale marker never becomes playback authority'); + assert.equal( + getRadioUIState().selected, + null, + 'stale marker never becomes playback authority', + ); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); return radioLayer.getAcceptedCatalogSnapshot(); @@ -1040,7 +1433,10 @@ test('tuner cancellation restores the frozen start marker after catalog removal' assert.equal(getRadioUIState().filter, 'news'); assert.equal(getRadioUIState().tuningRestoredStationId, null); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); - assert.equal(radioLayer.getAcceptedCatalogSnapshot(), changedFilterSnapshot); + assert.equal( + radioLayer.getAcceptedCatalogSnapshot(), + changedFilterSnapshot, + ); assert.equal(getRadioUIState().selected, null); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); @@ -1053,11 +1449,17 @@ test('tuner cancellation restores the frozen start marker after catalog removal' 'the next accepted equal-generation refresh clears the presentation-only marker', ); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); - assert.equal(radioLayer.getAcceptedCatalogSnapshot(), equalGenerationSnapshot); + assert.equal( + radioLayer.getAcceptedCatalogSnapshot(), + equalGenerationSnapshot, + ); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); - assert.equal(radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), true); + assert.equal( + radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), + true, + ); assert.equal(getRadioUIState().tuningRestoredStationId, null); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); } finally { @@ -1070,45 +1472,65 @@ test('tuner cancellation restores the frozen start marker after catalog removal' test('Radio public tuner, filter, and resume mutations require certain enabled lifecycle authority', async () => { const originalFetch = globalThis.fetch; - const stationRows = [{ - id: '00000000-0000-4000-8000-000000000061', - name: 'Lifecycle station', - lat: 30, - lon: -97, - streamUrl: 'https://radio.example.com/lifecycle.mp3', - homepage: null, - tags: ['news'], - languages: ['English'], - state: 'Texas', - country: 'United States', - countryCode: 'US', - metadataTrust: 'untrusted-community', - codec: 'MP3', - bitrate: 128, - }]; - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: stationRows, - stale: false, - degraded: false, - acceptedGeneration: 1, - catalogInstance: 'qa-instance-a', - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const stationRows = [ + { + id: '00000000-0000-4000-8000-000000000061', + name: 'Lifecycle station', + lat: 30, + lon: -97, + streamUrl: 'https://radio.example.com/lifecycle.mp3', + homepage: null, + tags: ['news'], + languages: ['English'], + state: 'Texas', + country: 'United States', + countryCode: 'US', + metadataTrust: 'untrusted-community', + codec: 'MP3', + bitrate: 128, + }, + ]; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: stationRows, + stale: false, + degraded: false, + acceptedGeneration: 1, + catalogInstance: 'qa-instance-a', + updatedAt: new Date().toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; viewer.scene.requestRender = () => {}; radioLayer.destroy(); try { - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); await radioLayer.update(); radioLayer.setLifecyclePresentation({ - lifecycleState: 'enabled', enabled: true, uncertain: false, + lifecycleState: 'enabled', + enabled: true, + uncertain: false, }); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.setTuningStatic(false), true); @@ -1121,18 +1543,36 @@ test('Radio public tuner, filter, and resume mutations require certain enabled l ]) { radioLayer.setLifecyclePresentation(presentation); const before = getRadioUIState(); - assert.equal(radioLayer.setTuningStatic(true), false, presentation.lifecycleState); - assert.equal(radioLayer.setFilter('news'), false, presentation.lifecycleState); - assert.equal(await radioLayer.togglePlayback({ origin: 'user' }), false, presentation.lifecycleState); + assert.equal( + radioLayer.setTuningStatic(true), + false, + presentation.lifecycleState, + ); + assert.equal( + radioLayer.setFilter('news'), + false, + presentation.lifecycleState, + ); + assert.equal( + await radioLayer.togglePlayback({ origin: 'user' }), + false, + presentation.lifecycleState, + ); const after = getRadioUIState(); - assert.equal(after.tuningStatic, before.tuningStatic, presentation.lifecycleState); + assert.equal( + after.tuningStatic, + before.tuningStatic, + presentation.lifecycleState, + ); assert.equal(after.filter, before.filter, presentation.lifecycleState); assert.equal(after.selected, null, presentation.lifecycleState); assert.equal(after.audioState, 'stopped', presentation.lifecycleState); } radioLayer.setLifecyclePresentation({ - lifecycleState: 'enabled', enabled: true, uncertain: false, + lifecycleState: 'enabled', + enabled: true, + uncertain: false, }); assert.equal(radioLayer.setTuningStatic(true), true); assert.equal(getRadioUIState().tuningStatic, true); @@ -1149,9 +1589,21 @@ test('tuner refuses a degraded fallback that has no accepted catalog generation' const originalFetch = globalThis.fetch; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; const station = { id: '00000000-0000-4000-8000-000000000043', @@ -1172,18 +1624,26 @@ test('tuner refuses a degraded fallback that has no accepted catalog generation' radioLayer.destroy(); try { - globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [station], - stale: false, - degraded: true, - acceptedGeneration: null, - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - radioLayer.init(viewer); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations: [station], + stale: false, + degraded: true, + acceptedGeneration: null, + updatedAt: new Date().toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); await radioLayer.update(); - assert.equal(getRadioUIState().stationCount, 1, 'fallback remains visible outside the tuner'); + assert.equal( + getRadioUIState().stationCount, + 1, + 'fallback remains visible outside the tuner', + ); assert.equal(getRadioUIState().acceptedCatalogGeneration, null); assert.deepEqual(radioLayer.getTunerStations(), []); assert.equal(radioLayer.beginTuning(), false); @@ -1217,24 +1677,44 @@ test('accepted snapshots allowlist station fields and never regress generation i }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; - const serve = (stations, acceptedGeneration, catalogInstance = 'qa-instance-a') => { - globalThis.fetch = async () => new Response(JSON.stringify({ - stations, - stale: false, - degraded: false, - acceptedGeneration, - catalogInstance, - updatedAt: now, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const serve = ( + stations, + acceptedGeneration, + catalogInstance = 'qa-instance-a', + ) => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + stations, + stale: false, + degraded: false, + acceptedGeneration, + catalogInstance, + updatedAt: now, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); }; radioLayer.destroy(); try { - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); serve([station], 1); radioLayer.enable(); await radioLayer.update(); @@ -1318,9 +1798,21 @@ test('update abort, destroy, and re-init invalidate the prior session and reset let requestSignal = null; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, }; const lateStation = { id: '00000000-0000-4000-8000-000000000099', @@ -1343,9 +1835,11 @@ test('update abort, destroy, and re-init invalidate the prior session and reset try { globalThis.fetch = (_url, options) => { requestSignal = options.signal; - return new Promise((resolve) => { resolveFetch = resolve; }); + return new Promise((resolve) => { + resolveFetch = resolve; + }); }; - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); radioLayer.enable(); const pending = radioLayer.update(); assert.equal(getRadioUIState().loading, true); @@ -1354,47 +1848,59 @@ test('update abort, destroy, and re-init invalidate the prior session and reset assert.equal(getRadioUIState().loading, false); radioLayer.destroy(); - radioLayer.init(viewer); + radioLayer.init(bindFakeCesiumCamera(viewer)); const cleanState = getRadioUIState(); - assert.deepEqual({ - enabled: cleanState.enabled, - loading: cleanState.loading, - stale: cleanState.stale, - degraded: cleanState.degraded, - error: cleanState.error, - updatedAt: cleanState.updatedAt, - stationCount: cleanState.stationCount, - selected: cleanState.selected, - audioState: cleanState.audioState, - voiceDucked: cleanState.voiceDucked, - voiceRestoring: cleanState.voiceRestoring, - acceptedCatalogGeneration: cleanState.acceptedCatalogGeneration, - }, { - enabled: false, - loading: false, - stale: false, - degraded: false, - error: null, - updatedAt: null, - stationCount: 0, - selected: null, - audioState: 'stopped', - voiceDucked: false, - voiceRestoring: false, - acceptedCatalogGeneration: null, - }); + assert.deepEqual( + { + enabled: cleanState.enabled, + loading: cleanState.loading, + stale: cleanState.stale, + degraded: cleanState.degraded, + error: cleanState.error, + updatedAt: cleanState.updatedAt, + stationCount: cleanState.stationCount, + selected: cleanState.selected, + audioState: cleanState.audioState, + voiceDucked: cleanState.voiceDucked, + voiceRestoring: cleanState.voiceRestoring, + acceptedCatalogGeneration: cleanState.acceptedCatalogGeneration, + }, + { + enabled: false, + loading: false, + stale: false, + degraded: false, + error: null, + updatedAt: null, + stationCount: 0, + selected: null, + audioState: 'stopped', + voiceDucked: false, + voiceRestoring: false, + acceptedCatalogGeneration: null, + }, + ); assert.equal(getRadioAcceptedCatalogSnapshot().generation, null); - resolveFetch(new Response(JSON.stringify({ - stations: [lateStation], - stale: false, - degraded: false, - acceptedGeneration: 99, - catalogInstance: 'qa-instance-a', - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + resolveFetch( + new Response( + JSON.stringify({ + stations: [lateStation], + stale: false, + degraded: false, + acceptedGeneration: 99, + catalogInstance: 'qa-instance-a', + updatedAt: new Date().toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); await pending; - assert.deepEqual(getRadioUIState(), cleanState, 'retired update completion is inert after re-init'); + assert.deepEqual( + getRadioUIState(), + cleanState, + 'retired update completion is inert after re-init', + ); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); @@ -1407,48 +1913,107 @@ test('All is the initial Radio filter', () => { }); test('local viewport ranking chooses geographic distance without a language override', () => { - const ranked = rankRadioStationsForViewport([ - { id: 'english-far', lat: 30.5, lon: -97.7, languages: ['English'] }, - { id: 'spanish-near', lat: 30.2673, lon: -97.7430, languages: ['Spanish'] }, - { id: 'english-near', lat: 30.28, lon: -97.74, languages: ['en'] }, - ], { lat: 30.2672, lon: -97.7431 }); - assert.deepEqual(ranked.map((station) => station.id), ['spanish-near', 'english-near', 'english-far']); + const ranked = rankRadioStationsForViewport( + [ + { id: 'english-far', lat: 30.5, lon: -97.7, languages: ['English'] }, + { + id: 'spanish-near', + lat: 30.2673, + lon: -97.743, + languages: ['Spanish'], + }, + { id: 'english-near', lat: 30.28, lon: -97.74, languages: ['en'] }, + ], + { lat: 30.2672, lon: -97.7431 }, + ); + assert.deepEqual( + ranked.map((station) => station.id), + ['spanish-near', 'english-near', 'english-far'], + ); }); test('global viewport ranking prefers English stations, then distance', () => { - const ranked = rankRadioStationsForViewport([ - { id: 'spanish-nearest', lat: 0, lon: 0, languages: ['Spanish'] }, - { id: 'english-far', lat: 15, lon: 0, languages: ['English'] }, - { id: 'english-near', lat: 5, lon: 0, languages: ['eng'] }, - ], { lat: 0, lon: 0 }, { preferEnglish: true }); - assert.deepEqual(ranked.map((station) => station.id), ['english-near', 'english-far', 'spanish-nearest']); + const ranked = rankRadioStationsForViewport( + [ + { id: 'spanish-nearest', lat: 0, lon: 0, languages: ['Spanish'] }, + { id: 'english-far', lat: 15, lon: 0, languages: ['English'] }, + { id: 'english-near', lat: 5, lon: 0, languages: ['eng'] }, + ], + { lat: 0, lon: 0 }, + { preferEnglish: true }, + ); + assert.deepEqual( + ranked.map((station) => station.id), + ['english-near', 'english-far', 'spanish-nearest'], + ); assert.equal(isEnglishRadioStation(ranked[0]), true); }); test('explicit Radio requests combine category, country, station name, and distance', () => { const rows = [ - { id: 'music-austin', name: 'Austin Music', tags: ['music'], countryCode: 'US', country: 'United States', lat: 30.26, lon: -97.74 }, - { id: 'news-dallas', name: 'Texas News', tags: ['news'], countryCode: 'US', country: 'United States', lat: 32.77, lon: -96.79 }, - { id: 'news-austin', name: 'Austin Public News', tags: ['news'], countryCode: 'US', country: 'United States', lat: 30.27, lon: -97.75 }, - { id: 'news-mexico', name: 'Noticias', tags: ['news'], countryCode: 'MX', country: 'Mexico', lat: 25.68, lon: -100.31 }, + { + id: 'music-austin', + name: 'Austin Music', + tags: ['music'], + countryCode: 'US', + country: 'United States', + lat: 30.26, + lon: -97.74, + }, + { + id: 'news-dallas', + name: 'Texas News', + tags: ['news'], + countryCode: 'US', + country: 'United States', + lat: 32.77, + lon: -96.79, + }, + { + id: 'news-austin', + name: 'Austin Public News', + tags: ['news'], + countryCode: 'US', + country: 'United States', + lat: 30.27, + lon: -97.75, + }, + { + id: 'news-mexico', + name: 'Noticias', + tags: ['news'], + countryCode: 'MX', + country: 'Mexico', + lat: 25.68, + lon: -100.31, + }, ]; const ranked = rankRadioStationsForRequest(rows, { categoryId: 'news', country: 'US', anchor: { lat: 30.2672, lon: -97.7431 }, }); - assert.deepEqual(ranked.map((station) => station.id), ['news-austin', 'news-dallas']); - assert.equal(rankRadioStationsForRequest(rows, { - categoryId: 'news', - stationQuery: 'public', - })[0].id, 'news-austin'); + assert.deepEqual( + ranked.map((station) => station.id), + ['news-austin', 'news-dallas'], + ); + assert.equal( + rankRadioStationsForRequest(rows, { + categoryId: 'news', + stationQuery: 'public', + })[0].id, + 'news-austin', + ); }); test('viewport ranking treats the antimeridian as adjacent', () => { - const ranked = rankRadioStationsForViewport([ - { id: 'far', lat: 0, lon: 150, languages: ['English'] }, - { id: 'across-dateline', lat: 0, lon: -179.9, languages: ['English'] }, - ], { lat: 0, lon: 179.9 }); + const ranked = rankRadioStationsForViewport( + [ + { id: 'far', lat: 0, lon: 150, languages: ['English'] }, + { id: 'across-dateline', lat: 0, lon: -179.9, languages: ['English'] }, + ], + { lat: 0, lon: 179.9 }, + ); assert.equal(ranked[0].id, 'across-dateline'); }); @@ -1459,10 +2024,26 @@ test('global-view threshold tolerates Cesium altitude round-off without widening test('Radio picks resolve ordinary, selected, primitive, and clustered entity ids', () => { assert.equal(radioStationIdFromPick({ id: 'radio:alpha' }), 'alpha'); - assert.equal(radioStationIdFromPick({ primitive: { id: 'radio:beta' } }), 'beta'); - assert.equal(radioStationIdFromPick({ id: { id: 'radio:selected:gamma' } }), 'gamma'); - assert.equal(radioStationIdFromPick({ id: [{ id: 'other' }, { id: 'radio:delta' }] }), 'delta'); - assert.equal(radioStationIdFromPick({ primitive: { id: [{ id: 'radio:cluster-first' }, { id: 'radio:cluster-second' }] } }), 'cluster-first'); + assert.equal( + radioStationIdFromPick({ primitive: { id: 'radio:beta' } }), + 'beta', + ); + assert.equal( + radioStationIdFromPick({ id: { id: 'radio:selected:gamma' } }), + 'gamma', + ); + assert.equal( + radioStationIdFromPick({ id: [{ id: 'other' }, { id: 'radio:delta' }] }), + 'delta', + ); + assert.equal( + radioStationIdFromPick({ + primitive: { + id: [{ id: 'radio:cluster-first' }, { id: 'radio:cluster-second' }], + }, + }), + 'cluster-first', + ); assert.equal(radioStationIdFromPick({ id: 'flights:abc' }), null); }); @@ -1476,11 +2057,14 @@ test('selected Radio station bracket is a transparent four-corner category-color test('Radio text uses protected selected and bounded ambient WorldOverlay entries', () => { const position = { x: 1, y: 2, z: 3 }; - const selected = createRadioSelectedOverlayEntry({ - id: 'station-a', - name: 'Austin News 93.9 FM - KQA', - tags: ['news'], - }, position); + const selected = createRadioSelectedOverlayEntry( + { + id: 'station-a', + name: 'Austin News 93.9 FM - KQA', + tags: ['news'], + }, + position, + ); assert.equal(selected.variant, 'selected'); assert.equal(selected.selected, true); assert.equal(selected.protected, true); @@ -1501,16 +2085,26 @@ test('Radio text uses protected selected and bounded ambient WorldOverlay entrie assert.equal(cluster.collisionGroup, 'ambient-label'); assert.equal(cluster.horizonCull, true); assert.equal(cluster.maxDistance, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); - assert.equal(cluster.distanceScale.far, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); + assert.equal( + cluster.distanceScale.far, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ); assert.equal(cluster.title, '12 NEWS'); assert.equal(cluster.priority, 12); assert.equal(cluster.stateless, true); assert.equal(cluster.edgeFade, 'none'); - assert.equal(RADIO_OVERLAY_SOURCE_OPTIONS.cohortLimit, RADIO_OVERLAY_COHORT_LIMIT); + assert.equal( + RADIO_OVERLAY_SOURCE_OPTIONS.cohortLimit, + RADIO_OVERLAY_COHORT_LIMIT, + ); assert.ok(RADIO_OVERLAY_COHORT_LIMIT < 750); const singleton = createRadioSingletonOverlayEntry({ - station: { id: 'station-b', name: '100.3 The River - WQRV', tags: ['public safety'] }, + station: { + id: 'station-b', + name: '100.3 The River - WQRV', + tags: ['public safety'], + }, position, priority: 1.5, }); @@ -1521,63 +2115,106 @@ test('Radio text uses protected selected and bounded ambient WorldOverlay entrie assert.equal(singleton.interactive, false); assert.equal(singleton.horizonCull, true); assert.equal(singleton.maxDistance, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); - assert.equal(singleton.distanceScale.far, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); + assert.equal( + singleton.distanceScale.far, + RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, + ); assert.equal(singleton.priority, 1.5); assert.equal(singleton.stateless, true); }); test('Radio singleton labels are zoom-aware, nearest-first, stable, and bounded', () => { - assert.equal(radioSingletonLabelLimit(25_000_000), RADIO_SINGLETON_GLOBAL_LIMIT); - assert.equal(radioSingletonLabelLimit(2_000_000), RADIO_SINGLETON_GLOBAL_LIMIT); + assert.equal( + radioSingletonLabelLimit(25_000_000), + RADIO_SINGLETON_GLOBAL_LIMIT, + ); + assert.equal( + radioSingletonLabelLimit(2_000_000), + RADIO_SINGLETON_GLOBAL_LIMIT, + ); assert.equal(radioSingletonLabelLimit(1_999_999), RADIO_SINGLETON_MID_LIMIT); assert.equal(radioSingletonLabelLimit(250_000), RADIO_SINGLETON_MID_LIMIT); assert.equal(radioSingletonLabelLimit(249_999), RADIO_SINGLETON_NEAR_LIMIT); - const ranked = selectRadioSingletonCandidates([ - { station: { id: 'far' }, distanceM: 30 }, - { station: { id: 'near-z' }, distanceM: 10 }, - { station: { id: 'near-a' }, distanceM: 10 }, - ], 2); - assert.deepEqual(ranked.map(({ station }) => station.id), ['near-a', 'near-z']); + const ranked = selectRadioSingletonCandidates( + [ + { station: { id: 'far' }, distanceM: 30 }, + { station: { id: 'near-z' }, distanceM: 10 }, + { station: { id: 'near-a' }, distanceM: 10 }, + ], + 2, + ); + assert.deepEqual( + ranked.map(({ station }) => station.id), + ['near-a', 'near-z'], + ); }); test('directory tuner maps every absolute slot to an available station', () => { assert.deepEqual(radioTunerSlot(0, 4), { - slot: 0, max: 3, locked: true, stationIndex: 0, leftIndex: 0, rightIndex: 0, + slot: 0, + max: 3, + locked: true, + stationIndex: 0, + leftIndex: 0, + rightIndex: 0, }); assert.deepEqual(radioTunerSlot(1, 4), { - slot: 1, max: 3, locked: true, stationIndex: 1, leftIndex: 1, rightIndex: 1, + slot: 1, + max: 3, + locked: true, + stationIndex: 1, + leftIndex: 1, + rightIndex: 1, }); assert.deepEqual(radioTunerSlot(99, 4), { - slot: 3, max: 3, locked: true, stationIndex: 3, leftIndex: 3, rightIndex: 3, + slot: 3, + max: 3, + locked: true, + stationIndex: 3, + leftIndex: 3, + rightIndex: 3, }); assert.equal(radioTunerSlot(0, 0).locked, false); }); test('directory tuner pointer progress maps left, center, right, and midpoint ties exactly', () => { assert.deepEqual(radioTunerPointerPosition(7, 0, 114, 750), { - ratio: 0, coordinate: 0, stationIndex: 0, + ratio: 0, + coordinate: 0, + stationIndex: 0, }); assert.deepEqual(radioTunerPointerPosition(57, 0, 114, 750), { - ratio: 0.5, coordinate: 374.5, stationIndex: 375, + ratio: 0.5, + coordinate: 374.5, + stationIndex: 375, }); assert.deepEqual(radioTunerPointerPosition(107, 0, 114, 750), { - ratio: 1, coordinate: 749, stationIndex: 749, + ratio: 1, + coordinate: 749, + stationIndex: 749, }); assert.deepEqual(radioTunerPointerPosition(50, 10, 80, 1), { - ratio: 0.5, coordinate: 0, stationIndex: 0, + ratio: 0.5, + coordinate: 0, + stationIndex: 0, }); assert.equal(radioTunerPointerPosition(50, 10, 80, 0).stationIndex, -1); assert.deepEqual(radioTunerCommitSlot(2.5, 5), radioTunerSlot(3, 5)); }); test('directory tuner bands preserve stable filtered order without injecting outside selections', () => { - const ranked = Array.from({ length: 800 }, (_, index) => ({ id: `station-${index}` })); + const ranked = Array.from({ length: 800 }, (_, index) => ({ + id: `station-${index}`, + })); const selected = { id: 'selected-outside-band' }; const band = buildRadioTunerBand(ranked, selected, 750); assert.equal(band.length, 750); assert.deepEqual(band, ranked.slice(0, 750)); assert.ok(!band.includes(selected)); - assert.deepEqual(buildRadioTunerBand(ranked, ranked[5], 24), ranked.slice(0, 24)); + assert.deepEqual( + buildRadioTunerBand(ranked, ranked[5], 24), + ranked.slice(0, 24), + ); }); test('directory tuner virtual tape is bounded and moves opposite the needle', () => { @@ -1585,12 +2222,21 @@ test('directory tuner virtual tape is bounded and moves opposite the needle', () const at101 = buildRadioTunerTicks(101, 750, 300); assert.ok(at100.ticks.length <= Math.ceil(286 / at100.pitchPx) + 6); assert.ok(at101.needleX > at100.needleX); - const sharedIndex = at100.ticks.find((tick) => at101.ticks.some((next) => next.stationIndex === tick.stationIndex)); + const sharedIndex = at100.ticks.find((tick) => + at101.ticks.some((next) => next.stationIndex === tick.stationIndex), + ); assert.ok(sharedIndex); - const before = at100.ticks.find((tick) => tick.stationIndex === sharedIndex.stationIndex); - const after = at101.ticks.find((tick) => tick.stationIndex === sharedIndex.stationIndex); + const before = at100.ticks.find( + (tick) => tick.stationIndex === sharedIndex.stationIndex, + ); + const after = at101.ticks.find( + (tick) => tick.stationIndex === sharedIndex.stationIndex, + ); assert.ok(after.xPx < before.xPx); - assert.ok(Math.abs(after.xPx - before.xPx) >= 4 * (at101.needleX - at100.needleX) - 1e-9); + assert.ok( + Math.abs(after.xPx - before.xPx) >= + 4 * (at101.needleX - at100.needleX) - 1e-9, + ); const singleton = buildRadioTunerTicks(0, 1, 300); assert.equal(singleton.ratio, 0.5); assert.equal(singleton.ticks.length, 1); @@ -1598,24 +2244,30 @@ test('directory tuner virtual tape is bounded and moves opposite the needle', () }); test('station camera plans preserve altitude and viewing orientation without zooming', () => { - const nadir = radioStationCameraPlan({ lat: 30, lon: -97 }, { - height: 600, - heading: 0, - pitch: -Math.PI / 2, - roll: 0, - }); + const nadir = radioStationCameraPlan( + { lat: 30, lon: -97 }, + { + height: 600, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + ); assert.ok(Math.abs(nadir.lat - 30) < 1e-9); assert.ok(Math.abs(nadir.lon + 97) < 1e-9); assert.equal(nadir.height, 600); assert.equal(nadir.heading, 0); assert.equal(nadir.pitch, -Math.PI / 2); assert.equal(nadir.roll, 0); - const oblique = radioStationCameraPlan({ lat: 30, lon: -97 }, { - height: 600, - heading: 0, - pitch: -Math.PI / 6, - roll: 0.2, - }); + const oblique = radioStationCameraPlan( + { lat: 30, lon: -97 }, + { + height: 600, + heading: 0, + pitch: -Math.PI / 6, + roll: 0.2, + }, + ); assert.equal(oblique.height, 600); assert.equal(oblique.heading, 0); assert.equal(oblique.pitch, -Math.PI / 6); @@ -1634,12 +2286,27 @@ test('Radio recenters clipped global and below-center closer Earth discs', () => keyholeRadius: 420, }; assert.equal(radioGlobeNeedsRecentering(centered), false); - assert.equal(radioGlobeNeedsRecentering({ ...centered, earthCenterY: 560 }), true); - assert.equal(radioGlobeNeedsRecentering({ ...centered, earthCenterX: 755 }), true); - assert.equal(radioGlobeNeedsRecentering({ ...centered, earthRadius: 405 }), false); + assert.equal( + radioGlobeNeedsRecentering({ ...centered, earthCenterY: 560 }), + true, + ); + assert.equal( + radioGlobeNeedsRecentering({ ...centered, earthCenterX: 755 }), + true, + ); + assert.equal( + radioGlobeNeedsRecentering({ ...centered, earthRadius: 405 }), + false, + ); const closer = { ...centered, earthRadius: 900 }; - assert.equal(radioGlobeNeedsRecentering({ ...closer, earthCenterY: 800 }), false); - assert.equal(radioGlobeNeedsRecentering({ ...closer, earthCenterY: 1_300 }), true); + assert.equal( + radioGlobeNeedsRecentering({ ...closer, earthCenterY: 800 }), + false, + ); + assert.equal( + radioGlobeNeedsRecentering({ ...closer, earthCenterY: 1_300 }), + true, + ); assert.equal(radioGlobeNeedsRecentering(null), false); }); @@ -1666,21 +2333,48 @@ test('Radio camera navigation yields to every live tracked-entity owner', () => }); test('tuner static spans drag and broadcaster handoff but voice ducking silences it', () => { - assert.equal(radioTuningStaticShouldPlay({ tuningActive: true, tuningStatic: true }), true); - assert.equal(radioTuningStaticShouldPlay({ tuningStatic: true, awaitingStationId: 'news' }), true); - assert.equal(radioTuningStaticShouldPlay({ - tuningStatic: true, - awaitingStationId: 'news', - voiceDucked: true, - }), false); - assert.equal(radioTuningStaticShouldPlay({ tuningStatic: false, awaitingStationId: 'news' }), false); + assert.equal( + radioTuningStaticShouldPlay({ tuningActive: true, tuningStatic: true }), + true, + ); + assert.equal( + radioTuningStaticShouldPlay({ + tuningStatic: true, + awaitingStationId: 'news', + }), + true, + ); + assert.equal( + radioTuningStaticShouldPlay({ + tuningStatic: true, + awaitingStationId: 'news', + voiceDucked: true, + }), + false, + ); + assert.equal( + radioTuningStaticShouldPlay({ + tuningStatic: false, + awaitingStationId: 'news', + }), + false, + ); }); test('station horizon scans ignore stationary camera noise but detect movement', () => { const prior = { x: 1_000, y: -2_000, z: 3_000 }; - assert.equal(radioCameraPositionChanged(prior, { x: 1_000, y: -2_000, z: 3_000 }), false); - assert.equal(radioCameraPositionChanged(prior, { x: 1_000.5, y: -2_000, z: 3_000 }), false); - assert.equal(radioCameraPositionChanged(prior, { x: 1_002, y: -2_000, z: 3_000 }), true); + assert.equal( + radioCameraPositionChanged(prior, { x: 1_000, y: -2_000, z: 3_000 }), + false, + ); + assert.equal( + radioCameraPositionChanged(prior, { x: 1_000.5, y: -2_000, z: 3_000 }), + false, + ); + assert.equal( + radioCameraPositionChanged(prior, { x: 1_002, y: -2_000, z: 3_000 }), + true, + ); assert.equal(radioCameraPositionChanged(null, prior), true); }); @@ -1688,7 +2382,9 @@ test('voice ducking preserves the user-owned Radio volume', async () => { radioLayer.destroy(); radioLayer.enable(); radioLayer.setLifecyclePresentation({ - lifecycleState: 'enabled', enabled: true, uncertain: false, + lifecycleState: 'enabled', + enabled: true, + uncertain: false, }); try { setRadioVoiceDucking(true); @@ -1730,7 +2426,9 @@ test('Radio volume requires certain enabled lifecycle authority', () => { } radioLayer.setLifecyclePresentation({ - lifecycleState: 'enabled', enabled: true, uncertain: false, + lifecycleState: 'enabled', + enabled: true, + uncertain: false, }); assert.equal(setRadioVolume(0.2), true); assert.equal(getRadioUIState().volume, 0.2); @@ -1753,7 +2451,9 @@ test('voice playback confirmation waits for playing and requires a hard duck', a subscribe: (next) => { listener = next; next({ ...state, audioState: 'stopped' }); - return () => { listener = () => {}; }; + return () => { + listener = () => {}; + }; }, getState: () => state, timeoutMs: 50, @@ -1761,15 +2461,18 @@ test('voice playback confirmation waits for playing and requires a hard duck', a assert.equal(await confirmed, true); state = { ...state, voiceDucked: false }; - assert.equal(await confirmRadioPlayback({ - startPlayback: async () => true, - subscribe: (next) => { - next(state); - return () => {}; - }, - getState: () => state, - timeoutMs: 5, - }), false); + assert.equal( + await confirmRadioPlayback({ + startPlayback: async () => true, + subscribe: (next) => { + next(state); + return () => {}; + }, + getState: () => state, + timeoutMs: 5, + }), + false, + ); }); test('voice playback confirmation accepts fallback buffering but times out safely', async () => { diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 2f4f7ea51..6ce376a6b 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -26,6 +26,7 @@ import { import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; import { alprMarkers } from './markerState.js'; import { heightsFor } from '../../queries/heights.js'; +import { cameraOf } from '../../render/rendererOf.js'; export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; @@ -534,14 +535,18 @@ export function createAlprPresentation({ state, services, source }) { validAlprGroundHeight(height) ? height : 0, ); if (!selectRecord(nearest.id)) return false; - camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), { - duration: 1.2, - offset: new Cesium.HeadingPitchRange( - camera.heading || 0, - -Math.PI / 4, - 800, - ), - }); + const port = cameraOf(state.viewer); + port.flyToBounds( + { center, radius: 30 }, + { + durationS: 1.2, + offset: { + heading: port.pose().heading || 0, + pitch: -Math.PI / 4, + range: 800, + }, + }, + ); governorRequestRender('alpr-focus'); return true; } diff --git a/src/layers/awareness/focus.js b/src/layers/awareness/focus.js index e7681b14b..00bbe3ba6 100644 --- a/src/layers/awareness/focus.js +++ b/src/layers/awareness/focus.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; +import { cameraOf } from '../../render/rendererOf.js'; export function createFocus({ state: layerState, services, parts, source }) { const flightsLayer = services.flights; @@ -45,9 +46,9 @@ export function createFocus({ state: layerState, services, parts, source }) { ) return true; announceNavigationAuthority('context-vessel-focus'); - layerState.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), - { duration: 1.4 }, + cameraOf(layerState.viewer).flyToBounds( + { center: vessel.position, radius: VESSEL_FOCUS_RADIUS_M }, + { durationS: 1.4 }, ); return true; } @@ -180,9 +181,9 @@ export function createFocus({ state: layerState, services, parts, source }) { announceNavigationAuthority('context-vessel-autofocus', { cancelPendingSelection: false, }); - layerState.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), - { duration: 1.6 }, + cameraOf(layerState.viewer).flyToBounds( + { center: vessel.position, radius: VESSEL_FOCUS_RADIUS_M }, + { durationS: 1.6 }, ); return true; } diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js index 6c576bb4f..6627c4c4f 100644 --- a/src/layers/cctv/navigation.js +++ b/src/layers/cctv/navigation.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { CCTV_FOCUS_RESULT } from './policy.js'; +import { cameraOf } from '../../render/rendererOf.js'; export function createNavigation({ state: layerState, @@ -59,19 +60,16 @@ export function createNavigation({ } const { camera } = record; const range = Math.max(280, camera.rangeM * 1.18); - viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - record.position, - Math.max(40, camera.rangeM * 0.36), - ), + cameraOf(viewer).flyToBounds( + { center: record.position, radius: Math.max(40, camera.rangeM * 0.36) }, { - offset: new Cesium.HeadingPitchRange( - parts.model.toRad(camera.headingDeg), - parts.model.toRad(-22), + offset: { + heading: parts.model.toRad(camera.headingDeg), + pitch: parts.model.toRad(-22), range, - ), - duration: Math.max(0.2, duration || 0), - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + durationS: Math.max(0.2, duration || 0), + easing: 'cubicInOut', }, ); return CCTV_FOCUS_RESULT.FOCUSED; diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 08bc2fcb5..86e24a4e4 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -23,6 +23,7 @@ import { translateKnown, } from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; +import { cameraOf } from '../../render/rendererOf.js'; // "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word // joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen). @@ -319,12 +320,15 @@ export function createCyclonesLayer({ selectedId !== storm.id ) return; - return viewer.camera.flyToBoundingSphere(sphere, { - duration: matchMedia?.('(prefers-reduced-motion: reduce)') - ?.matches - ? 0 - : 1.4, - }); + cameraOf(viewer).flyToBounds( + { center: sphere.center, radius: sphere.radius }, + { + durationS: matchMedia?.('(prefers-reduced-motion: reduce)') + ?.matches + ? 0 + : 1.4, + }, + ); }); } } diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f87f25c63..3dc411786 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -8,6 +8,7 @@ import { } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; import { fakeInput } from '../../input/testDoubles.mjs'; +import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs'; /** Record each click subscription the layer makes, as the old handler fakes did. */ function recordHandlers(viewer, handlers) { @@ -120,6 +121,7 @@ test('map selection yields to pointer owners and owns only its enabled handler', }, }, }; + bindFakeCesiumCamera(viewer); recordHandlers(viewer, handlers); layer.init(viewer); layer.setRowControlsListener(() => notices++); @@ -360,7 +362,10 @@ function harness( setSelection(id) { selection = id; }, - getFocusSphere: () => ({ radius: 500000 }), + getFocusSphere: () => ({ + center: { x: 6378137, y: 0, z: 0 }, + radius: 500000, + }), clear() { cleared++; }, @@ -384,6 +389,7 @@ function harness( navigation.push({ sphere, options }), }, }; + bindFakeCesiumCamera(viewer); layer.init(viewer); layer.attachShellServices({ runNavigation: (fn) => { diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index 2fa31e59e..c26d61c81 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -6,6 +6,7 @@ import { ANCHOR_REFRESH_M, } from './policy.js'; import { formatNumber, t, tn } from '../../i18n/index.js'; +import { cameraOf } from '../../render/rendererOf.js'; // installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and // hands back msg()-marked English plus values; getStats translates them here. @@ -175,16 +176,16 @@ export function createControls({ state: layerState, services, parts, source }) { // No camera flight without a real selection: a flight plus a stale subject // reads as success to Context navigation and strands NEXT on this item. if (!parts.selection.selectRecord(record.id)) return false; - layerState.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - parts.rendering.installationSurfaceHeightM(record), - ), - 18000, - ), - { duration: 1.4 }, + cameraOf(layerState.viewer).flyToBounds( + { + center: { + lon: record.longitude, + lat: record.latitude, + height: parts.rendering.installationSurfaceHeightM(record), + }, + radius: 18000, + }, + { durationS: 1.4 }, ); return true; }, diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js index d4539289a..aa1db5716 100644 --- a/src/layers/launches/policyHelpers.js +++ b/src/layers/launches/policyHelpers.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; +import { cameraOf } from '../../render/rendererOf.js'; import { SATELLITE_STANDALONE_DEFAULTS, LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M, @@ -228,17 +229,12 @@ export function createPolicyHelpers({ const range = parts.panel.missionHoverPreviewRange( layerState._viewer.camera.positionCartographic?.height, ); - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - layerState._viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(position, 0), + cameraOf(layerState._viewer).flyToBounds( + { center: { lon: launch.lon, lat: launch.lat }, radius: 0 }, { - offset: new Cesium.HeadingPitchRange( - 0, - -Cesium.Math.PI_OVER_TWO, - range, - ), - duration: 0.8, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + offset: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, range }, + durationS: 0.8, + easing: 'cubicInOut', }, ); } diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js index 243a1941b..f85b7be4d 100644 --- a/src/layers/radio/navigation.js +++ b/src/layers/radio/navigation.js @@ -4,6 +4,7 @@ import { GLOBAL_RADIO_ALTITUDE_M, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, } from './policy.js'; +import { cameraOf } from '../../render/rendererOf.js'; export function createNavigation({ state: layerState, @@ -229,7 +230,7 @@ export function createNavigation({ ) return; layerState._activeRadioCameraFlight = null; - layerState._viewer.camera.cancelFlight(); + cameraOf(layerState._viewer).cancelFlight(); } function invalidateRadioCameraNavigation() { @@ -247,7 +248,7 @@ export function createNavigation({ ); } - function startRadioCameraFlight(navigation, options, onComplete) { + function startRadioCameraFlight(navigation, flight, onComplete) { if (!radioCameraNavigationIsCurrent(navigation)) return false; const token = ++layerState._radioCameraFlightSequence; layerState._activeRadioCameraFlight = { @@ -260,10 +261,10 @@ export function createNavigation({ if (completed && radioCameraNavigationIsCurrent(navigation)) onComplete?.(); }; - layerState._viewer.camera.flyTo({ - ...options, - complete: () => finish(true), - cancel: () => finish(false), + cameraOf(layerState._viewer).flyTo(flight.destination, { + ...flight.options, + onComplete: () => finish(true), + onCancel: () => finish(false), }); return true; } @@ -280,17 +281,15 @@ export function createNavigation({ return startRadioCameraFlight( navigation, { - destination: Cesium.Cartesian3.fromDegrees( - plan.lon, - plan.lat, - plan.height, - ), - orientation: { - heading: plan.heading, - pitch: plan.pitch, - roll: plan.roll, + destination: { lon: plan.lon, lat: plan.lat, height: plan.height }, + options: { + orientation: { + heading: plan.heading, + pitch: plan.pitch, + roll: plan.roll, + }, + durationS: navigation.duration, }, - duration: navigation.duration, }, () => { navigation.phase = 'settled'; @@ -340,13 +339,15 @@ export function createNavigation({ activeNavigation, { destination: activeNavigation.recenterPlan.destination, - orientation: { - heading: activeNavigation.cameraState.heading, - pitch: activeNavigation.cameraState.pitch, - roll: activeNavigation.cameraState.roll, + options: { + orientation: { + heading: activeNavigation.cameraState.heading, + pitch: activeNavigation.cameraState.pitch, + roll: activeNavigation.cameraState.roll, + }, + durationS: recenterDuration, + easing: 'cubicInOut', }, - duration: recenterDuration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, }, () => { activeNavigation.recentered = true; @@ -363,14 +364,10 @@ export function createNavigation({ if (!station || !radioCameraNavigationAllowed(layerState._viewer)) return false; cancelActiveRadioCameraFlight(); - layerState._viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees( - station.lon, - station.lat, - 85_000, - ), - duration: 1.1, - }); + cameraOf(layerState._viewer).flyTo( + { lon: station.lon, lat: station.lat, height: 85_000 }, + { durationS: 1.1 }, + ); return true; } return { diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 68a0cc2f6..cbb6e134b 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -34,6 +34,7 @@ import { } from './model.js'; import { msg, t } from '../../i18n/index.js'; import { READOUT_WORDS, displayDay, displayMetres } from './display.js'; +import { cameraOf } from '../../render/rendererOf.js'; /** * Quiet time after the last arrow key before the focused day previews, so a @@ -987,15 +988,17 @@ export function createRecentImageryLayer({ height: canvas?.clientHeight, fovy: camera.frustum?.fovy, }); - camera.flyTo({ - destination: ellipsoid.cartographicToCartesian({ + cameraOf(_viewer).flyTo( + ellipsoid.cartographicToCartesian({ longitude: toRadians(target.lon), latitude: toRadians(target.lat), height, }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - duration: ZOOM_FIT_SECONDS, - }); + { + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + durationS: ZOOM_FIT_SECONDS, + }, + ); return height; }, diff --git a/src/layers/recentImagery/index.test.mjs b/src/layers/recentImagery/index.test.mjs index 7d8c70bd3..7d8c12e86 100644 --- a/src/layers/recentImagery/index.test.mjs +++ b/src/layers/recentImagery/index.test.mjs @@ -1,4 +1,5 @@ import test from 'node:test'; +import * as Cesium from 'cesium'; import assert from 'node:assert/strict'; import { NO_IMAGERY_HOST } from '../../maps/imageryHost.js'; import { acquireImageryComparison } from '../../maps/imageryComparison.js'; @@ -24,6 +25,7 @@ import { manualTimers, settle, } from './testDoubles.mjs'; +import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs'; const NOW = new Date('2026-09-21T15:00:00Z'); const LINK_BOX = { @@ -133,6 +135,7 @@ function fixture({ }); if (viirs) layer.setSources({ viirs: true }); const viewer = { camera: {} }; + bindFakeCesiumCamera(viewer); const reasons = []; layer.subscribe((snapshot, reason) => reasons.push(reason)); layer.init(viewer); @@ -966,6 +969,8 @@ test('USE VIEW takes the camera rectangle and refuses a view over the cap instea test('ZOOM IN: an oversized box or view flies top-down to its centre at the height whose view is 400 km wide', () => { const f = fixture(); const flights = []; + const landing = (i) => + Cesium.Ellipsoid.WGS84.cartesianToCartographic(flights[i].destination); const deg = (rad) => (rad * 180) / Math.PI; const rad = (value) => (value * Math.PI) / 180; // A 1000 × 500 canvas with a 60° horizontal FOV. @@ -976,7 +981,8 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig scene: { canvas: { clientWidth: 1000, clientHeight: 500 }, ellipsoid: { - cartographicToCartesian: (c) => ({ ...c, cartesian: true }), + cartographicToCartesian: (c) => + Cesium.Ellipsoid.WGS84.cartographicToCartesian(c), cartesianToCartographic: (c) => c && { ...c }, }, }, @@ -999,9 +1005,9 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig assert.ok(Math.abs(height - expected) < 1e-6, String(height)); assert.equal(flights.length, 1); const [flight] = flights; - assert.ok(Math.abs(deg(flight.destination.longitude) - 10) < 1e-9); - assert.ok(Math.abs(deg(flight.destination.latitude) - 10) < 1e-9); - assert.equal(flight.destination.height, height); + assert.ok(Math.abs(deg(landing(0).longitude) - 10) < 1e-9); + assert.ok(Math.abs(deg(landing(0).latitude) - 10) < 1e-9); + assert.ok(Math.abs(landing(0).height - height) < 1e-6); assert.deepEqual(flight.orientation, { heading: 0, pitch: -Math.PI / 2, @@ -1019,14 +1025,14 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig assert.equal(f.layer.useCurrentView(), false); assert.equal(f.snap().zoomToFit, true); f.layer.zoomToFit(); - assert.ok(Math.abs(deg(flights[1].destination.longitude) + 97) < 1e-9); - assert.ok(Math.abs(deg(flights[1].destination.latitude) - 31) < 1e-9); + assert.ok(Math.abs(deg(landing(1).longitude) + 97) < 1e-9); + assert.ok(Math.abs(deg(landing(1).latitude) - 31) < 1e-9); // Sky at the centre: the view rectangle's centre instead. centreHit = null; f.layer.useCurrentView(); f.layer.zoomToFit(); - assert.ok(Math.abs(deg(flights[2].destination.longitude) + 98) < 1e-9); - assert.ok(Math.abs(deg(flights[2].destination.latitude) - 30) < 1e-9); + assert.ok(Math.abs(deg(landing(2).longitude) + 98) < 1e-9); + assert.ok(Math.abs(deg(landing(2).latitude) - 30) < 1e-9); // The box tool's own oversized drag carries its box. f.layer.reportBoxRefusal('Box is 2,226 km wide · limit 1,000 km', { west: 0, diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js index 251bf909d..fd87ccaa0 100644 --- a/src/layers/streetLevel/cameraFraming.js +++ b/src/layers/streetLevel/cameraFraming.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; import { heightsFor } from '../../queries/heights.js'; +import { cameraOf } from '../../render/rendererOf.js'; /** Fly the globe camera once to frame a photo after it opens. */ export function createCameraFraming({ state }) { @@ -49,36 +50,37 @@ export function createCameraFraming({ state }) { return; const ground = groundHeightAt(position.lon, position.lat, altitude); // Owned before the call: starting it cancels the previous flight (ours - // included) synchronously, and a zero-length one completes at once. + // included), and a zero-length one completes inside the call. const current = {}; flight = current; const release = () => { if (flight === current) flight = null; }; - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), - 4, - ), + const started = cameraOf(state.viewer).flyToBounds( { - offset: new Cesium.HeadingPitchRange( - Cesium.Math.toRadians(bearing || 0), - Cesium.Math.toRadians(-32), - 140, - ), - duration: 1.6, - complete: release, - cancel: release, + center: { lon: position.lon, lat: position.lat, height: ground + 2 }, + radius: 4, + }, + { + offset: { + heading: Cesium.Math.toRadians(bearing || 0), + pitch: Cesium.Math.toRadians(-32), + range: 140, + }, + durationS: 1.6, + onComplete: release, + onCancel: release, }, ); + if (flight === current) current.handle = started; } /** Stop our framing flight if it is still in the air; another feature's flight is left alone. */ function cancel() { if (!flight) return; - // Give up ownership first: cancelFlight delivers `cancel` synchronously. + const { handle } = flight; flight = null; - state.viewer?.camera?.cancelFlight?.(); + handle?.cancel(); } return { attachNavigation, begin, frame, cancel }; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index d563769ba..a6ede575c 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; +import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { createMapillaryProvider } from './providers/mapillary/index.js'; @@ -326,7 +327,7 @@ function fakeViewer() { disableRootEvents: true, onwheel: null, }); - return { + return bindFakeCesiumCamera({ credits, flights, scene: { @@ -358,7 +359,7 @@ function fakeViewer() { if (at !== -1) credits.splice(at, 1); }, }, - }; + }); } /** A layer over `providers`, initialised and enabled on a stand-in viewer. */ diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js index 2c749f10b..bf8338fd6 100644 --- a/src/layers/submarineCables/interaction.js +++ b/src/layers/submarineCables/interaction.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { cablePicks } from './pickState.js'; +import { cameraOf } from '../../render/rendererOf.js'; export function createInteraction({ state, inputFor }) { function registerPickEntity(entity, info) { @@ -45,22 +46,20 @@ export function createInteraction({ state, inputFor }) { function flyToReference(viewer, reference) { if (!viewer || !reference) return; - const destination = Cesium.Cartesian3.fromDegrees( - reference.lon, - reference.lat, - 6500, - ); - viewer.camera.cancelFlight(); - viewer.camera.flyTo({ - destination, - orientation: { - heading: viewer.camera.heading || 0, - pitch: Cesium.Math.toRadians(-52), - roll: 0, + const camera = cameraOf(viewer); + camera.cancelFlight(); + camera.flyTo( + { lon: reference.lon, lat: reference.lat, height: 6500 }, + { + orientation: { + heading: camera.pose().heading || 0, + pitch: Cesium.Math.toRadians(-52), + roll: 0, + }, + durationS: 1.35, + easing: 'cubicInOut', }, - duration: 1.35, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); + ); } return { registerPickEntity, diff --git a/src/layers/weather/display.test.mjs b/src/layers/weather/display.test.mjs index e852a1ae8..1f171b6f9 100644 --- a/src/layers/weather/display.test.mjs +++ b/src/layers/weather/display.test.mjs @@ -7,6 +7,7 @@ import { createWeatherRendering } from './rendering.js'; import { weatherDisplay, insideExtent, escapeCreditText } from './display.js'; import { createShellCesium, createShellScene } from './shellFixture.mjs'; import { formatScaleLabel } from '../../ui/unitLabels.js'; +import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs'; const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z']; const base = { @@ -58,18 +59,22 @@ async function shownLayer(id, manifest, params, view = {}) { getDiagnostics: () => ({ time, loading: false, error: null }), }), }); - layer.init({ - camera: { - moveEnd: { addEventListener: () => () => {} }, - flyTo: ({ destination }) => flights.push(destination), - pickEllipsoid: () => - view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined, - }, - scene: { - canvas: { clientWidth: 800, clientHeight: 600 }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - }, - }); + layer.init( + bindFakeCesiumCamera({ + camera: { + moveEnd: { addEventListener: () => () => {} }, + flyTo: ({ destination }) => flights.push(destination), + pickEllipsoid: () => + view.center + ? Cesium.Cartesian3.fromDegrees(...view.center) + : undefined, + }, + scene: { + canvas: { clientWidth: 800, clientHeight: 600 }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + }), + ); if (params) layer.setParams(params); layer.enable(); await layer.update(); @@ -638,6 +643,7 @@ function globeViewer() { requestRender() {}, }, }; + bindFakeCesiumCamera(viewer); class Provider { constructor(options) { this.options = options; diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index e318e3087..d99eed43e 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -10,6 +10,7 @@ import { } from './clock.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatScaleLabel } from '../../ui/unitLabels.js'; +import { cameraOf } from '../../render/rendererOf.js'; /** * A stored (English) failure in the language. HTTP failures from source.js @@ -475,17 +476,19 @@ export function createWeatherLayer({ (lightning ? { west: 110, south: -25, east: 0, north: 80 } : manifest.bounds); - runNavigation(() => - viewer.camera.flyTo({ - destination: cesium.Rectangle.fromDegrees( - b.west, - b.south, - b.east, - b.north, - ), - duration: motion?.matches ? 0 : 1.4, - }), - ); + runNavigation(() => { + cameraOf(viewer).flyTo( + { + rect: { + west: b.west, + south: b.south, + east: b.east, + north: b.north, + }, + }, + { durationS: motion?.matches ? 0 : 1.4 }, + ); + }); } if (clock && enabled) { if (params.play === true) void clock.togglePlay(); diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index d377cb729..19d67d73a 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -182,6 +182,7 @@ export function createCesiumCamera({ scene, camera, report }) { o.range ?? 0, ), duration: options.durationS, + easingFunction: options.easing && EASINGS[options.easing], ...callbacks, }, ), diff --git a/src/render/cesium/cameraGoldens.js b/src/render/cesium/cameraGoldens.js index e8550718a..f319693f0 100644 --- a/src/render/cesium/cameraGoldens.js +++ b/src/render/cesium/cameraGoldens.js @@ -198,6 +198,7 @@ function cesiumDriver(clock, threshold) { ) : undefined, duration: options.durationS, + easingFunction: EASINGS[options.easing], complete, cancel, }, diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index 509200caf..ed4f75c71 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -230,6 +230,5 @@ "src/ui/shareFollow.js", "src/ui/visualEffects.js", "src/voice/gevActions.js", - "src/voice/pointerPick.js", - "src/worldFocus.js" + "src/voice/pointerPick.js" ] diff --git a/src/render/conformance/cameraScripts.js b/src/render/conformance/cameraScripts.js index 6c1da780c..58bef2047 100644 --- a/src/render/conformance/cameraScripts.js +++ b/src/render/conformance/cameraScripts.js @@ -371,6 +371,27 @@ export const CAMERA_SCRIPTS = Object.freeze([ { record: 'pose' }, ], }, + { + name: 'flyToBounds easing', + steps: [ + START, + { + do: 'flyToBounds', + args: [ + { center: { ...AUSTIN, height: 0 }, radius: 500 }, + { + offset: { heading: 0.5, pitch: -0.4, range: 2500 }, + durationS: 1, + easing: 'cubicInOut', + }, + ], + }, + { do: 'frames', count: 20 }, + { record: 'pose' }, + { do: 'frames', count: 70 }, + { record: 'pose' }, + ], + }, { name: 'a cancelled flight stops where it was', steps: [ diff --git a/src/render/conformance/goldens/camera.json b/src/render/conformance/goldens/camera.json index f5f12ffab..1dc6a53f8 100644 --- a/src/render/conformance/goldens/camera.json +++ b/src/render/conformance/goldens/camera.json @@ -2071,6 +2071,79 @@ } ] }, + "flyToBounds easing": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -2267093.6902229865, + "y": -16670141.730590489, + "z": 9795550.536889307 + }, + "direction": { + "x": 0.13436019637409768, + "y": 0.918216318410333, + "z": -0.37259378716678554 + }, + "up": { + "x": 0.11222044188344235, + "y": 0.3594849211517312, + "z": 0.9263785208477249 + }, + "right": { + "x": 0.9845577230684848, + "y": -0.1662810394167284, + "z": -0.05474217639712155 + }, + "heading": 0.06349647744048603, + "pitch": -1.4221294499846318, + "roll": 6.283185231543937, + "height": 13094845.933521682, + "frameIsIdentity": true + } + }, + { + "step": 5, + "kind": "pose", + "value": { + "position": { + "x": -744171.8196286416, + "y": -5464810.027402491, + "z": 3194736.500278188 + }, + "direction": { + "x": 0.5377602708229207, + "y": 0.6774719395626166, + "z": 0.5018422682773198 + }, + "up": { + "x": 0.1010227917440539, + "y": -0.6427253771880552, + "z": 0.7594066664618568 + }, + "right": { + "x": 0.8370234684121447, + "y": -0.35768122766471616, + "z": -0.41407227956428677 + }, + "heading": 0.4998993704309962, + "pitch": -0.4003616646350623, + "roll": 6.283184711710125, + "height": 973.9627016434621, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 62 + } + ] + }, "a cancelled flight stops where it was": { "records": [ { diff --git a/src/render/contract/camera.d.ts b/src/render/contract/camera.d.ts index 90ba04d97..482e6927f 100644 --- a/src/render/contract/camera.d.ts +++ b/src/render/contract/camera.d.ts @@ -210,9 +210,9 @@ export interface CameraPort { */ flyToBounds( sphere: Sphere, - options?: { offset?: HeadingPitchRange; durationS?: number } & Pick< + options?: { offset?: HeadingPitchRange } & Pick< FlightOptions, - 'onComplete' | 'onCancel' + 'durationS' | 'easing' | 'onComplete' | 'onCancel' >, ): Flight; cancelFlight(): void; diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index f6d5a6d70..ccdbc2019 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,5 +1,6 @@ /** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */ import * as Cesium from 'cesium'; +import { bindFakeCesiumCamera } from './fakeCameraPort.mjs'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; import { latToTileY, @@ -190,7 +191,7 @@ export function fakeCesiumViewer({ view = false } = {}) { }, }, ); - return { + return bindFakeCesiumCamera({ credits, flights, scene: { @@ -211,7 +212,7 @@ export function fakeCesiumViewer({ view = false } = {}) { if (at !== -1) credits.splice(at, 1); }, }, - }; + }); } /** diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index 5ed0b6bba..57127fe93 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -1,3 +1,4 @@ +import * as Cesium from 'cesium'; import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -15,6 +16,7 @@ import { response, settle, } from '../layers/recentImagery/testDoubles.mjs'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; const S18 = 'S30:2026-09-18'; const L16 = 'L30:2026-09-16'; @@ -128,6 +130,7 @@ function fixture({ fetchImpl, controller = fakeController() } = {}) { camera: {}, scene: { canvas: { clientWidth: 1000 }, requestRender() {} }, }; + bindFakeCesiumCamera(viewer); layer.init(viewer); layer.attachMapStackController(controller); let active = false; @@ -801,9 +804,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l const f = fixture(); const flights = []; f.viewer.scene.canvas.clientHeight = 500; - f.viewer.scene.ellipsoid = { - cartographicToCartesian: (c) => ({ ...c }), - }; + f.viewer.scene.ellipsoid = Cesium.Ellipsoid.WGS84; Object.assign(f.viewer.camera, { frustum: { fovy: 2 * Math.atan(Math.tan(Math.PI / 6) / 2) }, flyTo: (options) => flights.push(options), @@ -835,7 +836,9 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l assert.equal(flights.length, 1); assert.ok( Math.abs( - flights[0].destination.height - 400_000 / (2 * Math.tan(Math.PI / 6)), + Cesium.Ellipsoid.WGS84.cartesianToCartographic(flights[0].destination) + .height - + 400_000 / (2 * Math.tan(Math.PI / 6)), ) < 1e-6, ); // Another refusal has nothing to fit; a box that succeeds hides it again. diff --git a/src/worldFocus.js b/src/worldFocus.js index 5b5ca7a8e..c6c2dbe09 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -1,7 +1,8 @@ /** * One-click camera transfer for layer-owned world targets. */ -import * as Cesium from 'cesium'; +import { WGS84, toRadians } from './geo/index.js'; +import { rendererOf } from './render/rendererOf.js'; export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus'; export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; @@ -26,11 +27,8 @@ export function isValidWorldFocusTarget(detail) { // Vessel and fire targets are surface-anchored Earth positions. Merely // finite coordinates near the ECEF origin cannot be flown to, and must be // rejected before the camera policy releases a current follow owner. - const magnitude = Cesium.Cartesian3.magnitude(position); - return ( - Number.isFinite(magnitude) && - magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95 - ); + const magnitude = Math.hypot(position.x, position.y, position.z); + return Number.isFinite(magnitude) && magnitude >= WGS84.radii.z * 0.95; } /** Announce a valid user-click focus request. */ @@ -71,25 +69,26 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) { /** Fly to a world target after ownership has been released. */ export function flyToWorldTarget(viewer, target = {}) { - const camera = viewer?.camera; + const camera = rendererOf(viewer)?.camera; const framing = WORLD_FOCUS_FRAMING[target.kind]; if (!camera || !framing || !isValidWorldFocusTarget(target)) return false; - const heading = Number.isFinite(camera.heading) ? camera.heading : 0; + const poseHeading = camera.pose().heading; + const heading = Number.isFinite(poseHeading) ? poseHeading : 0; const duration = target.durationSec > 0 ? target.durationSec : WORLD_CLICK_FOCUS_DURATION_SEC; - camera.cancelFlight?.(); - camera.flyToBoundingSphere( - new Cesium.BoundingSphere(target.position, framing.radiusM), + camera.cancelFlight(); + camera.flyToBounds( + { center: target.position, radius: framing.radiusM }, { - offset: new Cesium.HeadingPitchRange( + offset: { heading, - Cesium.Math.toRadians(framing.pitchDeg), - framing.rangeM, - ), - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + pitch: toRadians(framing.pitchDeg), + range: framing.rangeM, + }, + durationS: duration, + easing: 'cubicInOut', }, ); return true; diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index f5e8146db..bb8773334 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; +import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs'; import { WORLD_CLICK_FOCUS_DURATION_SEC, WORLD_FOCUS_FRAMING, @@ -21,16 +22,28 @@ function stubCamera() { return { calls, heading: Cesium.Math.toRadians(45), - cancelFlight() { calls.cancelFlight += 1; }, - flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, + cancelFlight() { + calls.cancelFlight += 1; + }, + flyToBoundingSphere(sphere, options) { + calls.flights.push({ sphere, options }); + }, }; } test('a focus request carries the clicked target to any registered listener', () => { const target = new EventTarget(); const seen = []; - const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail)); - assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true); + const dispose = registerWorldFocusRequestListener(target, (event) => + seen.push(event.detail), + ); + assert.equal( + requestWorldFocus( + { kind: 'vessel', position: POSITION, id: '123' }, + target, + ), + true, + ); assert.equal(seen.length, 1); assert.equal(seen[0].kind, 'vessel'); assert.equal(seen[0].id, '123'); @@ -44,7 +57,9 @@ test('a focus request carries the clicked target to any registered listener', () test('requests without a kind or a position are dropped, never dispatched', () => { const target = new EventTarget(); let count = 0; - target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { count += 1; }); + target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { + count += 1; + }); assert.equal(requestWorldFocus({ position: POSITION }, target), false); assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false); assert.equal(requestWorldFocus(null, target), false); @@ -74,51 +89,95 @@ test('a refusing policy (cockpit owns the camera) never reaches the flight', () const result = routeWorldFocusRequest( { detail: { kind: 'vessel', id: '123', position: POSITION } }, () => false, - () => { flights += 1; }, + () => { + flights += 1; + }, ); assert.equal(result, false); assert.equal(flights, 0); }); test('malformed requests are inert', () => { - assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false); - assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false); + assert.equal( + routeWorldFocusRequest( + null, + () => 'x', + () => 'y', + ), + false, + ); + assert.equal( + routeWorldFocusRequest( + { detail: { kind: 'vessel' } }, + () => 'x', + () => 'y', + ), + false, + ); }); // A request that cannot fly must never cost the user their tracking: the UI // policy releases the follow camera, so validation happens BEFORE it runs. test('an unflyable request never reaches the release policy', () => { const unflyable = [ - { kind: 'plane', position: POSITION }, // no framing for this kind - { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position - { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete + { kind: 'plane', position: POSITION }, // no framing for this kind + { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position + { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete { kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin { kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth { kind: 'vessel', position: 'somewhere' }, - { kind: 'vessel', position: POSITION }, // missing owner id + { kind: 'vessel', position: POSITION }, // missing owner id { position: POSITION }, ]; for (const detail of unflyable) { - assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail)); + assert.equal( + isValidWorldFocusTarget(detail), + false, + JSON.stringify(detail), + ); let policyRuns = 0; const result = routeWorldFocusRequest( { detail }, - () => { policyRuns += 1; return 'released'; }, + () => { + policyRuns += 1; + return 'released'; + }, () => 'flew', ); assert.equal(result, false, JSON.stringify(detail)); - assert.equal(policyRuns, 0, `the policy must not run for ${JSON.stringify(detail)}`); + assert.equal( + policyRuns, + 0, + `the policy must not run for ${JSON.stringify(detail)}`, + ); // Nor may such a request be dispatched in the first place. assert.equal(requestWorldFocus(detail, new EventTarget()), false); } - assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true); + assert.equal( + isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), + true, + ); + assert.equal( + isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), + true, + ); }); test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); + assert.equal( + flyToWorldTarget(bindFakeCesiumCamera({ camera }), { + kind: 'vessel', + id: '123', + position: POSITION, + }), + true, + ); + assert.equal( + camera.calls.cancelFlight, + 1, + 'a prior flight must be cancelled, not queued', + ); assert.equal(camera.calls.flights.length, 1); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); @@ -131,12 +190,20 @@ test('the transfer flight supersedes any flight in progress and keeps the operat test('fires frame wider than vessels — a fire is read by its surroundings', () => { const camera = stubCamera(); - flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); + flyToWorldTarget(bindFakeCesiumCamera({ camera }), { + kind: 'fire', + id: 'fire-1', + position: POSITION, + }); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); + assert.ok( + WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM, + ); + assert.ok( + WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM, + ); }); test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => { @@ -145,25 +212,52 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph for (const kind of kinds) { const framing = WORLD_FOCUS_FRAMING[kind]; // Looking DOWN at the target, but obliquely — a nadir drop reads as a map. - assert.ok(framing.pitchDeg < 0, `${kind}: pitch must look down, got ${framing.pitchDeg}`); - assert.ok(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`); + assert.ok( + framing.pitchDeg < 0, + `${kind}: pitch must look down, got ${framing.pitchDeg}`, + ); + assert.ok( + framing.pitchDeg > -80, + `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`, + ); // Standing off outside the framed sphere, at a human-readable distance. assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`); assert.ok( framing.rangeM > framing.radiusM, `${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`, ); - assert.ok(framing.rangeM <= 10000, `${kind}: range must stay close-in, got ${framing.rangeM}`); + assert.ok( + framing.rangeM <= 10000, + `${kind}: range must stay close-in, got ${framing.rangeM}`, + ); } // Exact shipped values — a silent retune must show up as a failing test. - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 }); - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.vessel }, + { radiusM: 150, rangeM: 1200, pitchDeg: -30 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.fire }, + { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, + ); }); test('unknown kinds and missing viewers issue no flight', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); - assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); - assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); + assert.equal( + flyToWorldTarget(bindFakeCesiumCamera({ camera }), { + kind: 'plane', + position: POSITION, + }), + false, + ); + assert.equal( + flyToWorldTarget(bindFakeCesiumCamera({ camera }), { kind: 'vessel' }), + false, + ); + assert.equal( + flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), + false, + ); assert.equal(camera.calls.flights.length, 0); });