From f29b0b26391fb70a1de1c0a87a7f40e4737443f8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 13 Nov 2026 17:46:43 +0000 Subject: [PATCH] Keep the CCTV panel through Context isolation, and collapse through the panel chrome Contacts and Space Missions switch CCTV off to isolate themselves and restore it on the way out: the panel and its share preference now stay as they are for that round trip, as they do for Clear selected data layers. The controls no longer read the collapsed class before asking: the panel chrome tells a panel the accordion folded from one whose saved state is closed. The camera controls gate asserts the collapse, reopens CCTV from the right rail before switching it back on, and runs a Contacts entry and exit with the panel open. --- .../1723-cctv-panel-collapse-on-disable.md | 5 +- docs/CURRENT-STATE.md | 10 ++- scripts/qa-camera-controls.mjs | 75 +++++++++++++++++++ src/ui/applicationShell.js | 7 +- src/ui/cctvPanel.test.mjs | 29 +++++-- src/ui/cctvPresentation.js | 9 ++- src/ui/contextControls.js | 9 +++ src/ui/contextControls.test.mjs | 36 +++++++++ src/ui/shellFacade.js | 4 + 9 files changed, 166 insertions(+), 18 deletions(-) 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); }