diff --git a/src/data/cctvExplicitPickEndsOpening.test.mjs b/src/data/cctvExplicitPickEndsOpening.test.mjs new file mode 100644 index 000000000..cfe649888 --- /dev/null +++ b/src/data/cctvExplicitPickEndsOpening.test.mjs @@ -0,0 +1,129 @@ +// An explicit pick of the camera that a settled move just re-opened must end +// the opening pick. That pick goes through setActiveCamera's UNCHANGED path +// (same camera, already activated); if that path keeps the provisional flag, +// the next settled move (a pan, or the landing of NEAREST's own flight) +// switches the camera away from the one the user chose. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import cctvLayer, { + _setCctvCoverageStateForTest, + _setCctvOverlayHostForTest, + reopenNearestCamera, +} from './cctv.js'; + +const BASE = { + headingDeg: 41, + pitchDeg: -5, + fovDeg: 56, + rangeM: 210, + mountHeightM: 100, + groundElevationM: 0, + city: 'London', +}; + +function record(id, lat, lon) { + return { + camera: { ...BASE, id, name: `Camera ${id}`, lat, lon }, + coverageEntities: [], + projection: null, + activationDone: true, + groundResolved: {}, + groundSamples: {}, + groundPrior: 0, + frustumGeometry: null, + frustumPositions: null, + probeClampRangeM: null, + position: Cesium.Cartesian3.fromDegrees(lon, lat, 100), + }; +} + +function viewerAt(lat, lon) { + return { + entities: new Cesium.EntityCollection(), + isDestroyed: () => false, + trackedEntity: null, + camera: { + positionWC: Cesium.Cartesian3.fromDegrees(lon, lat, 300), + positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, 300), + heading: 0.4, + pitch: -0.7, + roll: 0, + transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + flyToBoundingSphere() {}, + }, + scene: { + canvas: { clientWidth: 1200, clientHeight: 800 }, + cartesianToCanvasCoordinates: () => undefined, + primitives: { add: (p) => p, remove: () => true }, + requestRender() {}, + }, + }; +} + +function moveViewer(viewer, lat, lon) { + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + lon, + lat, + 300, + ); +} + +function withReopenedLayer(fn) { + _setCctvOverlayHostForTest({ + setEntries() {}, + setVisible() {}, + clearSource() {}, + }); + // Activation builds the projection canvas and frame image. + const originalDocument = globalThis.document; + const originalImage = globalThis.Image; + globalThis.document = { createElement: () => ({ getContext: () => null }) }; + globalThis.Image = class {}; + try { + const trafalgar = record('a-trafalgar', 51.508, -0.128); + const cambridge = record('b-cambridge', 51.513, -0.129); + // enable() assigned the opening camera without activating it. + trafalgar.activationDone = false; + const viewer = viewerAt(51.5081, -0.1281); + _setCctvCoverageStateForTest({ + viewer, + records: [trafalgar, cambridge], + activeCameraId: 'a-trafalgar', + enabled: true, + openingCameraProvisional: true, + }); + // A settled move next to Cambridge Circus re-opens there, still as the + // opening pick. + moveViewer(viewer, 51.5131, -0.1291); + assert.equal(reopenNearestCamera(), true); + assert.equal(cctvLayer.getUIState().activeCameraId, 'b-cambridge'); + fn(viewer); + } finally { + globalThis.document = originalDocument; + globalThis.Image = originalImage; + _setCctvOverlayHostForTest(); + _setCctvCoverageStateForTest({ enabled: false }); + } +} + +for (const [name, pick] of [ + ['NEAREST (focusNearest)', () => cctvLayer.focusNearest({ focus: false })], + ['selectCamera', () => cctvLayer.selectCamera('b-cambridge')], + [ + 'setParams({ selectedCameraId })', + () => cctvLayer.setParams({ selectedCameraId: 'b-cambridge' }), + ], +]) { + test(`an explicit ${name} pick of the re-opened camera ends the opening pick`, () => { + withReopenedLayer((viewer) => { + pick(); + assert.equal(cctvLayer.getUIState().activeCameraId, 'b-cambridge'); + // A later settled move (a pan, or the landing of the pick's own flight) + // leaves the explicit choice alone. + moveViewer(viewer, 51.5081, -0.1281); + assert.equal(reopenNearestCamera(), false); + assert.equal(cctvLayer.getUIState().activeCameraId, 'b-cambridge'); + }); + }); +} diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index 834b2f583..1a5c1c161 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -79,6 +79,9 @@ export function createSelection({ record, ) ) { + // Picking the camera a settled move re-opened is still a choice: later + // settled moves must leave it alone. + layerState._openingCameraProvisional = false; // Browser-only feeds have a separate player; a reselect retries it // without re-running activation or touching the projection geometry. if (parts.model.isBrowserOverlayCamera(record.camera)) {