diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a092350c7..47690bc30 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -812,7 +812,11 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed stage coordinates, downrange distance and flight numbers remain unknown; the mission panel omits the flight-number label when no number was reported. Pending mission requests abort on disable/destruction and malformed launch -snapshots preserve the last accepted display. Catalog groups, propagation cadence, +snapshots preserve the last accepted display. A Launch Library stale-cache fallback +retains its missions but reports STALE in Data Layers; a fresh snapshot clears that +provenance. Hiding retained missions does not relabel them as fresh, and teardown +clears the instance state. Injected getLaunches-only sources remain supported. +Catalog groups, propagation cadence, tracking intent, replay timing, controls and source attribution remain unchanged. The fire layer now exposes `./layers/firms`: an instance factory with separate diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ff9abe8c..b5e75f077 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -447,6 +447,7 @@ "src/data/rocketLaunches.js", "src/layers/satellites/source.test.mjs", "src/layers/launches/source.test.mjs", + "src/layers/launches/ingestion.test.mjs", "src/layers/installations/lifecycle.js", "src/layers/installations/viewport.js", "src/layers/installations/controls.js", diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d55394bcc..b0dc035cb 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + stale: layerState._stale, }; }, diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index e7eab0565..bd1529c0b 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -168,9 +168,16 @@ export function createIngestion({ async function performMissionUpdate(token) { try { ensureActiveTleLookup(token); - const payload = await source.getLaunches({ - signal: layerState._sourceController.signal, - }); + const { payload, stale } = source.getLaunchSnapshot + ? await source.getLaunchSnapshot({ + signal: layerState._sourceController.signal, + }) + : { + payload: await source.getLaunches({ + signal: layerState._sourceController.signal, + }), + stale: false, + }; const launches = parts.model.normalizeRocketLaunches(payload); if ( !layerState._enabled || @@ -214,6 +221,7 @@ export function createIngestion({ parts.panel.renderMissionPanel(); layerState._lastUpdate = Date.now(); layerState._lastError = null; + layerState._stale = stale === true; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; diff --git a/src/layers/launches/ingestion.test.mjs b/src/layers/launches/ingestion.test.mjs new file mode 100644 index 000000000..c19cb40e4 --- /dev/null +++ b/src/layers/launches/ingestion.test.mjs @@ -0,0 +1,315 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import * as Cesium from 'cesium'; +import { createLaunchSource } from './source.js'; +import { createState } from './state.js'; +import { createModel } from './model.js'; +import { createIngestion } from './ingestion.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { layerFeedState } from '../../data/feedState.js'; + +const noop = () => {}; + +async function fixture({ legacySource = false } = {}) { + const launch = { + id: 'owned-launch', + name: 'Owned mission fixture', + net: new Date(Date.now() - 3600000).toISOString(), + status: { name: 'Launch Successful' }, + pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 }, + rocket: { payloads: [] }, + }; + const mode = { cache: 'HIT', results: [launch], hold: false }; + let arrival; + let release; + const server = http.createServer((req, res) => { + if (req.url === '/api/celestrak/active') { + res.end(''); + return; + } + assert.equal(req.url, '/api/launches'); + const send = () => { + if (res.destroyed) return; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'X-GEV-Cache': mode.cache, + }); + res.end(JSON.stringify({ results: mode.results })); + }; + if (mode.hold) { + release = send; + arrival?.(); + } else send(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const launchSource = createLaunchSource({ + fetchImpl: (url, options) => { + assert.ok(['/api/launches', '/api/celestrak/active'].includes(url)); + return fetch(new URL(url, origin), options); + }, + }); + const source = legacySource + ? { + getLaunches: launchSource.getLaunches, + getActiveTle: launchSource.getActiveTle, + } + : launchSource; + const services = { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + render: { holdContinuousRender: noop, releaseContinuousRender: noop }, + }; + const state = createState({ services }); + state._enabled = true; + state._lifecycleToken = 1; + state._dataSource = new Cesium.CustomDataSource('owned-qualification'); + const canvas = Object.assign(new EventTarget(), { + disableRootEvents: true, + onwheel: null, + style: {}, + }); + const viewer = { + dataSources: new Cesium.DataSourceCollection(), + scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, + }; + await viewer.dataSources.add(state._dataSource); + // Only presentation boundaries are inert. Source, normalization, state, + // ingestion, lifecycle, stats and feed-state policy execute unchanged. + const parts = { + panel: { + createMissionPanel: noop, + renderMissionRoster: noop, + renderMissionPanel: noop, + clearMissionRosterHover: noop, + }, + replay: { stopMissionReplay: noop }, + selection: { + setSelectedMission: noop, + focusMission: noop, + stopMissionZoomAnchor: noop, + }, + orbitRendering: { + removeMissionOrbitPrimitives: noop, + syncMissionOrbitPrimitiveVisibility: noop, + }, + overlays: { + createReplayVehicleOverlay: noop, + clearMissionOverlaySources: noop, + hideReplayVehicleOverlay: noop, + destroyReplayVehicleOverlay: noop, + }, + launchPad: { + initLaunchPadZonePrimitive: noop, + hideLaunchPadZone: noop, + destroyLaunchPadZonePrimitive: noop, + }, + rendering: { + updateMissionFrame: noop, + addLaunchEntity(record) { + state._dataSource.entities.add({ + id: record.id, + name: record.name, + position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat), + }); + }, + }, + }; + const context = { state, services, parts, source }; + parts.model = createModel(context); + parts.ingestion = createIngestion(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + const current = () => { + const stats = parts.controls.methods.getStats(); + return { + stats, + feed: layerFeedState(stats), + rendered: state._dataSource?.entities.values.map((x) => x.id) || [], + enabled: state._enabled, + }; + }; + return { + mode, + state, + source, + current, + viewer, + init: () => parts.lifecycle.methods.init(viewer), + update: () => parts.ingestion.methods.update(), + disable: () => parts.lifecycle.methods.disable(), + destroy: () => parts.lifecycle.methods.destroy(viewer), + holdArrival: () => + new Promise((resolve) => { + arrival = resolve; + }), + release: () => release?.(), + async close() { + await parts.lifecycle.methods.destroy(viewer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test('fresh launches remain nominal through ingestion and the layer stats', async () => { + const f = await fixture(); + try { + await f.update(); + const value = f.current(); + assert.equal(value.feed, 'nominal'); + assert.equal(value.stats.count, 1); + assert.deepEqual(value.rendered, ['owned-launch']); + assert.equal(value.stats.error, null); + assert.ok(value.stats.lastUpdate); + } finally { + await f.close(); + } +}); + +test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + assert.equal((await f.source.getLaunchSnapshot()).stale, true); + await f.update(); + const value = f.current(); + assert.equal(value.stats.count, 1); + assert.deepEqual(value.rendered, ['owned-launch']); + assert.equal(value.feed, 'stale'); + } finally { + await f.close(); + } +}); + +test('a fresh recovery clears stale launch provenance', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + const stale = f.current(); + f.mode.cache = 'MISS'; + await f.update(); + const recovered = f.current(); + assert.equal(recovered.feed, 'nominal'); + assert.equal(recovered.stats.count, 1); + assert.equal(recovered.stats.error, null); + assert.equal(stale.feed, 'stale'); + } finally { + await f.close(); + } +}); + +test('an empty stale snapshot still identifies its fallback provenance', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + f.mode.results = []; + await f.update(); + assert.equal(f.current().stats.count, 0); + assert.equal(f.current().feed, 'stale'); + } finally { + await f.close(); + } +}); + +test('getLaunches-only injected sources remain supported', async () => { + const f = await fixture({ legacySource: true }); + try { + await f.update(); + assert.equal(f.current().feed, 'nominal'); + assert.deepEqual(f.current().rendered, ['owned-launch']); + } finally { + await f.close(); + } +}); + +test('disable cancels a pending stale response without committing it', async () => { + const f = await fixture(); + try { + await f.update(); + const healthy = f.current(); + f.mode.cache = 'STALE-ERROR'; + f.mode.hold = true; + const arrived = f.holdArrival(); + const pending = f.update(); + await arrived; + await f.disable(); + f.release(); + await pending; + const value = f.current(); + assert.equal(value.enabled, false); + assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate); + assert.equal(value.feed, 'nominal'); + assert.equal(f.state._sourceController.signal.aborted, true); + } finally { + await f.close(); + } +}); + +test('hiding retained stale records does not relabel them as fresh', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + await f.disable(); + assert.equal(f.current().feed, 'stale'); + assert.equal(f.current().stats.count, 1); + } finally { + await f.close(); + } +}); + +test('destroy clears the previous feed state and records', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + await f.destroy(); + const value = f.current(); + assert.equal(value.stats.count, 0); + assert.equal(value.stats.lastUpdate, null); + assert.equal(value.feed, 'nominal'); + assert.equal(value.enabled, false); + } finally { + await f.close(); + } +}); + +test('destroy followed by actual init starts a clean instance with owned handlers', async () => { + const f = await fixture(); + try { + f.mode.cache = 'STALE-ERROR'; + await f.update(); + await f.destroy(); + await f.init(); + const value = f.current(); + assert.equal(value.feed, 'nominal'); + assert.equal(value.stats.count, 0); + assert.equal(value.stats.lastUpdate, null); + assert.equal(value.stats.error, null); + assert.equal(value.enabled, false); + assert.equal(f.viewer.dataSources.length, 1); + assert.equal(f.viewer.scene.preRender.numberOfListeners, 1); + const click = f.state._clickHandler; + const move = f.state._moveHandler; + assert.ok(click instanceof Cesium.ScreenSpaceEventHandler); + assert.ok(move instanceof Cesium.ScreenSpaceEventHandler); + assert.equal( + typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK), + 'function', + ); + await f.destroy(); + assert.equal(click.isDestroyed(), true); + assert.equal(move.isDestroyed(), true); + assert.equal(f.viewer.dataSources.length, 0); + assert.equal(f.viewer.scene.preRender.numberOfListeners, 0); + } finally { + await f.close(); + } +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 58d70d34b..75b0276a3 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -20,6 +20,7 @@ export function createLifecycle({ viewer.dataSources.add(layerState._dataSource); layerState._count = 0; layerState._lastUpdate = null; + layerState._stale = false; layerState._lastError = null; layerState._orbitMatches = 0; parts.overlays.clearMissionOverlaySources(); @@ -160,6 +161,7 @@ export function createLifecycle({ layerState._count = 0; layerState._orbitMatches = 0; layerState._lastUpdate = null; + layerState._stale = false; layerState._activeTleText = null; layerState._activeTlePromise = null; layerState._activeTlePromiseToken = 0; diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 0f0194221..d70be5b54 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._stale = false; + state._orbitMatches = 0; state._clickHandler = null;