mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
1503 lines
57 KiB
JavaScript
1503 lines
57 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as Cesium from 'cesium';
|
|
import { gstime } from 'satellite.js';
|
|
import { createModel } from '../layers/launches/model.js';
|
|
import rocketLaunchesLayer, {
|
|
_setRocketMissionOverlayHostForTest,
|
|
_setSelectedRocketMissionForTest,
|
|
approximateOrbitPath,
|
|
buildMissionPaths,
|
|
cameraHeadingForPath,
|
|
compactLaunchSiteName,
|
|
createMissionRosterPreviewOwnership,
|
|
createRocketMissionElementOverlayEntry,
|
|
createRocketMissionMarkerOverlayEntry,
|
|
formatMissionEventTime,
|
|
LAUNCH_PAD_ZONE_RADIUS_M,
|
|
launchPadZoneVisible,
|
|
launchStatusAllowsOrbit,
|
|
missionAnchorHorizonVisible,
|
|
missionAnchorVisible,
|
|
missionHoverPreviewRange,
|
|
missionDataCompleteness,
|
|
missionMarkerColor,
|
|
missionPathPresentation,
|
|
missionRosterEntries,
|
|
missionZoomPitch,
|
|
orbitProgressAtTime,
|
|
normalizeReplaySpeed,
|
|
normalizeRocketLaunches,
|
|
parseMissionDurationSeconds,
|
|
replayState,
|
|
replayCameraView,
|
|
replayChaseCameraHeading,
|
|
replayOrbitFrameSphere,
|
|
replayOrbitGlobeAnchor,
|
|
replayOrbitCameraTarget,
|
|
replayOrbitCameraPose,
|
|
replayOrbitGlobeRange,
|
|
replayOverlayMode,
|
|
replayInitialCameraHeading,
|
|
smoothReplayWindowPosition,
|
|
replayAscentDurationSeconds,
|
|
replayStartAfterPause,
|
|
replayVehicleScreenRotation,
|
|
resolveMissionRosterPreviewLaunch,
|
|
releaseAircraftTracking,
|
|
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
|
|
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
|
|
ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID,
|
|
ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID,
|
|
ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
|
samplePath,
|
|
satelliteParamsAfterSpaceMissions,
|
|
satelliteParamsForSpaceMissions,
|
|
shouldRetryAfterActiveTle,
|
|
selectRocketMissionMarkerOverlayCohort,
|
|
smoothReplayCameraHeading,
|
|
} from './rocketLaunches.js';
|
|
import {
|
|
findSatelliteOrbitTrackInTle,
|
|
orbitFrameModelMatrix,
|
|
satelliteCatalogModeChanged,
|
|
scoreSatelliteNameMatch,
|
|
} from './satellites.js';
|
|
|
|
const NOW = new Date('2026-07-27T00:00:00Z');
|
|
|
|
test('mission anchors are hidden behind Earth and restored on the facing hemisphere', () => {
|
|
const camera = Cesium.Cartesian3.fromDegrees(-75, 20, 18000000);
|
|
const front = Cesium.Cartesian3.fromDegrees(-75, 20);
|
|
const rear = Cesium.Cartesian3.fromDegrees(105, -20);
|
|
assert.equal(missionAnchorHorizonVisible(camera, front), true);
|
|
assert.equal(missionAnchorHorizonVisible(camera, rear), false);
|
|
});
|
|
|
|
test('selecting one mission hides every other front-facing launch anchor', () => {
|
|
const camera = Cesium.Cartesian3.fromDegrees(-75, 20, 18000000);
|
|
const first = Cesium.Cartesian3.fromDegrees(-75, 20);
|
|
const second = Cesium.Cartesian3.fromDegrees(-80, 25);
|
|
|
|
assert.equal(missionAnchorVisible(camera, first, 'first', null), true);
|
|
assert.equal(missionAnchorVisible(camera, second, 'second', null), true);
|
|
assert.equal(missionAnchorVisible(camera, first, 'first', 'first'), true);
|
|
assert.equal(missionAnchorVisible(camera, second, 'second', 'first'), false);
|
|
});
|
|
|
|
test('advances the live orbit marker between whole seconds', () => {
|
|
assert.equal(orbitProgressAtTime(10_000, 100), 0.1);
|
|
assert.equal(orbitProgressAtTime(10_250, 100), 0.1025);
|
|
assert.ok(orbitProgressAtTime(10_250, 100) > orbitProgressAtTime(10_000, 100));
|
|
});
|
|
|
|
test('samples replay paths uniformly by distance rather than vertex count', () => {
|
|
const path = [
|
|
new Cesium.Cartesian3(0, 0, 0),
|
|
new Cesium.Cartesian3(1, 0, 0),
|
|
new Cesium.Cartesian3(101, 0, 0),
|
|
];
|
|
assert.ok(Math.abs(samplePath(path, 0.5).x - 50.5) < 1e-9);
|
|
assert.ok(Math.abs(samplePath(path, 0.75).x - 75.75) < 1e-9);
|
|
});
|
|
|
|
test('transitions selected mission zoom from globe nadir to an oblique site view', () => {
|
|
assert.ok(Math.abs(missionZoomPitch(5000000) - Cesium.Math.toRadians(-90)) < 1e-10);
|
|
assert.ok(Math.abs(missionZoomPitch(180000) - Cesium.Math.toRadians(-42)) < 1e-10);
|
|
const midPitch = Cesium.Math.toDegrees(missionZoomPitch(1000000));
|
|
assert.ok(midPitch < -42 && midPitch > -90);
|
|
});
|
|
|
|
test('pulls replay camera from close ascent tracking into a globe-scale orbit view', () => {
|
|
const ascent = replayCameraView({ ascending: true, phaseProgress: 0.9 }, 120000);
|
|
const contextualAscent = replayCameraView({ ascending: true, phaseProgress: 0.9 }, 420000);
|
|
const orbitStart = replayCameraView({ ascending: false, phaseProgress: 0 }, 550000);
|
|
const orbitMidPullback = replayCameraView({ ascending: false, phaseProgress: 0.1 }, 550000);
|
|
const orbitGlobe = replayCameraView({ ascending: false, phaseProgress: 0.2 }, 550000);
|
|
|
|
assert.ok(ascent.pitch < Cesium.Math.toRadians(-20));
|
|
assert.ok(ascent.pitch > Cesium.Math.toRadians(-34));
|
|
assert.ok(contextualAscent.range > 1000000);
|
|
assert.equal(contextualAscent.pitch, Cesium.Math.toRadians(-34));
|
|
assert.equal(orbitStart.pitch, Cesium.Math.toRadians(-34));
|
|
assert.ok(orbitMidPullback.range > orbitStart.range);
|
|
assert.ok(orbitMidPullback.pitch < orbitStart.pitch);
|
|
assert.equal(orbitGlobe.range, 18000000);
|
|
assert.equal(orbitGlobe.pitch, Cesium.Math.toRadians(-45));
|
|
});
|
|
|
|
test('limits replay camera yaw through heading wraps', () => {
|
|
const previous = Cesium.Math.toRadians(359);
|
|
const desired = Cesium.Math.toRadians(181);
|
|
const next = smoothReplayCameraHeading(previous, desired);
|
|
assert.ok(Math.abs(Cesium.Math.negativePiToPi(next - previous)) <= Cesium.Math.toRadians(2));
|
|
|
|
const wrapped = smoothReplayCameraHeading(
|
|
Cesium.Math.toRadians(359),
|
|
Cesium.Math.toRadians(1),
|
|
);
|
|
assert.ok(Cesium.Math.negativePiToPi(wrapped - previous) > 0);
|
|
});
|
|
|
|
test('starts replay camera broadside to the ascent path', () => {
|
|
const path = [
|
|
Cesium.Cartesian3.fromDegrees(-120, 34, 0),
|
|
Cesium.Cartesian3.fromDegrees(-120, 35, 1000),
|
|
];
|
|
const chaseHeading = cameraHeadingForPath(path, 0);
|
|
const heading = replayInitialCameraHeading(path);
|
|
assert.ok(Math.abs(Cesium.Math.negativePiToPi(chaseHeading)) < 0.02);
|
|
assert.ok(Math.abs(Cesium.Math.negativePiToPi(heading - Cesium.Math.toRadians(90))) < 0.02);
|
|
});
|
|
|
|
test('keeps the chase camera in a rear-quarter view of the forward path', () => {
|
|
assert.ok(Math.abs(
|
|
Cesium.Math.negativePiToPi(replayChaseCameraHeading(0, 0) - Cesium.Math.toRadians(30)),
|
|
) < 1e-10);
|
|
assert.ok(Math.abs(
|
|
Cesium.Math.negativePiToPi(replayChaseCameraHeading(0, 1) - Cesium.Math.toRadians(45)),
|
|
) < 1e-10);
|
|
});
|
|
|
|
test('centers orbit follow on a globe-side anchor while retaining vehicle clearance', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(20, 10, 550000);
|
|
const anchor = replayOrbitGlobeAnchor(position, 1);
|
|
const anchorHeight = Cesium.Ellipsoid.WGS84.cartesianToCartographic(anchor).height;
|
|
assert.ok(Math.abs(anchorHeight - 55000) < 1);
|
|
const target = replayOrbitCameraTarget(anchor, Cesium.Cartesian3.ZERO, 1);
|
|
assert.ok(Cesium.Cartesian3.magnitude(target) > Cesium.Ellipsoid.WGS84.maximumRadius * 0.3);
|
|
assert.ok(Cesium.Cartesian3.distance(target, anchor) > 0);
|
|
assert.ok(Cesium.Cartesian3.distance(target, anchor) < Cesium.Cartesian3.magnitude(anchor));
|
|
assert.equal(replayOrbitGlobeRange(18000000, 550000, 1), 18000000);
|
|
assert.ok(replayOrbitGlobeRange(18000000, 35786000, 1) > 50000000);
|
|
});
|
|
|
|
test('keeps the forward orbit tangent moving toward screen-left', () => {
|
|
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
|
const position = new Cesium.Cartesian3(earthRadius + 550000, 0, 0);
|
|
const tangentPosition = new Cesium.Cartesian3(earthRadius + 550000, 10000, 0);
|
|
const target = new Cesium.Cartesian3(earthRadius * 0.35, 0, 0);
|
|
const pose = replayOrbitCameraPose(
|
|
position,
|
|
tangentPosition,
|
|
target,
|
|
18000000,
|
|
Cesium.Math.toRadians(-45),
|
|
);
|
|
assert.ok(pose);
|
|
const screenRight = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.cross(pose.direction, pose.up, new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const tangent = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(
|
|
tangentPosition,
|
|
position,
|
|
new Cesium.Cartesian3(),
|
|
),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
assert.ok(Cesium.Cartesian3.dot(screenRight, tangent) < -0.999);
|
|
assert.ok(Math.abs(
|
|
Cesium.Cartesian3.distance(pose.destination, target) - 18000000,
|
|
) < 1e-5);
|
|
});
|
|
|
|
test('frames the complete high-apogee orbit together with Earth', () => {
|
|
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
|
const orbit = [
|
|
new Cesium.Cartesian3(earthRadius + 300000, 0, 0),
|
|
new Cesium.Cartesian3(0, earthRadius + 35786000, 0),
|
|
new Cesium.Cartesian3(-(earthRadius + 300000), 0, 0),
|
|
new Cesium.Cartesian3(0, -(earthRadius + 35786000), 0),
|
|
];
|
|
const frame = replayOrbitFrameSphere(orbit);
|
|
const range = replayOrbitGlobeRange(18000000, 300000, 1, frame.radius);
|
|
assert.ok(frame.radius > earthRadius + 30000000);
|
|
assert.ok(range >= frame.radius * 3);
|
|
});
|
|
|
|
test('holds the completed replay on its final orbit frame instead of wrapping to launch', () => {
|
|
const launch = { launchTime: '2026-07-01T00:00:00Z', timeline: [] };
|
|
const completed = replayState(
|
|
launch,
|
|
0,
|
|
12,
|
|
28,
|
|
5400,
|
|
1,
|
|
40000,
|
|
0,
|
|
false,
|
|
);
|
|
assert.equal(completed.ascending, false);
|
|
assert.ok(completed.phaseProgress > 0.999);
|
|
});
|
|
|
|
test('smooths small replay marker reprojection jitter but snaps camera jumps', () => {
|
|
const smoothed = smoothReplayWindowPosition({ x: 100, y: 100 }, { x: 104, y: 103 });
|
|
assert.ok(smoothed.x > 100 && smoothed.x < 104);
|
|
assert.deepEqual(
|
|
smoothReplayWindowPosition({ x: 100, y: 100 }, { x: 140, y: 100 }),
|
|
{ x: 140, y: 100 },
|
|
);
|
|
});
|
|
|
|
test('shows the screen-space rocket only while replay is active', () => {
|
|
assert.equal(replayOverlayMode({
|
|
replayActive: false,
|
|
closeSelected: false,
|
|
ascending: true,
|
|
countdownActive: false,
|
|
}), null);
|
|
assert.equal(replayOverlayMode({
|
|
replayActive: false,
|
|
closeSelected: true,
|
|
ascending: false,
|
|
countdownActive: false,
|
|
}), null);
|
|
assert.equal(replayOverlayMode({
|
|
replayActive: true,
|
|
closeSelected: false,
|
|
ascending: true,
|
|
countdownActive: true,
|
|
}), 'countdown');
|
|
assert.equal(replayOverlayMode({
|
|
replayActive: true,
|
|
closeSelected: false,
|
|
ascending: true,
|
|
countdownActive: false,
|
|
}), 'ascent');
|
|
assert.equal(replayOverlayMode({
|
|
replayActive: true,
|
|
closeSelected: false,
|
|
ascending: false,
|
|
countdownActive: false,
|
|
}), 'orbit');
|
|
});
|
|
|
|
test('aligns the replay rocket nose to the projected path tangent', () => {
|
|
assert.equal(replayVehicleScreenRotation({ x: 10, y: 10 }, { x: 10, y: 0 }), 0);
|
|
assert.ok(Math.abs(
|
|
replayVehicleScreenRotation({ x: 10, y: 10 }, { x: 20, y: 10 })
|
|
- Math.PI / 2,
|
|
) < 1e-10);
|
|
assert.ok(Math.abs(
|
|
replayVehicleScreenRotation({ x: 10, y: 10 }, { x: 0, y: 10 })
|
|
+ Math.PI / 2,
|
|
) < 1e-10);
|
|
});
|
|
|
|
test('clamps and snaps ascent replay speed to supported quarter steps', () => {
|
|
assert.equal(normalizeReplaySpeed(0.1), 0.25);
|
|
assert.equal(normalizeReplaySpeed(0.62), 0.5);
|
|
assert.equal(normalizeReplaySpeed('1.75'), 1.75);
|
|
assert.equal(normalizeReplaySpeed(8), 4);
|
|
assert.equal(normalizeReplaySpeed('invalid'), 1);
|
|
});
|
|
|
|
test('shifts replay start time so pause duration does not advance the mission', () => {
|
|
assert.equal(replayStartAfterPause(1000, 4000, 9500), 6500);
|
|
assert.equal(replayStartAfterPause(1000, 9500, 4000), 1000);
|
|
assert.equal(replayStartAfterPause(1000, Number.NaN, 9500), 1000);
|
|
});
|
|
|
|
test('restores the complete standalone Satellite style after mission mode', () => {
|
|
const standalone = {
|
|
catalog: 'core',
|
|
showPoints: true,
|
|
showOrbits: true,
|
|
labelDensity: 'operator',
|
|
};
|
|
assert.deepEqual(satelliteParamsForSpaceMissions(standalone), {
|
|
...standalone,
|
|
catalog: 'dense',
|
|
showPoints: false,
|
|
showOrbits: false,
|
|
});
|
|
assert.deepEqual(satelliteParamsAfterSpaceMissions(standalone), standalone);
|
|
assert.deepEqual(satelliteParamsAfterSpaceMissions(null), {
|
|
catalog: 'core',
|
|
showPoints: true,
|
|
showOrbits: true,
|
|
});
|
|
});
|
|
|
|
test('suppresses every orbital representation for failed launches', () => {
|
|
assert.equal(launchStatusAllowsOrbit('Launch Successful'), true);
|
|
assert.equal(launchStatusAllowsOrbit('Go for Launch'), true);
|
|
assert.equal(launchStatusAllowsOrbit('Launch Failure'), false);
|
|
assert.equal(launchStatusAllowsOrbit('Partial Failure'), false);
|
|
assert.equal(launchStatusAllowsOrbit('Failed'), false);
|
|
});
|
|
|
|
test('keeps failed-launch mission details truthful without inventing paths', () => {
|
|
assert.deepEqual(missionPathPresentation({
|
|
status: 'Launch Failure',
|
|
orbit: { name: 'Geostationary Transfer Orbit' },
|
|
trajectory: [],
|
|
}, false), {
|
|
orbit: 'PLANNED · Geostationary Transfer Orbit',
|
|
ascent: 'UNAVAILABLE',
|
|
replayAvailable: false,
|
|
});
|
|
assert.deepEqual(missionPathPresentation({
|
|
status: 'Partial Failure',
|
|
orbit: { name: 'Low Earth Orbit' },
|
|
trajectory: [
|
|
{ latitude: 28.5, longitude: -80.5 },
|
|
{ latitude: 29, longitude: -79.5 },
|
|
],
|
|
}, false), {
|
|
orbit: 'PLANNED · Low Earth Orbit',
|
|
ascent: 'SUPPLIED TRAJECTORY POINTS',
|
|
replayAvailable: false,
|
|
});
|
|
});
|
|
|
|
test('describes only renderable successful mission paths', () => {
|
|
assert.deepEqual(missionPathPresentation({
|
|
status: 'Launch Successful',
|
|
orbit: { name: 'Low Earth Orbit' },
|
|
trajectory: [],
|
|
}, true), {
|
|
orbit: 'Low Earth Orbit',
|
|
ascent: 'RECONSTRUCTED ESTIMATE',
|
|
replayAvailable: true,
|
|
});
|
|
assert.deepEqual(missionPathPresentation({
|
|
status: 'Launch Successful',
|
|
orbit: null,
|
|
trajectory: [{ latitude: 28.5, longitude: -80.5 }],
|
|
}, false), {
|
|
orbit: null,
|
|
ascent: 'UNAVAILABLE',
|
|
replayAvailable: false,
|
|
});
|
|
});
|
|
|
|
test('bounds the post-TLE refresh to one resolved-catalog rebuild', () => {
|
|
const base = {
|
|
enabled: true,
|
|
retryCount: 0,
|
|
activeTleText: 'ACTIVE TLE',
|
|
renderedTleText: null,
|
|
};
|
|
assert.equal(shouldRetryAfterActiveTle(base), true);
|
|
assert.equal(shouldRetryAfterActiveTle({ ...base, retryCount: 1 }), false);
|
|
assert.equal(shouldRetryAfterActiveTle({ ...base, activeTleText: null }), false);
|
|
assert.equal(shouldRetryAfterActiveTle({ ...base, renderedTleText: 'ACTIVE TLE' }), false);
|
|
assert.equal(shouldRetryAfterActiveTle({ ...base, enabled: false }), false);
|
|
});
|
|
|
|
test('replay releases aircraft tracking through both owning layer APIs', () => {
|
|
const calls = [];
|
|
const dataManager = {
|
|
layers: new Map([
|
|
['flights', { module: { stopTracking: () => calls.push('flights') } }],
|
|
['military', { module: { stopTracking: () => calls.push('military') } }],
|
|
['satellites', { module: { stopTracking: () => calls.push('satellites') } }],
|
|
]),
|
|
};
|
|
assert.equal(releaseAircraftTracking(dataManager), 2);
|
|
assert.deepEqual(calls, ['flights', 'military']);
|
|
});
|
|
|
|
test('uses the core GMST frame transform for mission orbit primitives', () => {
|
|
const bakeDate = new Date('2026-07-20T10:00:00Z');
|
|
const nowDate = new Date('2026-07-20T10:10:00Z');
|
|
const gmstAtBake = gstime(bakeDate);
|
|
const matrix = orbitFrameModelMatrix(gmstAtBake, nowDate);
|
|
const actual = Cesium.Matrix4.multiplyByPoint(
|
|
matrix,
|
|
Cesium.Cartesian3.UNIT_X,
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const expectedAngle = -(gstime(nowDate) - gmstAtBake);
|
|
assert.ok(Math.abs(actual.x - Math.cos(expectedAngle)) < 1e-12);
|
|
assert.ok(Math.abs(actual.y - Math.sin(expectedAngle)) < 1e-12);
|
|
assert.ok(Math.abs(actual.z) < 1e-12);
|
|
});
|
|
|
|
test('treats an already-active Satellite catalog mode as idempotent', () => {
|
|
assert.equal(satelliteCatalogModeChanged('dense', 'dense'), false);
|
|
assert.equal(satelliteCatalogModeChanged('core', 'core'), false);
|
|
assert.equal(satelliteCatalogModeChanged('core', 'dense'), true);
|
|
assert.equal(satelliteCatalogModeChanged('dense', 'core'), true);
|
|
assert.equal(satelliteCatalogModeChanged('core', undefined), false);
|
|
});
|
|
|
|
test('holds replay at the pad for a real-time countdown independent of replay speed', () => {
|
|
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
|
|
const tilePreparation = replayState(launch, 115000, 12, 28, 5400, 4, 100000, 5);
|
|
assert.equal(tilePreparation.preCountdownActive, true);
|
|
assert.equal(tilePreparation.countdownActive, false);
|
|
const countdown = replayState(launch, 110000, 12, 28, 5400, 4, 100000);
|
|
assert.equal(countdown.countdownActive, true);
|
|
assert.equal(countdown.countdownSeconds, 10);
|
|
assert.equal(countdown.elapsedSinceStart, 0);
|
|
assert.equal(countdown.phaseProgress, 0);
|
|
|
|
const liftoff = replayState(launch, 110000, 12, 28, 5400, 4, 110500);
|
|
assert.equal(liftoff.countdownActive, false);
|
|
assert.equal(liftoff.countdownSeconds, 0);
|
|
assert.equal(liftoff.elapsedSinceStart, 2);
|
|
assert.ok(liftoff.phaseProgress > 0);
|
|
});
|
|
|
|
test('runs the five-second tile hold before a full T-10 countdown', () => {
|
|
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
|
|
// REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead.
|
|
const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5);
|
|
assert.equal(hold.preCountdownActive, true);
|
|
assert.equal(hold.countdownActive, false);
|
|
const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5);
|
|
assert.equal(countdown.preCountdownActive, false);
|
|
assert.equal(countdown.countdownActive, true);
|
|
assert.equal(countdown.countdownSeconds, 10);
|
|
});
|
|
|
|
test('hands replay directly from ascent into orbit', () => {
|
|
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
|
|
const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000);
|
|
assert.equal(orbit.ascending, false);
|
|
assert.equal(orbit.phaseProgress, 4 / 28);
|
|
});
|
|
|
|
test('shows launch-pad zone only for the selected close-range mission', () => {
|
|
assert.equal(LAUNCH_PAD_ZONE_RADIUS_M, 500);
|
|
const closeSelected = {
|
|
layerActive: true,
|
|
selectedLaunchId: 'mission-a',
|
|
launchId: 'mission-a',
|
|
cameraHeightM: 12000,
|
|
cameraDistanceM: 14000,
|
|
};
|
|
assert.equal(launchPadZoneVisible(closeSelected), true);
|
|
assert.equal(launchPadZoneVisible({ ...closeSelected, layerActive: false }), false);
|
|
assert.equal(launchPadZoneVisible({ ...closeSelected, selectedLaunchId: 'mission-b' }), false);
|
|
assert.equal(launchPadZoneVisible({ ...closeSelected, cameraHeightM: 120001 }), false);
|
|
assert.equal(launchPadZoneVisible({ ...closeSelected, cameraDistanceM: 180001 }), false);
|
|
});
|
|
|
|
test('orders mission roster newest-first without losing navigation indices', () => {
|
|
const entries = missionRosterEntries([
|
|
{ id: 'oldest', launchTime: '2026-07-01T00:00:00Z' },
|
|
{ id: 'newest', launchTime: '2026-07-20T00:00:00Z' },
|
|
{ id: 'middle', launchTime: '2026-07-10T00:00:00Z' },
|
|
]);
|
|
assert.deepEqual(entries.map((entry) => entry.launch.id), ['newest', 'middle', 'oldest']);
|
|
assert.deepEqual(entries.map((entry) => entry.index), [1, 2, 0]);
|
|
});
|
|
|
|
test('prioritizes data-rich missions before newer sparse records', () => {
|
|
const entries = missionRosterEntries([
|
|
{ id: 'rich', launchTime: '2026-07-01T00:00:00Z', provider: 'Agency', mission: 'Detailed mission', orbit: { name: 'LEO' }, payloads: [{ name: 'Payload' }], timeline: [{ name: 'Liftoff' }] },
|
|
{ id: 'sparse-new', launchTime: '2026-07-20T00:00:00Z' },
|
|
]);
|
|
assert.ok(missionDataCompleteness(entries[0].launch) > missionDataCompleteness(entries[1].launch));
|
|
assert.equal(entries[0].launch.id, 'rich');
|
|
});
|
|
|
|
test('preserves globe scale for roster hover previews', () => {
|
|
assert.equal(missionHoverPreviewRange(18178265), 18178265);
|
|
assert.equal(missionHoverPreviewRange(12000), 180000);
|
|
assert.equal(missionHoverPreviewRange(Number.NaN), 5000000);
|
|
});
|
|
|
|
test('keyboard focus previews roster missions and clears without selecting them', () => {
|
|
const calls = [];
|
|
const ownership = createMissionRosterPreviewOwnership({
|
|
preview: (index) => calls.push(['preview', index]),
|
|
clear: () => calls.push(['clear']),
|
|
});
|
|
|
|
ownership.focus(2);
|
|
ownership.blur(3);
|
|
ownership.blur();
|
|
|
|
assert.deepEqual(calls, [
|
|
['preview', 2],
|
|
['preview', 3],
|
|
['clear'],
|
|
]);
|
|
});
|
|
|
|
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
|
|
const calls = [];
|
|
const ownership = createMissionRosterPreviewOwnership({
|
|
preview: (index) => calls.push(['preview', index]),
|
|
clear: () => calls.push(['clear']),
|
|
});
|
|
|
|
ownership.focus(1);
|
|
ownership.pointerEnter(0);
|
|
ownership.pointerLeave();
|
|
|
|
assert.deepEqual(calls, [
|
|
['preview', 1],
|
|
['preview', 0],
|
|
['preview', 1],
|
|
]);
|
|
});
|
|
|
|
test('pending mission preview resolves the current refreshed record by identity', () => {
|
|
const stale = { id: 'mission-a', name: 'Stale' };
|
|
const current = { id: 'mission-a', name: 'Current' };
|
|
|
|
assert.equal(resolveMissionRosterPreviewLaunch([current], stale.id), current);
|
|
assert.equal(resolveMissionRosterPreviewLaunch([], stale.id), null);
|
|
});
|
|
|
|
test('latest roster input owns preview and restores the remaining owner on leave', () => {
|
|
const calls = [];
|
|
const ownership = createMissionRosterPreviewOwnership({
|
|
preview: (index) => calls.push(['preview', index]),
|
|
clear: () => calls.push(['clear']),
|
|
});
|
|
|
|
ownership.pointerEnter(0);
|
|
ownership.focus(1);
|
|
ownership.blur();
|
|
ownership.pointerLeave();
|
|
|
|
assert.deepEqual(calls, [
|
|
['preview', 0],
|
|
['preview', 1],
|
|
['preview', 0],
|
|
['clear'],
|
|
]);
|
|
});
|
|
|
|
test('assigns distinct stable colors to mission operators', () => {
|
|
assert.equal(missionMarkerColor({ provider: 'NASA', name: 'Science Flight' }).toCssColorString(), 'rgb(255,159,67)');
|
|
assert.equal(missionMarkerColor({ provider: 'SpaceX', name: 'Starlink Group' }).toCssColorString(), 'rgb(76,201,240)');
|
|
assert.equal(missionMarkerColor({ provider: 'Private Launch Co.', name: 'Test Flight' }).toCssColorString(), 'rgb(192,132,252)');
|
|
});
|
|
|
|
test('normalizes recent launches and preserves supplied trajectory/orbit data', () => {
|
|
const launches = normalizeRocketLaunches({ results: [{
|
|
id: 'recent-1',
|
|
name: 'Test Flight',
|
|
net: '2026-07-20T10:00:00Z',
|
|
status: { name: 'Launch Successful' },
|
|
pad: { name: 'Pad A', location: { name: 'Test Range', coordinates: '12.5,45.5' } },
|
|
trajectory: [{ latitude: 45.5, longitude: 12.5, altitude: 0 }],
|
|
timeline: [{ type: { abbrev: 'SECO-1' }, relative_time: 'PT8M40S' }],
|
|
mission: { description: 'Payload test', orbit: { name: 'LEO' } },
|
|
}] }, NOW);
|
|
|
|
assert.equal(launches.length, 1);
|
|
assert.equal(launches[0].lat, 45.5);
|
|
assert.equal(launches[0].lon, 12.5);
|
|
assert.equal(launches[0].orbit.name, 'LEO');
|
|
assert.equal(launches[0].trajectory.length, 1);
|
|
assert.equal(launches[0].timeline[0].offsetSeconds, 520);
|
|
});
|
|
|
|
test('uses Launch Library 2 pad latitude/longitude fields', () => {
|
|
const launches = normalizeRocketLaunches({ results: [{
|
|
id: 'pad-fields',
|
|
net: '2026-07-20T10:00:00Z',
|
|
pad: { latitude: '34.632', longitude: '-120.611', location: { name: 'Vandenberg' } },
|
|
}] }, NOW);
|
|
assert.equal(launches.length, 1);
|
|
assert.equal(launches[0].lat, 34.632);
|
|
assert.equal(launches[0].lon, -120.611);
|
|
});
|
|
|
|
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
|
|
const base = { net: '2026-07-20T10:00:00Z' };
|
|
for (const blank of [null, '', ' ', false]) {
|
|
assert.deepEqual(
|
|
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
|
|
[],
|
|
);
|
|
}
|
|
});
|
|
|
|
test('null pad coordinates use the location fallback instead of 0,0', () => {
|
|
const base = { net: '2026-07-20T10:00:00Z' };
|
|
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
|
|
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
|
|
assert.equal(fallback.lat, 28.6);
|
|
assert.equal(fallback.lon, -80.6);
|
|
});
|
|
|
|
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
|
|
const base = { net: '2026-07-20T10:00:00Z' };
|
|
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
|
|
rocket: { launcher_stage: [{ launcher_flight_number: null,
|
|
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
|
|
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
|
|
const [stage] = launch.recoveryStages;
|
|
assert.equal(stage.lat, null);
|
|
assert.equal(stage.lon, null);
|
|
assert.equal(stage.downrangeKm, null);
|
|
assert.equal(stage.flightNumber, null);
|
|
});
|
|
|
|
test('normalizes detailed payload and stage recovery records', () => {
|
|
const launches = normalizeRocketLaunches({ results: [{
|
|
id: 'recovery-details',
|
|
name: 'Falcon 9 | Test Payload',
|
|
net: '2026-07-20T10:00:00Z',
|
|
pad: { latitude: '28.608', longitude: '-80.604', name: 'LC-39A' },
|
|
rocket: {
|
|
payloads: [{
|
|
id: 501,
|
|
destination: 'Low Earth Orbit',
|
|
amount: 2,
|
|
payload: {
|
|
id: 91,
|
|
name: 'TestSat',
|
|
type: { name: 'Earth Observation Satellite' },
|
|
manufacturer: { name: 'Example Space' },
|
|
operator: { name: 'Example Operator' },
|
|
mass: 420,
|
|
},
|
|
}],
|
|
launcher_stage: [{
|
|
id: 77,
|
|
type: 'Core',
|
|
reused: true,
|
|
launcher_flight_number: 8,
|
|
launcher: { serial_number: 'B1099' },
|
|
landing: {
|
|
attempt: true,
|
|
success: true,
|
|
downrange_distance: 610,
|
|
type: { name: 'Autonomous Spaceport Drone Ship' },
|
|
landing_location: {
|
|
name: 'A Shortfall of Gravitas',
|
|
latitude: '30.1',
|
|
longitude: '-76.2',
|
|
},
|
|
},
|
|
}],
|
|
},
|
|
mission: { orbit: { name: 'Low Earth Orbit' } },
|
|
}] }, NOW);
|
|
|
|
assert.equal(launches[0].payloads[0].name, 'TestSat');
|
|
assert.equal(launches[0].payloads[0].amount, 2);
|
|
assert.equal(launches[0].payloads[0].massKg, 420);
|
|
assert.equal(launches[0].recoveryStages[0].name, 'Core · B1099');
|
|
assert.equal(launches[0].recoveryStages[0].status, 'RECOVERED');
|
|
assert.equal(launches[0].recoveryStages[0].downrangeKm, 610);
|
|
assert.equal(launches[0].recoveryStages[0].lat, 30.1);
|
|
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
|
|
});
|
|
|
|
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
|
|
const model = createModel({});
|
|
assert.equal(model.finiteCoordinate(42.5), 42.5);
|
|
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
|
|
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
|
|
assert.equal(model.finiteCoordinate(0), 0);
|
|
assert.equal(model.finiteCoordinate('0'), 0);
|
|
assert.equal(model.finiteCoordinate(undefined), null);
|
|
assert.equal(model.finiteCoordinate('invalid'), null);
|
|
assert.equal(model.finiteCoordinate('TBD'), null);
|
|
assert.equal(model.finiteCoordinate(Number.NaN), null);
|
|
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
|
|
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
|
|
});
|
|
|
|
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
|
|
const launches = normalizeRocketLaunches(
|
|
{
|
|
results: [
|
|
{
|
|
id: 'recovery-fallbacks',
|
|
name: 'Falcon 9 | Edge Case Stage Normalization',
|
|
net: '2026-07-20T10:00:00Z',
|
|
pad: { latitude: '28.608', longitude: '-80.604' },
|
|
rocket: {
|
|
launcher_stage: [
|
|
{
|
|
id: 88,
|
|
type: { name: 'Booster' },
|
|
launcher_flight_number: 'invalid-flight',
|
|
landing: {
|
|
attempt: true,
|
|
success: false,
|
|
downrange_distance: 'TBD',
|
|
landing_location: {
|
|
name: 'Ocean Impact',
|
|
latitude: 'N/A',
|
|
longitude: undefined,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: 89,
|
|
type: { name: 'Expended Stage' },
|
|
landing: {
|
|
attempt: false,
|
|
type: { name: 'Expendable' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
NOW,
|
|
);
|
|
|
|
assert.equal(launches[0].recoveryStages.length, 2);
|
|
|
|
const booster = launches[0].recoveryStages[0];
|
|
assert.equal(booster.status, 'LOST');
|
|
assert.equal(booster.success, false);
|
|
assert.equal(booster.attempted, true);
|
|
assert.equal(booster.flightNumber, null);
|
|
assert.equal(booster.downrangeKm, null);
|
|
assert.equal(booster.lat, null);
|
|
assert.equal(booster.lon, null);
|
|
|
|
const expendable = launches[0].recoveryStages[1];
|
|
assert.equal(expendable.status, 'EXPENDABLE');
|
|
assert.equal(expendable.attempted, false);
|
|
assert.equal(expendable.success, null);
|
|
});
|
|
|
|
test('normalizes payload amounts and fractional string masses', () => {
|
|
const launches = normalizeRocketLaunches(
|
|
{
|
|
results: [
|
|
{
|
|
id: 'payload-edge-cases',
|
|
name: 'Electron | Mixed Payloads',
|
|
net: '2026-07-20T10:00:00Z',
|
|
pad: { latitude: '-39.26', longitude: '177.86' },
|
|
rocket: {
|
|
payloads: [
|
|
{
|
|
id: 601,
|
|
amount: 'not-a-number',
|
|
payload: { id: 11, name: 'Unknown Amount Payload' },
|
|
},
|
|
{
|
|
id: 602,
|
|
amount: 3,
|
|
payload: { id: 12, name: 'Triple Payload' },
|
|
},
|
|
{
|
|
id: 603,
|
|
payload: {
|
|
id: 13,
|
|
name: 'Valid Fractional Mass Payload',
|
|
mass: '12.75',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
NOW,
|
|
);
|
|
|
|
// Blank, negative and non-numeric masses are pinned by "missing payload
|
|
// details stay unknown without inventing mass or classification".
|
|
const payloads = launches[0].payloads;
|
|
assert.equal(payloads.length, 3);
|
|
assert.equal(payloads[0].amount, 1);
|
|
assert.equal(payloads[1].amount, 3);
|
|
assert.equal(payloads[2].massKg, 12.75);
|
|
});
|
|
|
|
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
|
|
const launches = normalizeRocketLaunches({ results: [{
|
|
id: 'undisclosed',
|
|
net: '2026-07-20T10:00:00Z',
|
|
pad: { latitude: '28.608', longitude: '-80.604' },
|
|
rocket: {},
|
|
}] }, NOW);
|
|
assert.deepEqual(launches[0].payloads, []);
|
|
assert.deepEqual(launches[0].recoveryStages, []);
|
|
});
|
|
|
|
test('excludes launches outside the rolling 30-day window and missing coordinates', () => {
|
|
const launches = normalizeRocketLaunches({ results: [
|
|
{ id: 'old', net: '2026-06-26T00:00:00Z', pad: { location: { coordinates: '1,1' } } },
|
|
{ id: 'no-coordinates', net: '2026-07-20T00:00:00Z', pad: { location: {} } },
|
|
{ id: 'future', net: '2026-07-28T00:00:00Z', pad: { location: { coordinates: '1,1' } } },
|
|
] }, NOW);
|
|
|
|
assert.deepEqual(launches, []);
|
|
});
|
|
|
|
test('accepts an array payload for proxy and fixture flexibility', () => {
|
|
const launches = normalizeRocketLaunches([
|
|
{ id: 'array-1', net: '2026-07-01T00:00:00Z', pad: { location: { coordinates: '2,3' } } },
|
|
], NOW);
|
|
assert.equal(launches[0].id, 'array-1');
|
|
});
|
|
|
|
test('builds a surface-safe ascent that meets the orbit without a phase jump', () => {
|
|
const launch = Cesium.Cartesian3.fromDegrees(-120.61, 34.63, 0);
|
|
const orbit = Array.from({ length: 37 }, (_, index) => {
|
|
const longitude = -180 + index * 10;
|
|
const latitude = Math.sin(Cesium.Math.toRadians(longitude)) * 35;
|
|
return Cesium.Cartesian3.fromDegrees(longitude, latitude, 550000);
|
|
});
|
|
const paths = buildMissionPaths(launch, [], orbit);
|
|
|
|
assert.ok(paths.ascentPath.length > 2);
|
|
assert.equal(paths.ascentPath.at(-1), orbit[paths.insertionIndex]);
|
|
assert.equal(paths.animatedOrbitPath[0], orbit[paths.insertionIndex]);
|
|
assert.equal(paths.animatedOrbitPath.at(-1), paths.animatedOrbitPath[0]);
|
|
const launchCartographic = Cesium.Cartographic.fromCartesian(paths.ascentPath[0]);
|
|
const verticalCartographic = Cesium.Cartographic.fromCartesian(paths.ascentPath[12]);
|
|
const earlyHorizontalDistance = new Cesium.EllipsoidGeodesic(
|
|
launchCartographic,
|
|
verticalCartographic,
|
|
).surfaceDistance;
|
|
assert.ok(earlyHorizontalDistance < 2000);
|
|
assert.ok(verticalCartographic.height > launchCartographic.height + 10000);
|
|
const maximumTurn = Math.max(...paths.ascentPath.slice(1, -13).map((position, index) => {
|
|
const incoming = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(position, paths.ascentPath[index], new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const outgoing = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(paths.ascentPath[index + 2], position, new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
return Cesium.Math.acosClamped(Cesium.Cartesian3.dot(incoming, outgoing));
|
|
}));
|
|
assert.ok(Cesium.Math.toDegrees(maximumTurn) < 5);
|
|
const ascentTangent = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(paths.ascentPath.at(-1), paths.ascentPath.at(-2), new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const orbitTangent = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(paths.animatedOrbitPath[1], paths.animatedOrbitPath[0], new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
assert.ok(Cesium.Cartesian3.dot(ascentTangent, orbitTangent) > 0.7);
|
|
for (const position of paths.ascentPath) {
|
|
assert.ok(Cesium.Cartographic.fromCartesian(position).height >= -1);
|
|
}
|
|
});
|
|
|
|
test('keeps a tangent-blended inclined ascent outside the globe', () => {
|
|
const launch = Cesium.Cartesian3.fromDegrees(80, -60, 0);
|
|
const radius = Cesium.Ellipsoid.WGS84.maximumRadius + 200000;
|
|
const inclination = Cesium.Math.toRadians(30);
|
|
const orbit = Array.from({ length: 97 }, (_, index) => {
|
|
const angle = (index / 96) * Cesium.Math.TWO_PI;
|
|
return new Cesium.Cartesian3(
|
|
radius * Math.cos(angle),
|
|
radius * Math.sin(angle) * Math.cos(inclination),
|
|
radius * Math.sin(angle) * Math.sin(inclination),
|
|
);
|
|
});
|
|
const paths = buildMissionPaths(launch, [], orbit);
|
|
const minimumHeight = Math.min(
|
|
...paths.ascentPath.map((position) => Cesium.Cartographic.fromCartesian(position).height),
|
|
);
|
|
|
|
assert.ok(minimumHeight >= -1);
|
|
assert.equal(paths.ascentPath.at(-1), orbit[paths.insertionIndex]);
|
|
});
|
|
|
|
test('uses a propagated insertion reference instead of the radial nearest orbit point', () => {
|
|
const launch = Cesium.Cartesian3.fromDegrees(-80.6, 28.5, 0);
|
|
const orbit = approximateOrbitPath({
|
|
lat: 28.5,
|
|
lon: -80.6,
|
|
orbit: { name: 'Low Earth Orbit' },
|
|
});
|
|
const targetIndex = 42;
|
|
const paths = buildMissionPaths(launch, [], orbit, orbit[targetIndex]);
|
|
assert.equal(paths.insertionIndex, targetIndex);
|
|
assert.equal(paths.ascentPath.at(-1), orbit[targetIndex]);
|
|
const finalAscent = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(paths.ascentPath.at(-1), paths.ascentPath.at(-2), new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const firstOrbit = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(paths.animatedOrbitPath[1], paths.animatedOrbitPath[0], new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
assert.ok(Cesium.Cartesian3.dot(finalAscent, firstOrbit) > 0.7);
|
|
});
|
|
|
|
test('estimated mission orbit is a smooth planar ring', () => {
|
|
const orbit = approximateOrbitPath({
|
|
lat: 34.63,
|
|
lon: -120.61,
|
|
orbit: { name: 'Polar Orbit' },
|
|
});
|
|
const normal = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.cross(orbit[0], orbit[24], new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const maximumPlaneResidual = Math.max(...orbit.map((position) => Math.abs(
|
|
Cesium.Cartesian3.dot(
|
|
normal,
|
|
Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3()),
|
|
),
|
|
)));
|
|
assert.ok(maximumPlaneResidual < 1e-12);
|
|
assert.ok(Cesium.Cartesian3.distance(orbit[0], orbit.at(-1)) < 1e-6);
|
|
});
|
|
|
|
test('projects a west-coast ascent forward into orbit without reversing course', () => {
|
|
const launchInfo = {
|
|
lat: 34.63,
|
|
lon: -120.61,
|
|
orbit: { name: 'Low Earth Orbit' },
|
|
};
|
|
const launch = Cesium.Cartesian3.fromDegrees(launchInfo.lon, launchInfo.lat, 0);
|
|
const orbit = approximateOrbitPath(launchInfo);
|
|
const firstDownrange = Cesium.Cartographic.fromCartesian(orbit[1]);
|
|
assert.ok(Cesium.Math.toDegrees(firstDownrange.latitude) < launchInfo.lat);
|
|
assert.ok(Cesium.Math.toDegrees(firstDownrange.longitude) < launchInfo.lon);
|
|
|
|
const paths = buildMissionPaths(launch, [], orbit, orbit[10]);
|
|
const minimumDirectionContinuity = Math.min(
|
|
...paths.ascentPath.slice(1, -1).map((position, index) => {
|
|
const incoming = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(position, paths.ascentPath[index], new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const outgoing = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.subtract(paths.ascentPath[index + 2], position, new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
return Cesium.Cartesian3.dot(incoming, outgoing);
|
|
}),
|
|
);
|
|
assert.ok(minimumDirectionContinuity > Math.cos(Cesium.Math.toRadians(5)));
|
|
});
|
|
|
|
test('formats the launch epoch for ascent and orbit replay labels', () => {
|
|
assert.equal(
|
|
formatMissionEventTime('2026-06-29T02:25:00Z'),
|
|
'2026-06-29\n02:25:00 UTC',
|
|
);
|
|
assert.equal(formatMissionEventTime(null), 'UNAVAILABLE');
|
|
});
|
|
|
|
test('reduces generic launch-site names to their identifying suffix', () => {
|
|
assert.equal(compactLaunchSiteName('Orbital Launch Pad 2'), '2');
|
|
assert.equal(compactLaunchSiteName('Space Launch Complex 4E'), '4E');
|
|
assert.equal(compactLaunchSiteName('Launch Area 130'), '130');
|
|
assert.equal(
|
|
compactLaunchSiteName('Satish Dhawan Space Centre First Launch Pad'),
|
|
'Satish Dhawan Space Centre First Launch Pad',
|
|
);
|
|
assert.equal(compactLaunchSiteName('Unknown'), null);
|
|
});
|
|
|
|
test('mission overlay factories preserve all four source-formatted label roles and lane policy', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-80.604, 28.608);
|
|
const launch = {
|
|
id: 'mission-1',
|
|
name: 'Falcon 9 | Gauntlet Payload',
|
|
launchSite: 'Space Launch Complex 39A',
|
|
launchTime: '2026-07-20T10:00:00Z',
|
|
};
|
|
const ambient = createRocketMissionMarkerOverlayEntry(launch, position);
|
|
assert.deepEqual({
|
|
title: ambient.title,
|
|
details: ambient.details,
|
|
paintLane: ambient.paintLane,
|
|
protected: ambient.protected,
|
|
edgeFade: ambient.edgeFade,
|
|
}, {
|
|
title: 'FALCON 9',
|
|
details: [],
|
|
paintLane: 'ambient-label',
|
|
protected: false,
|
|
edgeFade: 'keyhole',
|
|
});
|
|
|
|
const selected = createRocketMissionMarkerOverlayEntry(launch, position, true);
|
|
assert.equal(selected.title, 'FALCON 9');
|
|
assert.deepEqual(selected.details, ['LAUNCH SITE · 39A']);
|
|
assert.equal(selected.paintLane, 'selected');
|
|
assert.equal(selected.protected, true);
|
|
|
|
const roles = [
|
|
['reentry', 'STAGE RE-ENTRY', '#ffd166', ['STAGE RE-ENTRY', []]],
|
|
['payload', 'EST. ORBIT POSITION\n2026-07-20\n10:00:00 UTC', '#ffd166', [
|
|
'EST. ORBIT POSITION',
|
|
['2026-07-20', '10:00:00 UTC'],
|
|
]],
|
|
['orbit', 'PROJECTED ORBIT', '#c084fc', ['PROJECTED ORBIT', []]],
|
|
];
|
|
for (const [id, text, accent, [title, details]] of roles) {
|
|
const entry = createRocketMissionElementOverlayEntry({ id, position, text, accent });
|
|
assert.equal(entry.title, title);
|
|
assert.deepEqual(entry.details, details);
|
|
assert.equal(entry.accent, accent);
|
|
assert.equal(entry.paintLane, 'selected');
|
|
assert.equal(entry.protected, true);
|
|
assert.equal(entry.edgeFade, 'keyhole');
|
|
}
|
|
|
|
const surplus = Array.from(
|
|
{ length: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT + 12 },
|
|
(_, index) => ({ id: `mission-${String(index).padStart(3, '0')}`, priority: index }),
|
|
);
|
|
const cohort = selectRocketMissionMarkerOverlayCohort(surplus);
|
|
assert.equal(cohort.length, ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT);
|
|
assert.equal(cohort[0].id, 'mission-059');
|
|
assert.equal(cohort.at(-1).id, 'mission-012');
|
|
});
|
|
|
|
test('parses signed Launch Library timeline durations', () => {
|
|
assert.equal(parseMissionDurationSeconds('PT1H1M46S'), 3706);
|
|
assert.equal(parseMissionDurationSeconds('-PT35M'), -2100);
|
|
assert.equal(parseMissionDurationSeconds('P0D'), 0);
|
|
assert.equal(parseMissionDurationSeconds('unknown'), null);
|
|
});
|
|
|
|
test('derives replay ascent duration from mission timing instead of a fixed constant', () => {
|
|
const path = [
|
|
Cesium.Cartesian3.fromDegrees(-120, 34, 0),
|
|
Cesium.Cartesian3.fromDegrees(-116, 34, 200000),
|
|
];
|
|
const fast = replayAscentDurationSeconds({
|
|
timeline: [{ name: 'SECO-1', offsetSeconds: 360 }],
|
|
}, path);
|
|
const slow = replayAscentDurationSeconds({
|
|
timeline: [{ name: 'Orbit insertion', offsetSeconds: 1200 }],
|
|
}, path);
|
|
assert.ok(slow > fast);
|
|
assert.ok(fast >= 8 && slow <= 36);
|
|
});
|
|
|
|
test('matches compact payload identifiers without accepting an arbitrary constellation member', () => {
|
|
assert.ok(scoreSatelliteNameMatch('Sirius SXM-11', 'SXM-11') >= 200);
|
|
assert.ok(scoreSatelliteNameMatch('Starlink Group 17-40', 'STARLINK-1008') < 12);
|
|
});
|
|
|
|
test('finds a newly launched payload in the active TLE fallback catalog', () => {
|
|
const tle = `SXM-11
|
|
1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990
|
|
2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440`;
|
|
const track = findSatelliteOrbitTrackInTle(tle, 'Sirius SXM-11', {
|
|
launchTime: '2026-06-29T02:25:00Z',
|
|
});
|
|
assert.equal(track?.noradId, 69728);
|
|
assert.equal(track?.name, 'SXM-11');
|
|
assert.ok(track?.orbitPath.length > 100);
|
|
assert.ok(track?.periodSec > 80000);
|
|
assert.ok(track?.current.speedMps > 1000);
|
|
assert.ok(track?.current.speedMps < 12000);
|
|
assert.equal(typeof track?.positionAt, 'function');
|
|
});
|
|
|
|
test('real mission build, select, refresh, deselect, disable, and destroy paths publish no native labels', async () => {
|
|
const realDocument = globalThis.document;
|
|
const realFetch = globalThis.fetch;
|
|
const realHtmlCanvasElement = globalThis.HTMLCanvasElement;
|
|
const realHtmlImageElement = globalThis.HTMLImageElement;
|
|
const realImageBitmap = globalThis.ImageBitmap;
|
|
const realOffscreenCanvas = globalThis.OffscreenCanvas;
|
|
const listeners = new Map();
|
|
const context = {
|
|
strokeStyle: '',
|
|
lineWidth: 1,
|
|
lineCap: '',
|
|
shadowColor: '',
|
|
shadowBlur: 0,
|
|
beginPath() {},
|
|
moveTo() {},
|
|
lineTo() {},
|
|
stroke() {},
|
|
};
|
|
class FakeElement {
|
|
constructor(tagName = 'div') {
|
|
this.tagName = tagName.toUpperCase();
|
|
this.children = [];
|
|
this.parentElement = null;
|
|
this.style = { setProperty() {} };
|
|
this.classList = { add() {}, remove() {}, toggle() {} };
|
|
this.dataset = {};
|
|
this.hidden = false;
|
|
this.clientWidth = 1600;
|
|
this.clientHeight = 900;
|
|
this.width = 1600;
|
|
this.height = 900;
|
|
this.disableRootEvents = false;
|
|
}
|
|
|
|
addEventListener(type, handler) { listeners.set(`${this.tagName}:${type}`, handler); }
|
|
removeEventListener(type) { listeners.delete(`${this.tagName}:${type}`); }
|
|
appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
|
|
remove() {
|
|
if (this.parentElement) {
|
|
this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
|
|
}
|
|
this.parentElement = null;
|
|
}
|
|
setAttribute() {}
|
|
// This fixture has no parsed DOM children; it exercises native label
|
|
// ownership, not the replay overlay's separately rendered icon children.
|
|
querySelectorAll() { return []; }
|
|
querySelector() { return null; }
|
|
getBoundingClientRect() { return { left: 0, top: 0, width: 1600, height: 900 }; }
|
|
getContext() { return this.tagName === 'CANVAS' ? context : null; }
|
|
}
|
|
const body = new FakeElement('body');
|
|
const document = {
|
|
body,
|
|
onmousewheel: undefined,
|
|
createElement: (tagName) => new FakeElement(tagName),
|
|
getElementById: () => null,
|
|
addEventListener(type, handler) { listeners.set(`document:${type}`, handler); },
|
|
removeEventListener(type) { listeners.delete(`document:${type}`); },
|
|
};
|
|
const canvas = new FakeElement('canvas');
|
|
const dataSources = [];
|
|
const camera = {
|
|
positionCartographic: null,
|
|
positionWC: Cesium.Cartesian3.fromDegrees(-80.604, 28.608, 18_000_000),
|
|
cancelFlight() {},
|
|
lookAtTransform() {},
|
|
};
|
|
const scene = {
|
|
canvas,
|
|
camera,
|
|
frameState: { frameNumber: 1 },
|
|
postRender: new Cesium.Event(),
|
|
preRender: new Cesium.Event(),
|
|
preUpdate: new Cesium.Event(),
|
|
primitives: { add: (primitive) => primitive, remove: () => true },
|
|
drillPick: () => [],
|
|
};
|
|
const viewer = {
|
|
camera,
|
|
scene,
|
|
dataSources: {
|
|
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
|
remove(dataSource) {
|
|
const index = dataSources.indexOf(dataSource);
|
|
if (index >= 0) dataSources.splice(index, 1);
|
|
return index >= 0;
|
|
},
|
|
},
|
|
selectedEntity: undefined,
|
|
};
|
|
const hostCalls = [];
|
|
const host = {
|
|
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
|
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
|
clearSource: (...args) => hostCalls.push(['clear', ...args]),
|
|
};
|
|
const launchTime = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
let launchName = 'Falcon 9 | Gauntlet Payload';
|
|
const launchPayload = () => ({ results: [{
|
|
id: 'mission-runtime',
|
|
name: launchName,
|
|
net: launchTime,
|
|
status: { name: 'Launch Successful' },
|
|
pad: {
|
|
latitude: '28.608',
|
|
longitude: '-80.604',
|
|
name: 'Space Launch Complex 39A',
|
|
},
|
|
rocket: {
|
|
launcher_stage: [{
|
|
id: 'booster-1',
|
|
type: 'Core',
|
|
landing: {
|
|
attempt: true,
|
|
success: true,
|
|
downrange_distance: 600,
|
|
landing_location: { latitude: '30.1', longitude: '-76.2' },
|
|
},
|
|
}],
|
|
},
|
|
mission: {
|
|
name: 'Gauntlet Payload',
|
|
orbit: { name: 'Low Earth Orbit' },
|
|
},
|
|
}] });
|
|
globalThis.document = document;
|
|
globalThis.HTMLCanvasElement = FakeElement;
|
|
globalThis.HTMLImageElement = class {};
|
|
globalThis.ImageBitmap = class {};
|
|
globalThis.OffscreenCanvas = class {};
|
|
globalThis.fetch = async (url) => {
|
|
if (url === '/api/celestrak/active') {
|
|
return { ok: true, text: async () => '' };
|
|
}
|
|
assert.equal(url, '/api/launches');
|
|
return { ok: true, json: async () => launchPayload() };
|
|
};
|
|
_setRocketMissionOverlayHostForTest(host);
|
|
let initialized = false;
|
|
try {
|
|
rocketLaunchesLayer.init(viewer);
|
|
initialized = true;
|
|
await rocketLaunchesLayer.enable();
|
|
await rocketLaunchesLayer.update();
|
|
|
|
const entities = dataSources[0].entities.values;
|
|
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
|
assert.ok(entities.every((entity) => entity.label === undefined));
|
|
const ambientPublication = hostCalls.findLast(([type, sourceId]) => (
|
|
type === 'entries' && sourceId === ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID
|
|
));
|
|
assert.ok(ambientPublication);
|
|
assert.equal(ambientPublication[2].length, 1);
|
|
assert.equal(ambientPublication[2][0].title, 'FALCON 9');
|
|
assert.deepEqual(ambientPublication[3], {
|
|
cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
|
|
collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
|
|
moving: false,
|
|
});
|
|
const launchEntity = dataSources[0].entities.getById('rocket-launch:mission-runtime');
|
|
assert.deepEqual(
|
|
ambientPublication[2][0].position,
|
|
launchEntity.position.getValue(Cesium.JulianDate.now()),
|
|
'ambient host marker must reuse the entity anchor Cartesian',
|
|
);
|
|
const frameTime = Cesium.JulianDate.now();
|
|
scene.preRender.raiseEvent(scene, frameTime);
|
|
assert.equal(launchEntity.point.show.getValue(frameTime), true);
|
|
camera.positionWC = Cesium.Cartesian3.fromDegrees(99.396, -28.608, 18_000_000);
|
|
scene.preRender.raiseEvent(scene, frameTime);
|
|
assert.equal(
|
|
launchEntity.point.show.getValue(frameTime),
|
|
false,
|
|
'pre-render horizon pass must hide a rear-side depth-free mission dot before draw and pick',
|
|
);
|
|
camera.positionWC = Cesium.Cartesian3.fromDegrees(-80.604, 28.608, 18_000_000);
|
|
scene.preRender.raiseEvent(scene, frameTime);
|
|
assert.equal(
|
|
launchEntity.point.show.getValue(frameTime),
|
|
true,
|
|
'mission dot must return when its surface anchor faces the camera again',
|
|
);
|
|
|
|
_setSelectedRocketMissionForTest('mission-runtime');
|
|
const selectedPublication = hostCalls.findLast(([type, sourceId]) => (
|
|
type === 'entries' && sourceId === ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID
|
|
));
|
|
assert.ok(selectedPublication);
|
|
assert.deepEqual(selectedPublication[3], ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS);
|
|
assert.deepEqual(selectedPublication[2].map(({ title }) => title), [
|
|
'FALCON 9',
|
|
'STAGE RE-ENTRY',
|
|
'EST. ORBIT POSITION',
|
|
'PROJECTED ORBIT',
|
|
]);
|
|
assert.deepEqual(selectedPublication[2][0].details, ['LAUNCH SITE · 39A']);
|
|
assert.match(selectedPublication[2][2].details[0], /^\d{4}-\d{2}-\d{2}$/);
|
|
assert.match(selectedPublication[2][2].details[1], /^\d{2}:\d{2}:\d{2} UTC$/);
|
|
assert.ok(selectedPublication[2].every((entry) => (
|
|
entry.selected === true
|
|
&& entry.protected === true
|
|
&& entry.paintLane === 'selected'
|
|
&& entry.edgeFade === 'keyhole'
|
|
)));
|
|
const satelliteEntity = dataSources[0].entities.getById('rocket-satellite:mission-runtime');
|
|
const satellitePosition = satelliteEntity.position.getValue(Cesium.JulianDate.now());
|
|
assert.equal(
|
|
selectedPublication[2][2].position(),
|
|
satellitePosition,
|
|
'payload host getter must return the exact per-frame live-position cache',
|
|
);
|
|
|
|
// Jitter regression net: live-state propagation advances at most once per
|
|
// frameNumber, and the host getter is a pure read that never advances it.
|
|
// Without both halves, the host label and the native dot can propagate to
|
|
// different wall-clock instants inside one frame — the documented
|
|
// label-separation jitter class.
|
|
const payloadEntry = selectedPublication[2][2];
|
|
const realDateNow = Date.now;
|
|
try {
|
|
let nowMs = realDateNow();
|
|
Date.now = () => nowMs;
|
|
scene.frameState.frameNumber = 41;
|
|
const frameA = Cesium.Cartesian3.clone(
|
|
satelliteEntity.position.getValue(Cesium.JulianDate.now()),
|
|
);
|
|
nowMs += 30_000;
|
|
assert.deepEqual(
|
|
Cesium.Cartesian3.clone(satelliteEntity.position.getValue(Cesium.JulianDate.now())),
|
|
frameA,
|
|
'a second native read in the same frame must not re-propagate',
|
|
);
|
|
nowMs += 30_000;
|
|
scene.frameState.frameNumber = 42;
|
|
assert.deepEqual(
|
|
Cesium.Cartesian3.clone(payloadEntry.position()),
|
|
frameA,
|
|
'host getter must not advance propagation, even on a new frame',
|
|
);
|
|
const frameB = Cesium.Cartesian3.clone(
|
|
satelliteEntity.position.getValue(Cesium.JulianDate.now()),
|
|
);
|
|
assert.notDeepEqual(frameB, frameA, 'native read on a new frame propagates (non-vacuous)');
|
|
assert.deepEqual(
|
|
Cesium.Cartesian3.clone(payloadEntry.position()),
|
|
frameB,
|
|
'host getter reads the advanced cache after native propagation',
|
|
);
|
|
} finally {
|
|
Date.now = realDateNow;
|
|
scene.frameState.frameNumber = 1;
|
|
}
|
|
const reentryEntity = dataSources[0].entities.getById('rocket-reentry:mission-runtime:0');
|
|
assert.deepEqual(
|
|
selectedPublication[2][1].position,
|
|
reentryEntity.polyline.positions.getValue(Cesium.JulianDate.now())[0],
|
|
're-entry host label must reuse the recovery-path interface Cartesian',
|
|
);
|
|
const transferEntity = dataSources[0].entities.getById('rocket-transfer:mission-runtime');
|
|
assert.deepEqual(
|
|
selectedPublication[2][3].position,
|
|
transferEntity.polyline.positions.getValue(Cesium.JulianDate.now()).at(-1),
|
|
'orbit host label must reuse the insertion Cartesian',
|
|
);
|
|
|
|
launchName = 'Mission Refresh | Gauntlet Payload';
|
|
await rocketLaunchesLayer.update();
|
|
assert.ok(dataSources[0].entities.values.every((entity) => entity.label === undefined));
|
|
const refreshPublication = hostCalls.findLast(([type, sourceId]) => (
|
|
type === 'entries' && sourceId === ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID
|
|
));
|
|
assert.equal(refreshPublication[2][0].title, 'MISSION REFRESH');
|
|
assert.deepEqual(refreshPublication[2][0].details, ['LAUNCH SITE · 39A']);
|
|
|
|
_setSelectedRocketMissionForTest(null);
|
|
const deselectedPublication = hostCalls.findLast(([type, sourceId]) => (
|
|
type === 'entries' && sourceId === ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID
|
|
));
|
|
assert.equal(deselectedPublication[2][0].title, 'MISSION REFRESH');
|
|
assert.equal(deselectedPublication[2][0].protected, false);
|
|
|
|
await rocketLaunchesLayer.disable();
|
|
assert.deepEqual(hostCalls.slice(-4), [
|
|
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
|
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
|
['clear', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID],
|
|
['visible', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false],
|
|
]);
|
|
await rocketLaunchesLayer.destroy(viewer);
|
|
initialized = false;
|
|
assert.equal(dataSources.length, 0);
|
|
assert.deepEqual(hostCalls.slice(-4), [
|
|
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
|
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
|
['clear', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID],
|
|
['visible', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false],
|
|
]);
|
|
} finally {
|
|
if (initialized) await rocketLaunchesLayer.destroy(viewer);
|
|
_setRocketMissionOverlayHostForTest();
|
|
globalThis.fetch = realFetch;
|
|
globalThis.document = realDocument;
|
|
globalThis.HTMLCanvasElement = realHtmlCanvasElement;
|
|
globalThis.HTMLImageElement = realHtmlImageElement;
|
|
globalThis.ImageBitmap = realImageBitmap;
|
|
globalThis.OffscreenCanvas = realOffscreenCanvas;
|
|
}
|
|
});
|
|
|
|
test('enable is transactional: a failed satellites dependency rolls the module back and a retry recaptures', async (t) => {
|
|
const calls = [];
|
|
let satellitesEnabled = false;
|
|
let failNextActivation = true;
|
|
const fakeManager = {
|
|
isEnabled: (id) => (id === 'satellites' ? satellitesEnabled : false),
|
|
getLayerParams: () => ({}),
|
|
setLayerParams: () => {},
|
|
setEnabled: (id, enabled) => {
|
|
calls.push(['setEnabled', id, enabled]);
|
|
if (id === 'satellites' && enabled && failNextActivation) {
|
|
return Promise.resolve(false); // activation refused/failed
|
|
}
|
|
if (id === 'satellites') satellitesEnabled = enabled;
|
|
return Promise.resolve(true);
|
|
},
|
|
};
|
|
const priorDocument = globalThis.document;
|
|
globalThis.document = {
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
};
|
|
rocketLaunchesLayer.attachDataManager(fakeManager);
|
|
t.after(() => {
|
|
globalThis.document = priorDocument;
|
|
rocketLaunchesLayer.attachDataManager(null);
|
|
});
|
|
|
|
// First enable: dependency activation fails -> enable() must reject and the
|
|
// module must roll itself back (satellite snapshot restored AND cleared).
|
|
await assert.rejects(() => rocketLaunchesLayer.enable(), /satellites layer/);
|
|
const restoreCall = calls.filter(([, id, enabled]) => id === 'satellites' && enabled === false);
|
|
assert.ok(restoreCall.length >= 1, 'rollback restored the pre-mission satellites state');
|
|
|
|
// Retry with a healthy dependency: capture must run AGAIN (a retained
|
|
// snapshot would skip it) and enable must succeed.
|
|
calls.length = 0;
|
|
failNextActivation = false;
|
|
await rocketLaunchesLayer.enable();
|
|
assert.deepEqual(
|
|
calls.filter(([, id, enabled]) => id === 'satellites' && enabled === true).length >= 1,
|
|
true,
|
|
'retry recaptured the satellites dependency',
|
|
);
|
|
await rocketLaunchesLayer.disable();
|
|
});
|
|
|
|
test('disable reports a semantic failure while restoring the satellites dependency', async (t) => {
|
|
let satellitesEnabled = false;
|
|
let failRestore = false;
|
|
const fakeManager = {
|
|
isEnabled: (id) => (id === 'satellites' ? satellitesEnabled : false),
|
|
isEffectivelyEnabled: (id) => (id === 'satellites' ? satellitesEnabled : false),
|
|
getLayerParams: () => ({}),
|
|
setLayerParams: () => {},
|
|
setEnabled: (id, enabled) => {
|
|
if (id === 'satellites' && !enabled && failRestore) return Promise.resolve(false);
|
|
if (id === 'satellites') satellitesEnabled = enabled;
|
|
return Promise.resolve(true);
|
|
},
|
|
};
|
|
const priorDocument = globalThis.document;
|
|
globalThis.document = {
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
};
|
|
rocketLaunchesLayer.attachDataManager(fakeManager);
|
|
t.after(() => {
|
|
globalThis.document = priorDocument;
|
|
rocketLaunchesLayer.attachDataManager(null);
|
|
});
|
|
|
|
await rocketLaunchesLayer.enable();
|
|
failRestore = true;
|
|
await assert.rejects(
|
|
() => rocketLaunchesLayer.disable(),
|
|
/could not restore the satellites layer/,
|
|
);
|
|
});
|
|
|
|
|
|
test('missing payload details stay unknown without inventing mass or classification', () => {
|
|
for (const [mass, expected] of [[null, null], [undefined, null], ['', null], [' ', null],
|
|
[false, null], [-1, null], ['invalid', null], [0, 0], ['125', 125]]) {
|
|
const [launch] = normalizeRocketLaunches([{ id: 'mass', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z',
|
|
payloads: [{ mass }] }], NOW);
|
|
assert.equal(launch.payloads[0].massKg, expected);
|
|
assert.equal(launch.payloads[0].name, 'Unnamed payload');
|
|
}
|
|
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
|
|
assert.deepEqual(launch.payloads, []);
|
|
});
|