diff --git a/changelog.d/1723-cctv-panel-collapse-on-disable.md b/changelog.d/1723-cctv-panel-collapse-on-disable.md index fe9d3522b..ae5a49445 100644 --- a/changelog.d/1723-cctv-panel-collapse-on-disable.md +++ b/changelog.d/1723-cctv-panel-collapse-on-disable.md @@ -1,4 +1,5 @@ - Collapse the CCTV panel when the layer is switched off. It stayed open with an empty frame and a meta line that still described the last camera; the - meta line of a disabled layer now reads the catalog count (asturksever, - #1723). + meta line of a disabled layer now reads the catalog count. Contacts, Space + Missions and Clear selected data layers leave the panel as it is + (asturksever, #1723). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 509a0e6c3..fd9b2c75f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2230,9 +2230,13 @@ settles), so a new camera's preview is blank only while its frame loads. Disposa releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. The panel opens when a camera becomes active and collapses when the layer is -switched off, from its own switch or anywhere else; a context-mode layer clear -keeps it as it is. A panel opened by hand while the layer is off stays open, -and its meta line reads the catalog count, not the last active camera. +switched off, from its own switch or anywhere else. The panel chrome applies +the collapse, so a panel the desktop accordion only folded has its saved and +shared state closed too. Clear selected data layers and Context isolation +(Contacts, Space Missions) switch the layer off without the user asking: the +panel and its share preference stay as they are, and the layer comes back to an +open panel. A panel opened by hand while the layer is off stays open, and its +meta line reads the catalog count, not the last active camera. ## UI disposal diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..b16a793df 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -339,6 +339,45 @@ try { '#cctv-frame', (image) => !image.getAttribute('src') && image.dataset.cameraId === '', ); + // The address bar follows the share state after a short delay: wait for the + // CCTV panel token (1 collapsed, 0 open) the step expects. + const panelState = async (expectedToken) => { + const settled = await page + .waitForFunction( + (token) => + new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test( + new URLSearchParams(location.hash.slice(1)).get('ui') || '', + ), + { timeout: 5000 }, + expectedToken, + ) + .then( + () => true, + () => false, + ); + return page.evaluate( + (shared) => ({ + collapsed: document + .getElementById('cctv-panel') + .classList.contains('collapsed'), + shared, + ui: new URLSearchParams(location.hash.slice(1)).get('ui'), + }), + settled ? expectedToken : null, + ); + }; + const closedOnDisable = await panelState('1'); + check( + 'disable collapses the panel and its share preference', + closedOnDisable.collapsed && closedOnDisable.shared === '1', + JSON.stringify(closedOnDisable), + ); + // The switch sits inside the collapsed panel: reopen it from the right rail. + await page.click('[data-rail-item="cctv-panel"]'); + await page.waitForFunction( + () => + !document.getElementById('cctv-panel').classList.contains('collapsed'), + ); await page.click('#cctv-enable-btn'); await page.waitForFunction( () => @@ -351,6 +390,42 @@ try { 'disable clears the preview and re-enable reacquires the camera frame', disabledCleanly, ); + // Contacts switches CCTV off to isolate itself and restores it on the way + // out: the open panel and its share preference survive the round trip. + const cameraBeforeContacts = await page.evaluate( + () => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId, + ); + const entered = await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode('flights'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const duringContacts = await panelState('0'); + await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode(null), + ); + await page.waitForFunction(() => + window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const afterContacts = await panelState('0'); + check( + 'Contacts isolation keeps the open CCTV panel and restores its camera', + entered?.ok === true && + !duringContacts.collapsed && + duringContacts.shared === '0' && + !afterContacts.collapsed && + afterContacts.shared === '0' && + (await page.evaluate( + (cameraId) => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId === cameraId, + cameraBeforeContacts, + )), + JSON.stringify({ entered, duringContacts, afterContacts }), + ); await page.$eval('#cctv-frame', (image) => image.scrollIntoView({ block: 'nearest' }), ); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index f5df3c51a..8122bda29 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1197,8 +1197,11 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), - preservePanelStateDuringClear: () => - this._preservePanelStateDuringLayerClear, + // Clear selected data layers and Context isolation switch the layer + // off without the user asking: the panel keeps its state for both. + preservePanelState: () => + this._preservePanelStateDuringLayerClear || + this._contextSuspendsLayer('cctv'), showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), }, diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 65c5c724d..bc9c83c9b 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -541,9 +541,12 @@ test('switching the layer off collapses the panel it opened', (t) => { // The layer keeps its active camera while off. controls._renderCctvState({ ...activeState, enabled: false }); assert.deepEqual(calls.at(-1), ['cctv-panel', true]); - // Later notifications while off leave a panel the user reopened alone. + // A panel reopened by hand stays open through later notifications while + // the layer is off: only the on → off change collapses. + controls._cctvPanel.classList.remove('collapsed'); controls._renderCctvState({ ...activeState, enabled: false }); assert.equal(calls.length, 2); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); }); test('a layer that was never on leaves an open panel alone', (t) => { @@ -565,22 +568,34 @@ test('the data manager switching the layer off collapses the panel too', (t) => assert.deepEqual(calls.at(-1), ['cctv-panel', true]); }); -test('a context-mode layer clear keeps the panel open', (t) => { +test('a layer the application suspends keeps its panel through the round trip', (t) => { const { controls } = panel(t); const calls = collapseCalls(controls); - controls.actions.preservePanelStateDuringClear = () => true; + // Clear selected data layers and Context isolation both answer true. + let suspended = true; + controls.actions.preservePanelState = () => suspended; controls._renderCctvState(activeState); controls._renderCctvState({ ...activeState, enabled: false }); - assert.deepEqual(calls, [['cctv-panel', false]]); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + suspended = false; + controls._renderCctvState(activeState); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + assert.ok(calls.every(([, collapsed]) => collapsed === false)); + // The next switch-off is the user's again. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); }); -test('an already collapsed panel is not collapsed again', (t) => { +test('a panel the accordion folded still has its saved state closed', (t) => { const { controls } = panel(t); controls._renderCctvState(activeState); - const calls = collapseCalls(controls); + const calls = []; + controls.actions.setPanelCollapsed = (...args) => calls.push(args); + // Folded for layout only: the class is there, the saved state is open. The + // panel chrome tells the two apart, so the controls always ask it. controls._cctvPanel.classList.add('collapsed'); controls._renderCctvState({ ...activeState, enabled: false }); - assert.deepEqual(calls, []); + assert.deepEqual(calls, [['cctv-panel', true]]); }); test('the meta line of a disabled layer does not describe its last camera', () => { diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index eff9d528b..2f4cb8584 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -192,13 +192,14 @@ export function _renderCctvState(state) { this._lastSeenCctvActiveId = effectiveActiveId; // Switching the layer off closes the panel it opened: nothing is left to // show. Only the on → off change collapses, so a panel the user opens - // while the layer is off stays open, and a context-mode layer clear keeps - // the panel for the layers it restores. + // while the layer is off stays open. Clear selected data layers and + // Context isolation keep the panel: the layer comes back with it. The + // panel chrome decides whether anything changes: a panel the desktop + // accordion only folded still has an open saved state to close. if ( this._wasCctvEnabled && !enabled && - !this.actions.preservePanelStateDuringClear?.() && - !this._cctvPanel?.classList?.contains('collapsed') + !this.actions.preservePanelState?.() ) { this.actions.setPanelCollapsed('cctv-panel', true); } diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 81da5f20e..552a9e3e9 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -59,6 +59,15 @@ export class ContextControls { showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } + /** + * Whether Context isolation switched this layer off and leaving the mode + * restores it. Such a layer is suspended, not switched off by the user. + * @param {string} layerId Layer id. + * @returns {boolean} + */ + suspendsLayer(layerId) { + return Boolean(this._contextSessionSnapshot?.enabledLayerIds?.has(layerId)); + } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 33b3c4d10..4a4e69a9f 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -153,3 +153,39 @@ test('a rejected tracked reaction settles without an unhandled rejection', async await controls._waitForContextLayerSettlement(); assert.equal(controls._contextLayerReactionPromises.size, 0); }); + +test('a layer Context isolation switches off is suspended until the mode is left', async (t) => { + const { controls } = fixture(t); + const enabled = new Set(['cctv']); + // What the CCTV panel is told at each visibility change of its layer. + const seen = []; + controls._dataManager = { + layers: new Map([['cctv', {}]]), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => null, + setLayerParams() {}, + isEnabled: (layerId) => enabled.has(layerId), + isEffectivelyEnabled: (layerId) => enabled.has(layerId), + async setEnabled(layerId, next) { + if (next) enabled.add(layerId); + else enabled.delete(layerId); + seen.push([layerId, next, controls.suspendsLayer(layerId)]); + return true; + }, + async restoreEnabledLayerIds(layerIds) { + for (const layerId of layerIds) await this.setEnabled(layerId, true); + }, + }; + assert.equal(controls.suspendsLayer('cctv'), false); + controls._captureContextSessionSnapshot(); + await controls._clearLayersOutsideContextMode('flights'); + assert.deepEqual(seen, [['cctv', false, true]]); + assert.equal(controls.suspendsLayer('cctv'), true); + assert.equal(controls.suspendsLayer('street-level'), false); + await controls._restoreContextSession(); + assert.equal(enabled.has('cctv'), true); + assert.equal(controls.suspendsLayer('cctv'), false); + // A switch-off after the mode is left is the user's own. + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); +}); diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 678b69c79..276a95547 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -891,6 +891,10 @@ export class ShellFacade { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _contextSuspendsLayer(layerId) { + return this._contextControls?.suspendsLayer(layerId) ?? false; + } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); }