diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 41856b54e..b256e624d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1109,5 +1109,6 @@ "src/app/layers/qaSources.test.mjs", "scripts/qa-w1-layers.mjs", "src/bench/w1Feeds.js", - "scripts/qa-map-switch.mjs" + "scripts/qa-map-switch.mjs", + "scripts/qa-engine-notice.mjs" ] diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index df7766019..2d1db3053 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -130,7 +130,7 @@ export function createLoadingFeedbackState() { export function createGlobalStatusNotice( message, nowMs = 0, - { state = 'error', detail = '', persistent = false } = {}, + { state = 'error', detail = '', persistent = false, dwellMs } = {}, ) { const label = String(message || '').trim(); if (!label) return null; @@ -139,7 +139,11 @@ export function createGlobalStatusNotice( label, detail: String(detail || '').trim(), persistent: !!persistent, - dwellMs: persistent ? null : LOADING_FAILURE_DWELL_MS, + dwellMs: persistent + ? null + : Number.isFinite(dwellMs) && dwellMs > 0 + ? dwellMs + : LOADING_FAILURE_DWELL_MS, // A finite notice starts its dwell only when it first wins presentation. // Otherwise a higher-priority manager failure could consume the whole // deadline while this notice remained queued and invisible. diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index c7bd64cf6..dd64b6dcf 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,6 +1,9 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { APPLICATION_LAYER_DIRECTORY } from '../app/layers/directory.js'; +/** How long the "kept in the link" notice stays up (ms). */ +export const NOT_ON_THIS_ENGINE_DWELL_MS = 15_000; + /** Names of the layers a link turned on that this engine does not have. */ export function layersNotOnThisEngine(results) { return results @@ -189,6 +192,9 @@ export class ShareRestoration { this.showStatus('NOT ON THIS ENGINE', { state: 'retry', detail: `Kept in the link: ${names.join(', ')}`, + // Long enough to read a list of layer names; the engine's own + // persistent notice comes back afterwards (ShellFeedback). + dwellMs: NOT_ON_THIS_ENGINE_DWELL_MS, }); } cancelSelection() { diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index 359e885d1..27eff1ed5 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -17,6 +17,9 @@ export class ShellFeedback { this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); this._loadingFeedbackEvent = null; this._globalStatusNotice = null; + // A persistent notice (the Navara engine notice) that a finite one + // covers for its dwell; it comes back when the finite one ends. + this._coveredPersistentNotice = null; this._loadingFeedbackTicker = this._trafficChipTicker = this._toastTimer = @@ -111,7 +114,8 @@ export class ShellFeedback { Number.isFinite(this._globalStatusNotice?.hideAt) && now >= this._globalStatusNotice.hideAt ) { - this._globalStatusNotice = null; + this._globalStatusNotice = this._coveredPersistentNotice; + this._coveredPersistentNotice = null; } // Loading phases and universal notices both have time-driven transitions. // Compute this after arbitration: a queued finite notice starts its dwell @@ -138,7 +142,21 @@ export class ShellFeedback { _showGlobalStatusNotice(message, options = {}) { if (this.destroyed) return; const now = performance.now(); - this._globalStatusNotice = createGlobalStatusNotice(message, now, options); + const next = createGlobalStatusNotice(message, now, options); + const current = this._globalStatusNotice; + if (next?.persistent && current && !current.persistent) { + // A persistent notice arriving under a live finite one (the engine + // notice after a share link's "kept in the link") waits for it. + this._coveredPersistentNotice = next; + this._updateGlobalLoadingFeedback(now); + return; + } + if (next && !next.persistent) { + if (current?.persistent) this._coveredPersistentNotice = current; + } else { + this._coveredPersistentNotice = null; + } + this._globalStatusNotice = next; this._updateGlobalLoadingFeedback(now); } diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs index 7d48ffb06..5e6af5ae6 100644 --- a/src/ui/shellLifecycle.test.mjs +++ b/src/ui/shellLifecycle.test.mjs @@ -404,3 +404,72 @@ test('busy traffic completion is polled promptly without accelerating idle or gl f.restore(); } }); + +test('a finite notice covers the persistent engine notice only for its dwell', () => { + const f = fixture(); + const realNow = performance.now; + let now = 1_000; + performance.now = () => now; + try { + const owner = new ShellFeedback({ readLayers: () => [] }); + const detail = () => f.nodes.get('global-loading-detail').textContent; + owner._showGlobalStatusNotice('Experimental Navara engine', { + state: 'retry', + detail: 'No data layers on this engine yet', + persistent: true, + }); + assert.equal(detail(), 'No data layers on this engine yet'); + owner._showGlobalStatusNotice('NOT ON THIS ENGINE', { + state: 'retry', + detail: 'Kept in the link: CCTV', + dwellMs: 15_000, + }); + assert.equal(detail(), 'Kept in the link: CCTV'); + now += 14_000; + owner._updateGlobalLoadingFeedback(now); + assert.equal(detail(), 'Kept in the link: CCTV', 'still up after 14 s'); + now += 2_000; + owner._updateGlobalLoadingFeedback(now); + owner._updateGlobalLoadingFeedback(now); + assert.equal( + detail(), + 'No data layers on this engine yet', + 'the engine notice comes back', + ); + assert.equal(owner._globalStatusNotice?.persistent, true); + owner.destroy(); + } finally { + performance.now = realNow; + f.restore(); + } +}); + +test('a persistent notice that arrives under a finite one waits for it', () => { + const f = fixture(); + const realNow = performance.now; + let now = 1_000; + performance.now = () => now; + try { + const owner = new ShellFeedback({ readLayers: () => [] }); + const detail = () => f.nodes.get('global-loading-detail').textContent; + owner._showGlobalStatusNotice('NOT ON THIS ENGINE', { + state: 'retry', + detail: 'Kept in the link: Earthquakes', + dwellMs: 15_000, + }); + owner._showGlobalStatusNotice('Experimental Navara engine', { + state: 'retry', + detail: 'No data layers on this engine yet', + persistent: true, + }); + assert.equal(detail(), 'Kept in the link: Earthquakes'); + now += 16_000; + owner._updateGlobalLoadingFeedback(now); + owner._updateGlobalLoadingFeedback(now); + assert.equal(detail(), 'No data layers on this engine yet'); + owner.destroy(); + } finally { + performance.now = realNow; + f.restore(); + } +});