mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Roll back a Space Missions entry whose mission index fails to load
The launches layer swallowed every fetch error in its update, so the manager treated a 429 or timeout on /api/launches as a successful start. Context then committed the isolating mode with no missions: the other layers stayed off, unrelated enables were refused, the camera stayed at the full-globe view and the roster kept reading "loading". Until the first index loads, the update now reports the failure. The manager fails the enable closed and both entry routes roll back to the pre-entry layers. The camera pose captured at entry is restored when the mode is left before any index loaded, and the roster shows a failed state instead of loading. A valid empty index still enters, and a refresh failure after a loaded index keeps its records.
This commit is contained in:
1 parent
7d8b80db0a
commit
5c04b12164
18 files changed
+424
-6
No files matched your search
@@ -5169,7 +5169,7 @@ silently demoting every later lookup for the session.
|
||||
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
|
||||
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
|
||||
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers and the camera returns to its pre-entry view instead of committing an empty, isolating mode. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. The roster reads `LOADING 30-DAY MISSION INDEX` while that first load is pending and `30-DAY MISSION INDEX UNAVAILABLE` once it failed. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
|
||||
@@ -474,6 +474,7 @@
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
"AVAILABLE MISSIONS": "المهام المتاحة",
|
||||
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
|
||||
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
|
||||
"RADIO": "الراديو",
|
||||
"ENABLE": "تفعيل",
|
||||
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
|
||||
|
||||
@@ -760,6 +760,7 @@
|
||||
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
|
||||
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
|
||||
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
|
||||
"Internet radio and local SDR": "Internetradio und lokales SDR",
|
||||
"STATION TAG": "KATEGORIE",
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
|
||||
"RADIO": "RADIO",
|
||||
"ENABLE": "ACTIVAR",
|
||||
"Previous filtered radio station": "Emisora de radio filtrada anterior",
|
||||
|
||||
@@ -2799,6 +2799,7 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
|
||||
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
|
||||
"Internet radio and local SDR": "Radio Internet et SDR local",
|
||||
"STATION TAG": "TAG DE STATION",
|
||||
|
||||
@@ -2799,6 +2799,7 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
|
||||
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
|
||||
"Internet radio and local SDR": "Radio internet e SDR locale",
|
||||
"STATION TAG": "TAG STAZIONE",
|
||||
|
||||
@@ -2657,6 +2657,7 @@
|
||||
"AVAILABLE MISSIONS": "利用可能なミッション",
|
||||
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
|
||||
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
|
||||
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
|
||||
"STATION TAG": "局タグ",
|
||||
|
||||
@@ -2801,6 +2801,7 @@
|
||||
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
|
||||
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
|
||||
"Internet radio and local SDR": "Rádio pela internet e SDR local",
|
||||
"STATION TAG": "TAG DA ESTAÇÃO",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"Contact Context actions": "Действия контекста контакта",
|
||||
"Available Space Missions": "Доступные космические миссии",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
|
||||
"Radio playback": "Воспроизведение радио",
|
||||
"Radio volume": "Громкость радио",
|
||||
"STATION TAG": "ТЕГ СТАНЦИИ",
|
||||
|
||||
@@ -2869,6 +2869,7 @@
|
||||
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
|
||||
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
|
||||
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
|
||||
"STATION TAG": "ТЕГ СТАНЦІЇ",
|
||||
|
||||
@@ -2556,6 +2556,7 @@
|
||||
"AVAILABLE MISSIONS": "可用任务",
|
||||
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
|
||||
"Internet radio and local SDR": "网络电台与本地 SDR",
|
||||
"STATION TAG": "电台标签",
|
||||
|
||||
@@ -2544,6 +2544,7 @@
|
||||
"AVAILABLE MISSIONS": "可用任務",
|
||||
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
|
||||
"Internet radio and local SDR": "網絡電臺與本地 SDR",
|
||||
"STATION TAG": "電臺標籤",
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// A Space Missions entry whose mission index never loads must not commit the
|
||||
// mode. The real manager, Context coordinator and launches ingestion run
|
||||
// against a local /api/launches that answers 429, so the test follows the
|
||||
// same failure the browser sees: isolation clears the other layers, the first
|
||||
// update fails, and the entry rolls back to the layers and view it replaced.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DataLayerManager } from '../../data/manager.js';
|
||||
import { ContextControls } from '../../ui/contextControls.js';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createPolicyHelpers } from './policyHelpers.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const launchRecord = {
|
||||
id: 'fixture-launch',
|
||||
name: 'Fixture mission',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
|
||||
function plainLayer(id, name, params = {}) {
|
||||
let current = { ...params };
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
update: noop,
|
||||
getParams: () => ({ ...current }),
|
||||
setParams(next) {
|
||||
current = { ...current, ...next };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCamera() {
|
||||
const camera = {
|
||||
position: new Cesium.Cartesian3(1, 2, 3),
|
||||
heading: 0.1,
|
||||
pitch: -0.5,
|
||||
roll: 0,
|
||||
flights: 0,
|
||||
cancelled: 0,
|
||||
get positionWC() {
|
||||
return camera.position;
|
||||
},
|
||||
flyTo({ destination }) {
|
||||
camera.flights += 1;
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = 0;
|
||||
camera.pitch = -Math.PI / 2;
|
||||
},
|
||||
cancelFlight() {
|
||||
camera.cancelled += 1;
|
||||
},
|
||||
setView({ destination, orientation }) {
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = orientation.heading;
|
||||
camera.pitch = orientation.pitch;
|
||||
camera.roll = orientation.roll;
|
||||
},
|
||||
};
|
||||
return camera;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const upstream = { status: 429, results: [launchRecord] };
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/celestrak/active') {
|
||||
res.end('');
|
||||
return;
|
||||
}
|
||||
if (upstream.status !== 200) {
|
||||
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: upstream.results }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const source = createLaunchSource({
|
||||
fetchImpl: (url, options) => fetch(new URL(url, origin), options),
|
||||
});
|
||||
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const camera = fakeCamera();
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
// Scene presentation is inert; source, ingestion, lifecycle, panel views
|
||||
// and the satellites dependency run unchanged.
|
||||
const parts = {
|
||||
overlays: {
|
||||
shortMissionLabel: (name) => name,
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop, presentReplay: () => null },
|
||||
selection: {
|
||||
focusFullGlobe: (target) =>
|
||||
target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }),
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
entityLaunchId: () => null,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.policyHelpers = createPolicyHelpers(context);
|
||||
parts.model = createModel(context);
|
||||
parts.panel = createPanel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const missions = Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion.methods,
|
||||
);
|
||||
const rosters = [];
|
||||
parts.panel.subscribePanel((view) => rosters.push(view.roster));
|
||||
|
||||
const manager = new DataLayerManager(viewer);
|
||||
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
|
||||
manager.register(plainLayer('satellites', 'Satellites'));
|
||||
manager.register(plainLayer('wind', 'Wind'));
|
||||
manager.register(missions);
|
||||
missions.attachDataManager(manager);
|
||||
|
||||
const toasts = [];
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: noop,
|
||||
showToast: (message) => toasts.push(message),
|
||||
setClearBusy: noop,
|
||||
syncDetection: noop,
|
||||
scheduleLayout: noop,
|
||||
refreshRadio: noop,
|
||||
setPanelCollapsed: noop,
|
||||
},
|
||||
});
|
||||
controls.connect(manager);
|
||||
|
||||
t.after(async () => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
|
||||
await manager.setEnabled(id, false);
|
||||
await missions.destroy(viewer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
|
||||
const view = () => ({
|
||||
x: camera.position.x,
|
||||
y: camera.position.y,
|
||||
z: camera.position.z,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
});
|
||||
const settle = async () => {
|
||||
await controls._waitForContextLayerSettlement();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
|
||||
await manager.waitForLayerSettled(id);
|
||||
await controls._waitForContextLayerSettlement();
|
||||
};
|
||||
return {
|
||||
upstream,
|
||||
manager,
|
||||
controls,
|
||||
missions,
|
||||
state,
|
||||
camera,
|
||||
rosters,
|
||||
toasts,
|
||||
enabledIds,
|
||||
view,
|
||||
settle,
|
||||
};
|
||||
}
|
||||
|
||||
test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
const before = f.view();
|
||||
|
||||
const entered = await f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await f.settle();
|
||||
|
||||
assert.equal(entered, false, 'a failed mission index fails the enable');
|
||||
assert.deepEqual(f.enabledIds(), ['earthquakes']);
|
||||
assert.equal(f.controls.getContextModeState().mode, null);
|
||||
assert.equal(f.controls.getContextModeState().active, false);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
|
||||
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
|
||||
|
||||
// Nothing is left isolating the workspace.
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('wind', true, { origin: 'user' }),
|
||||
true,
|
||||
'an unrelated layer is not blocked after the failed entry',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const before = f.view();
|
||||
|
||||
const result = await f.controls.setContextMode('space-missions', {
|
||||
claimVisualAuthority: false,
|
||||
});
|
||||
await f.settle();
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(f.enabledIds(), ['earthquakes']);
|
||||
assert.equal(f.controls.getContextModeState().mode, null);
|
||||
assert.deepEqual(f.view(), before);
|
||||
});
|
||||
|
||||
test('the roster reports a failed mission index instead of loading forever', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
|
||||
const roster = f.rosters.at(-1);
|
||||
assert.ok(roster, 'a failed load publishes a roster view');
|
||||
assert.deepEqual(roster.entries, []);
|
||||
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
|
||||
|
||||
// A new attempt starts from the loading state, then shows the index.
|
||||
f.upstream.status = 200;
|
||||
const loadingFrom = f.rosters.length;
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
|
||||
assert.equal(f.rosters.at(-1).entries.length, 1);
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
});
|
||||
|
||||
test('a valid empty mission index is a successful entry', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
f.upstream.results = [];
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
assert.equal(
|
||||
f.rosters.at(-1).empty,
|
||||
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
|
||||
);
|
||||
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
|
||||
});
|
||||
|
||||
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
const framed = f.view();
|
||||
|
||||
f.upstream.status = 429;
|
||||
await f.missions.update();
|
||||
await f.settle();
|
||||
|
||||
assert.equal(f.manager.isEnabled('rocket-launches'), true);
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
assert.equal(f.missions.getStats().count, 1);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.equal(f.rosters.at(-1).entries.length, 1);
|
||||
|
||||
// Leaving a mode that loaded keeps today's camera behaviour.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), framed);
|
||||
});
|
||||
@@ -50,8 +50,9 @@ export function createIngestion({
|
||||
layerState._postTleRetryCount++;
|
||||
layerState._retryTimer = setTimeout(() => {
|
||||
layerState._retryTimer = null;
|
||||
// A failed retry is already recorded in the layer stats.
|
||||
if (layerState._enabled && token === layerState._lifecycleToken)
|
||||
requestMissionUpdate();
|
||||
requestMissionUpdate().catch(() => {});
|
||||
}, POST_TLE_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
@@ -201,6 +202,10 @@ export function createIngestion({
|
||||
if (layerState._renderedTleText === layerState._activeTleText)
|
||||
clearPostTleRetry();
|
||||
layerState._count = launches.length;
|
||||
// The roster reads these to tell a loaded empty index from loading.
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
parts.panel.renderMissionRoster();
|
||||
if (
|
||||
!layerState._selectedLaunchId ||
|
||||
@@ -219,13 +224,19 @@ export function createIngestion({
|
||||
}
|
||||
layerState._focusAfterActiveLookup = false;
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
layerState._entryCameraView = null;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
console.warn('[Data:RocketLaunches] Fetch error:', error);
|
||||
// Without one usable index there is no mode to enter: report the
|
||||
// failure so the manager fails the enable closed and Context rolls
|
||||
// back. A refresh after a loaded index keeps its records instead.
|
||||
if (layerState._lastUpdate === null) {
|
||||
parts.panel.renderMissionRoster();
|
||||
throw error;
|
||||
}
|
||||
layerState._entryCameraView = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,28 @@ export function createLaunchHover({
|
||||
};
|
||||
}
|
||||
|
||||
/** The camera pose to give back, in world coordinates. */
|
||||
function captureCameraView(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera?.positionWC) return null;
|
||||
return {
|
||||
destination: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop the full-globe flight and return to a captured pose. */
|
||||
function restoreCameraView(viewer, view) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera) return;
|
||||
camera.cancelFlight?.();
|
||||
camera.setView(view);
|
||||
}
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -200,6 +222,14 @@ export function createLifecycle({
|
||||
layerState._updateDirty = false;
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
if (layerState._lastUpdate === null) {
|
||||
// A new attempt at an index that never loaded starts from loading.
|
||||
layerState._lastError = null;
|
||||
parts.panel.renderMissionRoster();
|
||||
}
|
||||
// Kept until the mission index loads, so an entry that never does
|
||||
// gives the view back.
|
||||
layerState._entryCameraView = captureCameraView(layerState._viewer);
|
||||
parts.selection.focusFullGlobe(layerState._viewer);
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.selection.setSelectedMission(
|
||||
@@ -214,6 +244,9 @@ export function createLifecycle({
|
||||
hover?.reset();
|
||||
layerState._sourceController.abort();
|
||||
layerState._enabled = false;
|
||||
const entryView = layerState._entryCameraView;
|
||||
layerState._entryCameraView = null;
|
||||
if (entryView) restoreCameraView(layerState._viewer, entryView);
|
||||
releaseContinuousRender('rocket-launches');
|
||||
layerState._lifecycleToken++;
|
||||
layerState._updateDirty = false;
|
||||
@@ -239,6 +272,7 @@ export function createLifecycle({
|
||||
layerState._sourceController.abort();
|
||||
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
layerState._entryCameraView = null;
|
||||
layerState._lifecycleToken++;
|
||||
layerState._updateDirty = false;
|
||||
await parts.ingestion.restoreSatelliteDependency();
|
||||
|
||||
@@ -263,8 +263,19 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** The roster entries as the list shows them. */
|
||||
/**
|
||||
* The roster entries as the list shows them; null while the first index
|
||||
* is still loading, and an empty failed roster once that load failed.
|
||||
*/
|
||||
function presentRoster() {
|
||||
if (layerState._lastUpdate === null && !layerState._launches.length) {
|
||||
if (!layerState._lastError) return null;
|
||||
return {
|
||||
count: '—',
|
||||
entries: [],
|
||||
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
|
||||
};
|
||||
}
|
||||
const entries = parts.policyHelpers
|
||||
.missionRosterEntries(layerState._launches)
|
||||
.map(({ launch, index }) => {
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._entryCameraView = null;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
Reference in new issue
Block a user