Files
gods-eye-view/src/app/application.test.mjs
T
German Khokhlov 92c8f8d9da Merge origin/main into german/navara
Main's camera, voice, Cockpit and layer work now runs on the globe
contract where this branch draws through it:

- The voice tilt to an angle, its pivot about the place under the
  crosshair and the frame limit from orbit run through the camera rig;
  src/globeInView.js measures a camera pose with src/geo instead of a
  Cesium camera. The free-look height limit and the globe-in-view
  recovery stay Cesium-only for now.
- Cockpit takes main's own-layer entry rule. Double-clicking the
  followed track goes through cockpitHost.routeDoubleClick, so it works
  on both engines.
- Space Missions' failed-entry rollback and GhostMaps' age filter run
  in the engine-neutral layer code. On Cesium an expired report is
  dropped in place; on Navara its product is redrawn.
- Wildlife Sightings joins the catalog Cesium-only: Navara leaves it
  out, and the shell builds its card only for the real layer, so a
  Navara page no longer fails to start on its stand-in.
- One instruction sentence is shorter, which keeps the Realtime prompt
  within its 55,500-character budget with the Sentinel-2 basemap.
2026-11-07 01:07:26 +04:00

1073 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import * as Cesium from 'cesium';
import {
cesiumFramePose,
installFreeLookHeightLimit,
installGlobeInViewRecovery,
installTrackpadPinchZoom,
} from './viewer.js';
import * as globeInView from '../globeInView.js';
const { FREE_LOOK_MAX_HEIGHT_METERS } = globeInView;
// The frame measure judges a pose; these tests read the Cesium camera's.
const globeFrameShare = (camera, options) =>
globeInView.globeFrameShare(cesiumFramePose(camera), options);
const globeOutOfFrame = (camera, options) =>
globeInView.globeOutOfFrame(cesiumFramePose(camera), options);
const lookAngleWithinFrame = (camera, look, angle, options) =>
globeInView.lookAngleWithinFrame(
cesiumFramePose(camera),
look,
angle,
options,
);
const lookStepLosesGlobe = (camera, look, angle, options) =>
globeInView.lookStepLosesGlobe(cesiumFramePose(camera), look, angle, options);
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
const events = [];
const constructors = Object.fromEntries(
phases.map((phase) => [
`create${phase}`,
({ defer }) => {
events.push(`start:${phase}`);
defer(() => events.push(`stop:${phase}`));
return { name: phase };
},
]),
);
return { events, app: createApplication({ ...constructors, ...overrides }) };
}
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
test('importing and constructing the package is inactive without browser globals', () => {
const result = spawnSync(
process.execPath,
[
// Views are single-file components; Node needs the loader hosts use.
'--import=gods-eye-view/build/register-vue',
'--input-type=module',
'-e',
`
globalThis.fetch = () => { throw new Error('unexpected request'); };
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
const { createApplication } = await import('gods-eye-view/application');
const fail = () => { throw new Error('unexpected construction'); };
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
if (app.getState().status !== 'created') process.exit(1);
`,
],
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr);
});
test('constructors are validated before allocating anything', () => {
assert.throws(() => createApplication({}), /Missing scene constructor/);
});
test('startup shares one promise and teardown keeps data alive for controls', async () => {
const { app, events } = fixture({
createTools({ scene, controls, data, defer }) {
assert.equal(scene.name, 'Scene');
assert.equal(controls.name, 'Controls');
assert.equal(data.name, 'Data');
defer(() => events.push('stop:Tools'));
return { name: 'Tools' };
},
});
const states = [];
app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
const started = app.start();
assert.equal(started, app.start());
const components = await started;
assert.ok(Object.isFrozen(components));
assert.equal(components.tools.name, 'Tools');
assert.equal(app.getState().status, 'ready');
const stopped = app.destroy();
assert.equal(stopped, app.destroy());
await stopped;
assert.deepEqual(events.slice(-4), [
'stop:Tools',
'stop:Controls',
'stop:Data',
'stop:Scene',
]);
assert.deepEqual(app.getComponents(), {});
assert.equal(states.at(-1), 'destroyed:null');
await assert.rejects(app.start(), /destroyed/);
});
test('later components receive the scene engine beside its viewer', async () => {
const viewer = { name: 'viewer' };
const engine = { kind: 'cesium' };
const seen = [];
const receive =
(phase) =>
({ scene }) => {
seen.push([phase, scene.viewer, scene.engine]);
return { name: phase };
};
const { app } = fixture({
createScene: () => ({ viewer, engine }),
createControls: receive('Controls'),
createData: receive('Data'),
createTools: receive('Tools'),
});
const components = await app.start();
assert.equal(components.scene.engine, engine);
assert.deepEqual(seen, [
['Controls', viewer, engine],
['Data', viewer, engine],
['Tools', viewer, engine],
]);
await app.destroy();
});
for (const phase of phases) {
test(`failure during ${phase} releases partially acquired resources`, async () => {
const { app, events } = fixture({
[`create${phase}`]({ defer }) {
defer(() => events.push('partial:released'));
throw new Error('broken constructor');
},
});
await assert.rejects(app.start(), /broken constructor/);
assert.equal(
events.filter((value) => value === 'partial:released').length,
1,
);
assert.equal(app.getState().status, 'failed');
assert.deepEqual(app.getComponents(), {});
await app.destroy();
assert.equal(
events.filter((value) => value === 'partial:released').length,
1,
);
});
}
test('destroy before startup prevents every constructor', async () => {
const { app, events } = fixture();
const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' });
await app.destroy();
await failed;
assert.deepEqual(events, []);
});
test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => {
const gate = deferred();
const entered = deferred();
let observedSignal;
const { app, events } = fixture({
async createScene({ signal, defer }) {
observedSignal = signal;
entered.resolve();
await gate.promise;
defer(() => events.push('late:released'));
return {};
},
});
const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' });
await entered.promise;
let settled = false;
const stopped = app.destroy().then(() => {
settled = true;
});
assert.equal(observedSignal.aborted, true);
await Promise.resolve();
assert.equal(settled, false);
gate.resolve();
await stopped;
await failed;
assert.deepEqual(events, ['late:released']);
});
test('cleanup errors are reported after all remaining resources are attempted', async () => {
const { app, events } = fixture({
createTools({ defer }) {
defer(() => events.push('tools:earlier'));
defer(() => {
throw new Error('dispose failed');
});
return {};
},
});
await app.start();
await assert.rejects(app.destroy(), (error) => {
assert.ok(error instanceof AggregateError);
assert.equal(error.errors[0].message, 'dispose failed');
return true;
});
assert.deepEqual(events.slice(-4), [
'tools:earlier',
'stop:Controls',
'stop:Data',
'stop:Scene',
]);
assert.equal(app.getState().status, 'failed');
});
test('a failed teardown can be retried and repeats only the failed release', async () => {
let failures = 1;
const { app, events } = fixture({
createData({ defer }) {
defer(() => events.push('data:released'));
defer(() => {
events.push('data:detach');
if (failures-- > 0) throw new Error('detach failed');
});
return {};
},
});
await app.start();
await assert.rejects(app.destroy(), AggregateError);
assert.equal(app.getState().status, 'failed');
const firstAttempt = events.splice(0);
assert.deepEqual(firstAttempt.slice(-4), [
'stop:Controls',
'data:detach',
'data:released',
'stop:Scene',
]);
await app.destroy();
assert.deepEqual(events, ['data:detach']);
assert.equal(app.getState().status, 'destroyed');
await assert.rejects(app.start(), /destroyed/);
});
test('subscribers can destroy during startup without allowing the next constructor', async () => {
const { app, events } = fixture();
app.subscribe((state) => {
if (state.phase === 'controls') void app.destroy();
});
await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy();
assert.deepEqual(events, ['start:Scene', 'stop:Scene']);
});
test('destroy during ready notification rejects startup and tears everything down', async () => {
const { app } = fixture();
app.subscribe((state) => {
if (state.status === 'ready') void app.destroy();
});
await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy();
assert.equal(app.getState().status, 'destroyed');
});
test('cleanup registration closes when a constructor settles', async () => {
let register;
const { app } = fixture({
createScene({ defer }) {
register = defer;
return {};
},
});
await app.start();
assert.throws(() => register(() => {}), /during component construction/);
await app.destroy();
});
test('different application instances do not share lifecycle state', async () => {
const first = fixture();
const second = fixture();
await Promise.all([first.app.start(), second.app.start()]);
await first.app.destroy();
assert.equal(second.app.getState().status, 'ready');
assert.equal(second.events.length, 4);
await second.app.destroy();
});
test('the separate viewer export imports without constructing a browser viewer', async () => {
const { createApplicationViewer } =
await import('gods-eye-view/application/viewer');
assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
const { applicationViewerOptions } = await import('./viewer.js');
const { StarrySky } = await import('../sky/starrySky.js');
const creditContainer = {};
const options = applicationViewerOptions(creditContainer);
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
assert.ok(options.skyBox instanceof StarrySky);
assert.equal(options.creditContainer, creditContainer);
options.skyBox.destroy();
});
function pinchFixture({ zoomEventTypes } = {}) {
const handlers = new Set();
const relayed = [];
const container = {
addEventListener(type, handler, options) {
assert.equal(type, 'wheel');
assert.deepEqual(options, { capture: true, passive: false });
handlers.add(handler);
},
removeEventListener(type, handler, capture) {
assert.equal(type, 'wheel');
assert.equal(capture, true);
handlers.delete(handler);
},
emit(event) {
for (const handler of handlers) handler(event);
},
};
const canvas = {
dispatchEvent(event) {
// A real canvas dispatch crosses the container's capture listener again.
container.emit(event);
relayed.push(event);
return true;
},
};
const controller = { zoomEventTypes };
const viewer = {
container,
canvas,
scene: { screenSpaceCameraController: controller },
};
const createEvent = (init = {}) => ({
deltaX: 0,
deltaY: 0,
deltaZ: 0,
deltaMode: 0,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
defaultPrevented: false,
propagationStopped: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {
this.propagationStopped = true;
},
...init,
});
return { viewer, controller, container, handlers, relayed, createEvent };
}
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
const original = [1, 2, 3];
const f = pinchFixture({ zoomEventTypes: original });
const dispose = installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
assert.equal(f.controller.zoomEventTypes.length, 4);
const plain = f.createEvent({ deltaY: 2 });
f.container.emit(plain);
assert.equal(plain.defaultPrevented, false);
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
f.container.emit(lineMode);
assert.equal(lineMode.defaultPrevented, false);
assert.equal(f.relayed.length, 0);
dispose();
dispose();
assert.equal(f.controller.zoomEventTypes, original);
assert.equal(f.handlers.size, 0);
});
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(small);
assert.equal(small.defaultPrevented, true);
assert.equal(small.propagationStopped, true);
assert.equal(f.relayed.length, 1);
assert.equal(f.relayed[0].deltaY, 16);
assert.equal(f.relayed[0].ctrlKey, true);
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
f.container.emit(large);
assert.equal(f.relayed.length, 2);
assert.equal(f.relayed[1].deltaY, -120);
});
test('trackpad pinch falls back to the original event when relay construction fails', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent() {
throw new Error('unsupported');
},
});
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(event);
assert.equal(event.defaultPrevented, false);
assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0);
});
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
const listeners = new Set();
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
const camera = { positionCartographic: { height } };
const viewer = {
camera,
scene: {
screenSpaceCameraController: controller,
preUpdate: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
};
const frame = (nextHeight) => {
camera.positionCartographic = { height: nextHeight };
for (const listener of listeners) listener();
};
return { viewer, controller, listeners, frame };
}
test('free look follows camera height and is restored on dispose', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
assert.equal(f.controller.enableLook, false);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
assert.equal(f.controller.enableLook, true);
f.frame(Number.NaN);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
dispose();
dispose();
assert.equal(f.controller.enableLook, true);
assert.equal(f.listeners.size, 0);
});
test('drags that start on the stars orbit above the free-look height', () => {
// Below Cesium's trackball height such a drag turns the camera in place.
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(
f.controller.minimumTrackBallHeight,
FREE_LOOK_MAX_HEIGHT_METERS,
);
dispose();
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
const lower = freeLookFixture({ height: 500 });
lower.controller.minimumTrackBallHeight = 500_000;
const disposeLower = installFreeLookHeightLimit(lower.viewer);
assert.equal(
lower.controller.minimumTrackBallHeight,
500_000,
'never raised',
);
disposeLower();
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
const later = freeLookFixture({ height: 500 });
const disposeLater = installFreeLookHeightLimit(later.viewer);
later.controller.minimumTrackBallHeight = 2_000_000;
disposeLater();
assert.equal(
later.controller.minimumTrackBallHeight,
2_000_000,
'dispose keeps a value set after install',
);
});
test('free look keeps a setting other code makes after it is installed', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
f.controller.enableLook = false;
f.frame(10);
assert.equal(f.controller.enableLook, false, 'turned off stays off');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
f.frame(10);
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
f.controller.enableLook = true;
f.frame(10);
assert.equal(f.controller.enableLook, true, 'turned back on');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
f.frame(10);
f.controller.enableLook = false;
dispose();
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
});
test('free look stays off when the viewer had it disabled', () => {
const f = freeLookFixture({ enableLook: false, height: 500 });
installFreeLookHeightLimit(f.viewer);
f.frame(10);
assert.equal(f.controller.enableLook, false);
});
// A real Cesium camera on the smallest scene its constructor reads, so the
// frustum test runs on Cesium's own view math rather than a stub.
function realCamera({ width = 1600, height = 900, mode } = {}) {
const scene = {
canvas: { clientWidth: width, clientHeight: height },
drawingBufferWidth: width,
drawingBufferHeight: height,
mapProjection: new Cesium.GeographicProjection(),
mode: mode ?? Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
tweens: new Cesium.TweenCollection(),
renders: 0,
requestRender() {
this.renders += 1;
},
};
const camera = new Cesium.Camera(scene);
camera.frustum.aspectRatio = width / height;
return { camera, scene, viewer: { camera, scene } };
}
function aim(
camera,
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
) {
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: Cesium.Math.toRadians(roll),
},
});
}
const reportedStuckView = {
alt: 17_996_811,
heading: 180,
pitch: -42,
roll: 180,
};
// Oblique poses that still show part of the planet: Director recipe shots
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
const partialGlobeViews = [
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
];
// Stars and a few pixels of limb, no surface: where a frame limit measured
// against a sphere of the planet's largest radius used to stop a voice tilt.
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
test('the globe is out of frame only when no surface is on screen', () => {
const cases = [
[reportedStuckView, 1225, 923, true],
[limbOnlyView, 1600, 900, true],
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
];
for (const [view, width, height, out] of cases) {
const { camera } = realCamera({ width, height });
aim(camera, view);
const label = JSON.stringify(view);
assert.equal(globeOutOfFrame(camera), out, label);
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
assert.ok(
Math.abs(
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
) < 0.03,
`${label}: the frame share matches the pick-ray oracle`,
);
}
});
test('a move that ends with the globe out of frame turns back to nadir', () => {
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(viewer);
aim(camera, reportedStuckView);
const position = Cesium.Cartesian3.clone(camera.positionWC);
const heading = camera.heading;
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
assert.ok(
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(scene.renders, 1);
// Looking up near the ground, then zoomed out past the limit.
aim(camera, { alt: 3_834_000, pitch: 10 });
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Only the limb on a 16:9 screen still counts as lost.
const wide = realCamera({ width: 1600, height: 900 });
const disposeWide = installGlobeInViewRecovery(wide.viewer);
aim(wide.camera, limbOnlyView);
wide.camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
disposeWide();
dispose();
dispose();
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
});
// True when a moveEnd leaves the camera's view direction as it was.
function moveEndKeepsView(camera) {
const direction = Cesium.Cartesian3.clone(camera.directionWC);
camera.moveEnd.raiseEvent();
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
}
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
for (const view of partialGlobeViews) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer);
aim(camera, view);
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
}
const low = realCamera();
installGlobeInViewRecovery(low.viewer);
aim(low.camera, { alt: 900_000, pitch: 85 });
assert.equal(globeOutOfFrame(low.camera), true);
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
// Locked on a point in space, between it and the planet, facing away.
const locked = realCamera();
installGlobeInViewRecovery(locked.viewer);
locked.camera.lookAtTransform(
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
new Cesium.Cartesian3(-1_000_000, 0, 0),
);
assert.equal(globeOutOfFrame(locked.camera), true);
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
// Tracking an entity that has no position yet keeps an identity transform.
const tracked = realCamera();
tracked.viewer.trackedEntity = { id: 'pending-fix' };
installGlobeInViewRecovery(tracked.viewer);
aim(tracked.camera, reportedStuckView);
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
installGlobeInViewRecovery(flat.viewer);
flat.scene.mode = Cesium.SceneMode.SCENE3D;
aim(flat.camera, reportedStuckView);
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
});
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
const radians = Cesium.Math.toRadians;
// About 2% of a 16:9 frame on the planet, below the 10% limit.
const sliver = { alt: 18_000_000, pitch: -60 };
const { camera } = realCamera();
aim(camera, sliver);
const start = surfacePixelShare(camera, 1600, 900);
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
const startShare = globeFrameShare(camera);
for (let i = 0; i < 20; i += 1) {
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
startShare,
});
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
camera.lookLeft(step);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
// Toward the planet is always allowed.
aim(camera, sliver);
assert.equal(
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
radians(10),
);
// Away from it stops short of losing it.
aim(camera, sliver);
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
assert.ok(away < radians(30));
camera.lookUp(away);
assert.ok(globeFrameShare(camera) > 0);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('the look limit leaves a view with no surface to the move-end recovery', () => {
const { camera } = realCamera({ width: 1225, height: 923 });
aim(camera, reportedStuckView);
assert.equal(
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
Cesium.Math.toRadians(5),
);
});
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
// keyhole: the full frame shows some planet, the scope shows none.
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
const { camera } = realCamera();
aim(camera, cornerOnlyView);
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(globeOutOfFrame(camera, keyhole), true);
aim(camera, { alt: 18_000_000, pitch: -90 });
assert.ok(
Math.abs(
globeFrameShare(camera, keyhole) -
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
) < 0.03,
'the keyhole share matches the pick-ray oracle',
);
});
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
for (const [scopeOn, expectRecovery] of [
[true, true],
[false, false],
]) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer, {
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
});
aim(camera, cornerOnlyView);
camera.moveEnd.raiseEvent();
assert.equal(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
expectRecovery,
`scope ${scopeOn ? 'on' : 'off'}`,
);
}
});
test('the recovery leaves a camera flight and an authored scene alone', () => {
let playing = true;
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
scene.camera = camera;
scene.screenSpaceCameraController = { enableInputs: true };
installGlobeInViewRecovery(viewer, { paused: () => playing });
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
playing = false;
// A flight that pauses looking away is left alone until it ends.
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
duration: 5,
});
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
camera.cancelFlight();
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
});
test('a check a scene or flight defers runs once it ends', () => {
for (const viaMoveEnd of [false, true]) {
let playing = true;
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, {
paused: () => playing,
...timers,
});
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
for (let i = 0; i < 4; i += 1) timers.tick();
if (viaMoveEnd) camera.moveEnd.raiseEvent();
// Longer than the settle check's five-minute bound.
for (let i = 0; i < 1500; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
assert.equal(timers.pending(), 1, 'still watching');
playing = false;
timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
);
}
});
// Holding a drag still for half a second on a view that already showed no
// surface looked like a settled camera, and the view was turned down under
// the user's hand.
test('the recovery waits while a pointer is held on the globe', () => {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const document = new EventTarget();
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
document.dispatchEvent(new Event('pointerup'));
for (let i = 0; i < 3; i += 1) timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once the pointer is released',
);
dispose();
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
document.dispatchEvent(new Event('pointerup'));
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
});
/** A held-pointer recovery over a canvas in a document in a window. */
function heldPointerRecovery() {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const window = new EventTarget();
const document = Object.assign(new EventTarget(), {
defaultView: window,
visibilityState: 'visible',
});
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
const pointer = (target, type, pointerId, init = {}) =>
target.dispatchEvent(
Object.assign(new Event(type), { pointerId, ...init }),
);
/** Press, move and hold still: the check defers while a pointer is held. */
const holdStill = () => {
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
};
const settle = () => {
for (let i = 0; i < 3; i += 1) timers.tick();
};
const recovered = () =>
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
return {
camera,
document,
window,
dispose,
pointer,
holdStill,
settle,
recovered,
canvas: scene.canvas,
};
}
// Releasing one of two fingers cleared the held flag, and the recovery
// turned the view down under the finger still on the glass.
test('the recovery waits until every held pointer is released', () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.pointer(h.canvas, 'pointerdown', 2);
h.holdStill();
h.pointer(h.document, 'pointerup', 1);
h.settle();
assert.equal(h.recovered(), false, 'left alone under the second finger');
assert.equal(globeOutOfFrame(h.camera), true);
h.pointer(h.document, 'pointercancel', 2);
h.settle();
assert.equal(h.recovered(), true, 'recovered once both are released');
h.dispose();
});
// A release the page never sees (the pointer let go outside the window)
// must not hold the recovery off for good.
for (const [label, lose] of [
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
[
'the page is hidden',
(h) => {
h.document.visibilityState = 'hidden';
h.document.dispatchEvent(new Event('visibilitychange'));
},
],
[
'the canvas loses the pointer capture',
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
],
[
'the pointer moves with no button down',
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
],
]) {
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.holdStill();
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
h.settle();
assert.equal(h.recovered(), false, 'left alone while held');
lose(h);
h.settle();
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
h.dispose();
});
}
function fakeTimers() {
let next = 1;
const pending = new Map();
return {
setTimeout: (callback) => {
const id = next++;
pending.set(id, callback);
return id;
},
clearTimeout: (id) => pending.delete(id),
pending: () => pending.size,
/** Run the earliest pending callback, as 250 ms passing would. */
tick() {
const [id, callback] = pending.entries().next().value ?? [];
if (id === undefined) return false;
pending.delete(id);
callback();
return true;
},
};
}
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
// At this pose the camera can drift by a rounding error every frame, so
// Cesium never sees it stop and never raises moveEnd for it.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
// A rounding-error drift between samples still counts as at rest.
camera.lookUp(1e-15);
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Under that drift Cesium raises no new moveStart, so a later move that
// ends looking away is caught by the same check.
aim(camera, reportedStuckView);
timers.tick();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
camera.moveEnd.raiseEvent();
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
});
test('the settle check follows a long move and gives up five minutes after the last one', () => {
// A drag of half a minute that stops at a stuck pose is still recovered.
const long = fakeTimers();
const dragged = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(dragged.viewer, long);
aim(dragged.camera, reportedStuckView);
dragged.camera.moveStart.raiseEvent();
for (let i = 0; i < 120; i += 1) {
dragged.camera.moveLeft(0.01);
long.tick();
}
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
while (long.tick());
assert.ok(
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
// A camera that keeps moving is followed, and left alone, for as long as it
// moves; once it rests the check gives up five minutes later.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 2000; i += 1) {
camera.moveLeft(0.01);
timers.tick();
}
assert.equal(timers.pending(), 1, 'still following after eight minutes');
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
let idleSamples = 0;
while (timers.tick()) idleSamples += 1;
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
assert.ok(
idleSamples >= 1200 && idleSamples <= 1205,
`gave up ${idleSamples} samples after it stopped`,
);
const disposed = fakeTimers();
const other = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
aim(other.camera, reportedStuckView);
other.camera.moveStart.raiseEvent();
dispose();
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
});