diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d8b4c6d6a..9adc88922 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -14,10 +14,10 @@ export function createControls({ state: layerState, services, parts, source }) { releaseCameraOwnership() { // Navigation takes the camera; a failed entry must not restore the old view. layerState._entryCameraView = null; + layerState._cameraReleased = true; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); - layerState._cameraReleased = true; }, /** @@ -60,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + errorAt: layerState._lastErrorAt, stale: layerState._stale, }; }, diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index 52adcb792..4330cf0d8 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -6,6 +6,7 @@ import http from 'node:http'; import * as Cesium from 'cesium'; import { DataLayerManager } from '../../data/manager.js'; import { ContextControls } from '../../ui/contextControls.js'; +import { spaceMissionsIndexFailure } from '../../ui/contextActions.js'; import { createLaunchSource } from './source.js'; import { createState } from './state.js'; import { createModel } from './model.js'; @@ -454,7 +455,6 @@ test('a refresh failure after a loaded index keeps the mode and its records', as await f.settle(); assert.deepEqual(f.view(), framed); }); -// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => { const f = await fixture(t); f.upstream.status = 200; @@ -473,10 +473,7 @@ test('exiting during a successful entry flight cancels the mission-owned flight' 'leaving Space Missions stops the flight it started (main cancels it)', ); }); -// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()). -// Encodes the "surface the reason where the user acted" fix: the roster that -// carries 30-DAY MISSION INDEX UNAVAILABLE is hidden by the rollback, so the -// lasting feedback (the toast) has to name the cause. +// The rollback hides the roster, so the toast has to name the cause. test('a failed first load tells the user the mission index is unavailable', async (t) => { const f = await fixture(t); await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); @@ -491,3 +488,29 @@ test('a failed first load tells the user the mission index is unavailable', asyn `visible feedback names the failure, got ${JSON.stringify(f.toasts)}`, ); }); + +test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const flight = f.camera.flight; + assert.ok(flight, 'the entry flight is in progress'); + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, flight, 'navigation decides about the flight'); + // After exit an inactive layer never cancels another owner's flight. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) }); + const other = f.camera.flight; + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, other); +}); + +test("an earlier attempt's index error is not blamed for a later failure", async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + assert.match(spaceMissionsIndexFailure(f.manager), /mission index/); + assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index 9994acb86..be6190815 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -228,6 +228,7 @@ export function createIngestion({ } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; + layerState._lastErrorAt = Date.now(); console.warn('[Data:RocketLaunches] Fetch error:', error); // No index yet: fail the enable so Context rolls back. // After a loaded index, a failed refresh keeps its records. diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6d87b47d8..13150e805 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -285,6 +285,7 @@ export function createLifecycle({ parts.ingestion.clearPostTleRetry(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); if (layerState._dataSource) layerState._dataSource.show = false; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); @@ -310,6 +311,7 @@ export function createLifecycle({ await parts.ingestion.restoreSatelliteDependency(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); parts.ingestion.clearPostTleRetry(); if (layerState._clickHandler) layerState._clickHandler.destroy(); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index beb8d1bf7..fd2ae7176 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._lastErrorAt = null; + state._entryCameraView = null; state._entryLandedView = null; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index 702130073..eeb22fefe 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -4,12 +4,13 @@ import { contextModeWord, } from '../contextModePolicy.js'; -/** Why a Space Missions entry failed, when its mission index never loaded. */ -export function spaceMissionsIndexFailure(dataManager) { +/** Why a Space Missions entry failed: its mission index never loaded, and + * the error is no older than `since` (an earlier attempt's is not the cause). */ +export function spaceMissionsIndexFailure(dataManager, since = 0) { const stats = dataManager?.layers ?.get('rocket-launches') ?.module?.getStats?.(); - return stats?.error && stats.lastUpdate == null + return stats?.error && stats.lastUpdate == null && stats.errorAt >= since ? t( 'Space Missions unavailable: the mission index could not load. Try again later.', ) diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index c5e4fc52e..8c6b8760e 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -60,11 +60,12 @@ export function _initGlobalContextPanel() { const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; this._claimContextVisualAuthority(); + const startedAt = Date.now(); void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode(nextMode, { notificationToken }), () => - (nextMode && spaceMissionsIndexFailure(this._dataManager)) || + (nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) || t( 'Space Missions could not complete the requested transition; try again', ),