mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Localize a bootstrap shot title without a source pack only when the Nepal pack marker binds that title to the shot's id, so an operator shot renamed to a bootstrap title in the built-in scene stays as typed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026 lines
81 KiB
JavaScript
2026 lines
81 KiB
JavaScript
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
|
// Director-level pins for scene playback.
|
|
//
|
|
// scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is
|
|
// where every regression in this file's history actually lived: the reconcile
|
|
// walking the whole registry, captured tracking params handing the camera back
|
|
// to the follow loop, a refused enable reported as success, STOP landing layer
|
|
// changes after the operator stopped, and a stale LOAD completing last.
|
|
//
|
|
// The cancellation pins matter twice over: checking a boolean AFTER an await
|
|
// only stops the NEXT step, so the awaited operation itself has to be
|
|
// cancellable — an AbortSignal for the data manager, a liveness predicate for
|
|
// the visual commit. Several of these assert exactly that plumbing.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { LayerLifecycle } from '../data/lifecycle.js';
|
|
import { SceneDirector } from './director.js';
|
|
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
|
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
|
|
|
const NEPAL_ORIGINAL_SHOT_TITLES = [
|
|
'Global Incident Context',
|
|
'Nepal-Focused Globe Rotation',
|
|
'Bhote Koshi Regional Approach',
|
|
'Bhote Koshi Nearby Cities',
|
|
'Bhote Koshi Incident Corridor',
|
|
'Bhote Koshi Flood Path',
|
|
'Bhote Koshi Corridor Overview',
|
|
'Bhote Koshi Upper Valley',
|
|
];
|
|
|
|
function nepalProjectFixture() {
|
|
const project = structuredClone(PROJECT_FIXTURE);
|
|
project.scenes[0].title = 'Nepal Flood Incident';
|
|
project.scenes[0].shots = NEPAL_ORIGINAL_SHOT_TITLES.map((title, index) => ({
|
|
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
|
|
id: `nepal-original-${index + 1}`,
|
|
title,
|
|
camera: {
|
|
lat: 27 + index / 10,
|
|
lon: 85 + index / 10,
|
|
alt: 1000 + index * 100,
|
|
heading: index * 10,
|
|
pitch: -35 - index,
|
|
roll: 0,
|
|
},
|
|
visual: {
|
|
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].visual),
|
|
mapStack: 'photoreal',
|
|
},
|
|
layers: {
|
|
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].layers),
|
|
'bhote-koshi-locator': {
|
|
enabled: index > 0,
|
|
params: { presentation: `fixture-${index + 1}` },
|
|
},
|
|
},
|
|
}));
|
|
project.scenes[0].shots[7].layers['bhote-koshi-locator'] = {
|
|
enabled: true,
|
|
params: { presentation: 'bhote-koshi-trigger-record' },
|
|
};
|
|
project.scenes[0].shots[7].holdSec = 8;
|
|
return project;
|
|
}
|
|
|
|
function legacyThreeShotNepalProjectFixture() {
|
|
const project = structuredClone(PROJECT_FIXTURE);
|
|
project.scenes[0].title = 'Nepal Flood Incident';
|
|
project.scenes[0].shots = [0, 1, 2].map((index) => ({
|
|
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
|
|
id: `legacy-nepal-${index + 1}`,
|
|
title: `Shot ${index + 1}`,
|
|
camera: {
|
|
lat: 20 + index,
|
|
lon: 80 + index,
|
|
alt: 1000000 + index * 100000,
|
|
heading: index * 15,
|
|
pitch: -50 - index,
|
|
roll: 0,
|
|
},
|
|
}));
|
|
return project;
|
|
}
|
|
|
|
function legacyDefaultProjectWithoutNepalFixture() {
|
|
const project = structuredClone(PROJECT_FIXTURE);
|
|
project.scenes[0].id = 'bhote-koshi-flood';
|
|
project.scenes[0].title = 'Bhote Koshi Flood Reconstruction';
|
|
project.scenes[0].shots = [{
|
|
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[0]),
|
|
id: 'standalone-shot',
|
|
title: 'Shot 1',
|
|
}];
|
|
return project;
|
|
}
|
|
|
|
|
|
test('Mailung clip trim estimates seven seconds and its exit, including older saved holds', () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
const shot = scene.shots[0];
|
|
shot.sourcePackId = 'bhote-koshi-nepal-evidence-pack';
|
|
shot.durationSec = 4.2;
|
|
shot.holdSec = 15;
|
|
shot.layers = { 'bhote-koshi-2026': { enabled: true, params: {
|
|
presentation: 'scene-beat', beatId: 'mailung-bazzar',
|
|
} } };
|
|
assert.equal(director._effectiveShotHoldSec(scene, shot), 7.65);
|
|
assert.equal(director._shotRuntimeDurationSec(scene, shot), 4.2 + 7.65);
|
|
const state = director._layerStatesForShot(scene, shot)['bhote-koshi-2026'];
|
|
assert.equal(state.params.sceneContext.holdSec, 7.65);
|
|
assert.equal(state.params.sceneControls.mediaPlaybackHoldSec, 7);
|
|
assert.equal(state.params.sceneControls.minimumHoldSec, 7.65);
|
|
assert.equal(shot.holdSec, 15, 'runtime correction does not rewrite saved shots');
|
|
shot.layers['bhote-koshi-2026'].params.beatId = 'dandagaun';
|
|
assert.equal(director._effectiveShotHoldSec(scene, shot), 15, 'other shots keep their authored hold');
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('Incident Corridor gives all overview pins time to reveal without rewriting saved shots', () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
const shot = scene.shots[0];
|
|
shot.holdSec = 0.9;
|
|
shot.layers = { 'bhote-koshi-locator': {
|
|
enabled: true, params: { presentation: 'bhote-koshi-incident-places' },
|
|
} };
|
|
assert.equal(director._effectiveShotHoldSec(scene, shot), 11);
|
|
assert.equal(shot.holdSec, 0.9);
|
|
shot.holdSec = 15;
|
|
assert.equal(director._effectiveShotHoldSec(scene, shot), 15);
|
|
shot.holdSec = 0.9;
|
|
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-flood-path';
|
|
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
|
|
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-incident-places';
|
|
shot.layers['bhote-koshi-locator'].enabled = false;
|
|
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
|
|
test('scene clock seek resolves the exact shot phase and camera in both directions', async () => {
|
|
const { director, restore } = makeDirector();
|
|
const loads = [];
|
|
director._loadShot = async (sceneId, shotId, options) => {
|
|
loads.push({ sceneId, shotId, options });
|
|
return { started: true, shotId };
|
|
};
|
|
try {
|
|
assert.equal(await director.seekScene('scene-1', 0), true);
|
|
assert.equal(await director.seekScene('scene-1', 0.99), true);
|
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
|
assert.equal(await director.seekScene('missing', 0.5), false);
|
|
assert.deepEqual(loads.map(({ sceneId, shotId }) => ({ sceneId, shotId })), [
|
|
{ sceneId: 'scene-1', shotId: 'shot-a' },
|
|
{ sceneId: 'scene-1', shotId: 'shot-b' },
|
|
{ sceneId: 'scene-1', shotId: 'shot-a' },
|
|
]);
|
|
assert.equal(loads[0].options.sceneSeek.sceneProgress, 0);
|
|
assert.equal(loads[0].options.sceneSeek.cameraProgress, 0);
|
|
assert.ok(loads[1].options.sceneSeek.cameraProgress > 0.9);
|
|
assert.equal(loads[1].options.sceneSeek.shotIndex, 1);
|
|
assert.equal(loads[2].options.sceneSeek.sceneProgress, 0.25);
|
|
assert.equal(loads[2].options.sceneSeek.cameraProgress, 0.5);
|
|
assert.equal(loads[2].options.sceneSeek.camera.lat, 10);
|
|
assert.equal(loads[2].options.sceneSeek.camera.lon, 20);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking restores declared layer visibility after manual changes', async () => {
|
|
const { director, dataManager, styleManager, restore } = makeDirector();
|
|
try {
|
|
const shot = director._project.scenes[0].shots[0];
|
|
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
await dataManager.setEnabled('flights', false);
|
|
await dataManager.setEnabled('traffic', true);
|
|
await dataManager.setEnabled('satellites', true);
|
|
dataManager.setEnabledCalls.length = 0;
|
|
const visualCalls = styleManager.visualCalls.length;
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
|
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
|
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
|
|
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
|
|
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
|
|
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
|
|
assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers');
|
|
assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites'));
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
|
|
let refuse = false;
|
|
const { director, dataManager, styleManager, restore } = makeDirector({
|
|
data: { refuse: () => refuse },
|
|
});
|
|
try {
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
|
await dataManager.setEnabled('flights', false);
|
|
refuse = true;
|
|
const visualCalls = styleManager.visualCalls.length;
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
|
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
|
|
const timing = director.getPlaybackTimingState();
|
|
assert.equal(timing.activeTimers, 0);
|
|
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
|
|
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
|
|
const { director, dataManager, restore } = makeDirector();
|
|
try {
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
const disabling = dataManager.setEnabled('flights', false);
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
|
await disabling;
|
|
await settle();
|
|
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
|
|
const { director, dataManager, styleManager, viewer, restore } = makeDirector();
|
|
const lifecycle = new LayerLifecycle(viewer);
|
|
for (const id of ['flights', 'traffic', 'satellites']) {
|
|
lifecycle.register({
|
|
id,
|
|
name: id,
|
|
init() {},
|
|
enable() {},
|
|
disable() {},
|
|
update() {},
|
|
});
|
|
}
|
|
const realDirector = new SceneDirector(viewer, styleManager, lifecycle);
|
|
try {
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
await dataManager.setEnabled('flights', false);
|
|
dataManager.setEnabledCalls.length = 0;
|
|
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
|
|
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
|
await reload;
|
|
await settle();
|
|
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
|
|
|
|
await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
await lifecycle.setEnabled('flights', false);
|
|
const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) {
|
|
await Promise.resolve();
|
|
}
|
|
assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), {
|
|
enabled: false,
|
|
lifecycleState: 'disabled',
|
|
uncertain: false,
|
|
});
|
|
assert.equal(await realDirector.seekScene('scene-1', 0.25), true);
|
|
await queuedReload;
|
|
await settle();
|
|
assert.equal(lifecycle.isEnabled('flights'), true);
|
|
|
|
lifecycle.layers.get('flights').lifecycleUncertain = true;
|
|
assert.equal(await realDirector.seekScene('scene-1', 0.3), true);
|
|
assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false);
|
|
|
|
let navClaims = 0;
|
|
styleManager.runImmediateNavigation = () => {
|
|
navClaims += 1;
|
|
return false;
|
|
};
|
|
assert.equal(await realDirector.seekScene('scene-1', 0.4), false);
|
|
assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD');
|
|
} finally {
|
|
await realDirector.destroy();
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => {
|
|
let refuse = () => false;
|
|
const { director, dataManager, styleManager, viewer, restore } = makeDirector({
|
|
data: { refuse: (id, on) => refuse(id, on) },
|
|
});
|
|
try {
|
|
const shot = director._project.scenes[0].shots[0];
|
|
shot.layers = {
|
|
flights: { enabled: true },
|
|
traffic: { enabled: true },
|
|
satellites: { enabled: true },
|
|
};
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
|
await dataManager.setEnabled('flights', false);
|
|
await dataManager.setEnabled('traffic', false);
|
|
await dataManager.setEnabled('satellites', false);
|
|
styleManager.contextMode = 'space-missions';
|
|
styleManager.contextExits.length = 0;
|
|
refuse = (id) => id === 'traffic';
|
|
dataManager.setEnabledCalls.length = 0;
|
|
const viewsBefore = viewer.views.length;
|
|
const visualCalls = styleManager.visualCalls.length;
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
|
assert.equal(
|
|
dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'),
|
|
false,
|
|
'stop the plan before later ids are written',
|
|
);
|
|
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
|
|
assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled');
|
|
assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled');
|
|
assert.equal(enabled.get('satellites'), false, 'later stale layer is never written');
|
|
assert.equal(styleManager.contextMode, 'space-missions');
|
|
assert.deepEqual(styleManager.contextExits, []);
|
|
assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose');
|
|
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
|
assert.ok(
|
|
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
|
|
'clock stays at the last settled seek position',
|
|
);
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => {
|
|
const { styleManager, viewer, restore } = makeDirector();
|
|
let holdEnable = false;
|
|
let releaseEnable;
|
|
const enableGate = new Promise((resolve) => {
|
|
releaseEnable = resolve;
|
|
});
|
|
const lifecycle = new LayerLifecycle(viewer);
|
|
lifecycle.register({
|
|
id: 'flights',
|
|
name: 'flights',
|
|
init() {},
|
|
async enable() {
|
|
if (holdEnable) await enableGate;
|
|
},
|
|
disable() {},
|
|
update() {},
|
|
});
|
|
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
|
try {
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
|
await lifecycle.setEnabled('flights', false);
|
|
holdEnable = true;
|
|
const viewsBefore = viewer.views.length;
|
|
|
|
const seek = director.seekScene('scene-1', 0.25);
|
|
for (
|
|
let i = 0;
|
|
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
|
i++
|
|
) {
|
|
await Promise.resolve();
|
|
}
|
|
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
|
const admissionSignal = director._loadAbort?.signal;
|
|
assert.ok(admissionSignal, 'same-shot seek installs an admission controller');
|
|
|
|
const missingLoad = director.loadShot('missing', 'nope');
|
|
assert.equal(
|
|
admissionSignal.aborted,
|
|
true,
|
|
'missing load aborts the admission controller before returning',
|
|
);
|
|
assert.equal((await missingLoad).reason, 'shot-not-found');
|
|
assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort');
|
|
|
|
releaseEnable();
|
|
assert.equal(await seek, false);
|
|
await settle();
|
|
assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored');
|
|
assert.equal(viewer.views.length, viewsBefore, 'setView is not called');
|
|
assert.ok(
|
|
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
|
|
'sceneProgress stays at the last settled value',
|
|
);
|
|
|
|
let loadFallbackCalls = 0;
|
|
const origLoadShot = director._loadShot.bind(director);
|
|
director._loadShot = (...args) => {
|
|
loadFallbackCalls += 1;
|
|
return origLoadShot(...args);
|
|
};
|
|
const origSeekLoadedShot = director._seekLoadedShot.bind(director);
|
|
director._seekLoadedShot = async () => {
|
|
await director.startScene('missing');
|
|
return null;
|
|
};
|
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
|
assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot');
|
|
director._seekLoadedShot = origSeekLoadedShot;
|
|
} finally {
|
|
releaseEnable?.();
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
|
|
const { styleManager, viewer, restore } = makeDirector();
|
|
let holdEnable = true;
|
|
let releaseEnable;
|
|
const enableGate = new Promise((resolve) => {
|
|
releaseEnable = resolve;
|
|
});
|
|
const lifecycle = new LayerLifecycle(viewer);
|
|
lifecycle.register({
|
|
id: 'flights',
|
|
name: 'flights',
|
|
init() {},
|
|
async enable() {
|
|
if (holdEnable) await enableGate;
|
|
},
|
|
disable() {},
|
|
update() {},
|
|
});
|
|
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
|
try {
|
|
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
for (
|
|
let i = 0;
|
|
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
|
i++
|
|
) {
|
|
await Promise.resolve();
|
|
}
|
|
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
|
|
|
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
|
|
holdEnable = false;
|
|
releaseEnable();
|
|
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
|
|
assert.equal(lifecycle.isEnabled('flights'), true);
|
|
} finally {
|
|
releaseEnable?.();
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
|
|
let refuse = () => false;
|
|
const { director, dataManager, restore } = makeDirector({
|
|
data: { refuse: (id, on) => refuse(id, on) },
|
|
});
|
|
try {
|
|
const shot = director._project.scenes[0].shots[0];
|
|
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
|
await dataManager.setEnabled('flights', false);
|
|
await dataManager.setEnabled('traffic', false);
|
|
refuse = (id) => id === 'traffic';
|
|
const manualEnable = dataManager.setEnabled('flights', true);
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
|
await manualEnable;
|
|
await settle();
|
|
assert.equal(
|
|
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
|
|
true,
|
|
'rollback restores the operator\'s intended state, not the stale committed one',
|
|
);
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
|
|
const { director, dataManager, styleManager, restore } = makeDirector();
|
|
try {
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
await dataManager.setEnabled('satellites', true);
|
|
dataManager.setEnabledCalls.length = 0;
|
|
const visualCalls = styleManager.visualCalls.length;
|
|
|
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
|
assert.deepEqual(dataManager.setEnabledCalls, []);
|
|
assert.equal(styleManager.visualCalls.length, visualCalls);
|
|
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
|
|
const { director, restore } = makeDirector();
|
|
const snapshots = [];
|
|
const unsubscribe = director.subscribeSceneClock((snapshot) => snapshots.push(snapshot));
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
director._publishSceneClock(scene, scene.shots[0], 0.1, { running: true });
|
|
director._publishSceneClock(scene, scene.shots[1], 0.3, { running: true });
|
|
unsubscribe();
|
|
director._publishSceneClock(scene, scene.shots[1], 0.4, { running: false });
|
|
assert.deepEqual(snapshots.map(({ shotId, sceneElapsedSec, running }) => ({
|
|
shotId, sceneElapsedSec, running,
|
|
})), [
|
|
{ shotId: 'shot-a', sceneElapsedSec: 0.1, running: true },
|
|
{ shotId: 'shot-b', sceneElapsedSec: 0.3, running: true },
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
|
|
test('the Nepal evidence pack appends once and applies the approved corridor framing', () => {
|
|
const project = nepalProjectFixture();
|
|
const originalShots = structuredClone(project.scenes[0].shots);
|
|
const { director, restore } = makeDirector({ project });
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
const originalShotIds = scene.shots.map(({ id }) => id);
|
|
const first = director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
|
assert.deepEqual(first, {
|
|
appended: true,
|
|
updated: false,
|
|
shotCount: 17,
|
|
patchedShotCount: 14,
|
|
firstShotId: scene.shots[8].id,
|
|
});
|
|
assert.deepEqual(scene.shots.slice(0, 8).map(({ id }) => id), originalShotIds);
|
|
assert.equal(scene.shots.length, 25);
|
|
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-2026'));
|
|
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-locator'));
|
|
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
|
assert.equal(scene.appliedShotPacks[0].version, 18);
|
|
assert.deepEqual(
|
|
Object.keys(scene.appliedShotPacks[0].shotBindings),
|
|
[...NEPAL_ORIGINAL_SHOT_TITLES, ...scene.shots.slice(8).map(({ title }) => title)],
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots.map((shot) => shot.visual.mapStack),
|
|
[
|
|
...Array(25).fill('photoreal'),
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots.map((shot) => shot.layers['bhote-koshi-2026'].params.beatId),
|
|
[
|
|
'immediate-collapse-viewpoint',
|
|
'debris-dammed-lake',
|
|
'gyirong-border-gate',
|
|
'timure-cluster',
|
|
'syabru-besi',
|
|
'bidur-trishuli-bridge',
|
|
'gyirong-border-gate',
|
|
'immediate-collapse-viewpoint',
|
|
'immediate-collapse-viewpoint', 'debris-dammed-lake', 'second-landslide',
|
|
'gyirong-border-gate', 'timure-cluster', 'syabru-besi', 'dhunche',
|
|
'mailung-upper-trishuli', 'mailung-bazzar', 'dandagaun', 'dandagaun-viewpoint',
|
|
'betrawati-bazaar', 'bhainse', 'bidur-trishuli-bridge',
|
|
'devighat-taadi-khola-bridge', 'charaudi', 'final-view',
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots[8].layers['bhote-koshi-2026'].params.sceneControls,
|
|
{ evidenceMediaAutoplay: true },
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots[9].layers['bhote-koshi-2026'].params.sceneControls,
|
|
{ evidenceMediaAutoplay: true },
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots[11].layers['bhote-koshi-2026'].params.sceneControls,
|
|
{
|
|
imageryComparison: true,
|
|
evidenceSequence: true,
|
|
evidenceSequenceDurationSec: 2.5,
|
|
deferEvidenceUntilCameraSettled: true,
|
|
evidenceRevealDurationSec: 2.5,
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots.slice(12, 14).map(
|
|
(shot) => shot.layers['bhote-koshi-2026'].params.sceneControls,
|
|
),
|
|
[
|
|
{
|
|
imageryComparison: true,
|
|
evidenceSequence: true,
|
|
evidenceSequenceDurationSec: 4,
|
|
},
|
|
{
|
|
imageryComparison: true,
|
|
evidenceSequence: true,
|
|
evidenceSequenceDurationSec: 4,
|
|
},
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
scene.shots[21].layers['bhote-koshi-2026'].params.sceneControls,
|
|
{
|
|
evidenceSequence: true,
|
|
evidenceSequenceDurationSec: 4,
|
|
},
|
|
);
|
|
for (const shot of scene.shots) {
|
|
assert.equal(shot.layers['bhote-koshi-2026'].enabled, true);
|
|
assert.equal(shot.layers['bhote-koshi-2026'].params.presentation, 'scene-beat');
|
|
}
|
|
assert.deepEqual(
|
|
scene.shots.slice(0, 8).filter((_, index) => index !== 4).map(({ camera }) => camera),
|
|
originalShots.filter((_, index) => index !== 4).map(({ camera }) => camera),
|
|
);
|
|
assert.deepEqual(scene.shots[4].camera, {
|
|
lat: 28.0529,
|
|
lon: 85.2189,
|
|
alt: 126931,
|
|
heading: 0,
|
|
pitch: -90,
|
|
roll: 0,
|
|
});
|
|
assert.equal(scene.shots[4].holdSec, 11);
|
|
assert.deepEqual(
|
|
scene.shots.slice(0, 8).map((shot) => shot.layers['bhote-koshi-locator']),
|
|
originalShots.map((shot) => shot.layers['bhote-koshi-locator']),
|
|
);
|
|
assert.equal(new Set(scene.shots.map(({ id }) => id)).size, 25);
|
|
assert.deepEqual(
|
|
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
|
{ appended: false, reason: 'already-appended' },
|
|
);
|
|
assert.equal(scene.shots.length, 25);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('installed v12 Nepal pack inserts ten points without replacing renamed cameras', () => {
|
|
const first = makeDirector({ project: nepalProjectFixture() });
|
|
let legacy;
|
|
const recipe = getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack');
|
|
try {
|
|
first.director.appendShotPack('scene-1', recipe.id);
|
|
legacy = structuredClone(first.director._project);
|
|
const scene = legacy.scenes[0];
|
|
scene.shots = scene.shots.filter((shot) => !shot.sourcePackId
|
|
|| recipe.previousRequiredSourcePackBeatIds.includes(shot.layers['bhote-koshi-2026'].params.beatId));
|
|
scene.appliedShotPacks[0].version = 12;
|
|
const ids = new Set(scene.shots.map(({ id }) => id));
|
|
scene.appliedShotPacks[0].shotBindings = Object.fromEntries(
|
|
Object.entries(scene.appliedShotPacks[0].shotBindings).filter(([, id]) => ids.has(id)));
|
|
scene.shots[8].title = 'My collapse camera';
|
|
scene.shots[8].camera.heading = 149;
|
|
} finally { first.restore(); }
|
|
const originalShots = structuredClone(legacy.scenes[0].shots);
|
|
const upgraded = makeDirector({ project: legacy });
|
|
try {
|
|
const scene = upgraded.director._project.scenes[0];
|
|
assert.equal(scene.shots.length, 25);
|
|
for (const old of originalShots) {
|
|
const current = scene.shots.find(({ id }) => id === old.id);
|
|
assert.ok(current);
|
|
assert.deepEqual(current.camera, old.camera);
|
|
assert.equal(current.title, old.title);
|
|
}
|
|
assert.deepEqual(scene.shots.filter((shot) => shot.sourcePackId).map((shot) =>
|
|
shot.layers['bhote-koshi-2026'].params.beatId), recipe.requiredSourcePackBeatIds);
|
|
assert.equal(scene.appliedShotPacks[0].version, 18);
|
|
assert.equal(upgraded.director.appendShotPack(scene.id, recipe.id).reason, 'already-appended');
|
|
} finally { upgraded.restore(); }
|
|
});
|
|
|
|
|
|
test('a legacy three-shot Nepal browser project bootstraps to the current 25-shot sequence', () => {
|
|
const project = legacyThreeShotNepalProjectFixture();
|
|
const originalIds = project.scenes[0].shots.map(({ id }) => id);
|
|
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
|
|
const { director, restore } = makeDirector({ project });
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
assert.equal(scene.shots.length, 25);
|
|
assert.deepEqual(scene.shots.map(({ title }) => title),
|
|
getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack').requiredShotTitles);
|
|
assert.deepEqual(scene.shots.slice(0, 3).map(({ id }) => id), originalIds);
|
|
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
|
|
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
|
assert.equal(scene.appliedShotPacks[0].version, 18);
|
|
// displayShotTitle trusts these bindings to localize the bootstrap shots.
|
|
for (const shot of scene.shots.slice(0, 8)) {
|
|
assert.equal(scene.appliedShotPacks[0].shotBindings[shot.title], shot.id);
|
|
}
|
|
assert.equal(director._selectedSceneId, scene.id);
|
|
assert.equal(director._selectedShotId, scene.shots[0].id);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('the Nepal evidence pack refuses a partial inventory without mutating the scene', () => {
|
|
const project = nepalProjectFixture();
|
|
project.scenes[0].shots[3].title = 'Renamed Nearby Cities';
|
|
const { director, restore } = makeDirector({ project });
|
|
try {
|
|
const sceneBefore = structuredClone(director._project.scenes[0]);
|
|
assert.deepEqual(
|
|
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
|
{
|
|
appended: false,
|
|
updated: false,
|
|
reason: 'shot-inventory-mismatch',
|
|
},
|
|
);
|
|
assert.deepEqual(director._project.scenes[0], sceneBefore);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('the Nepal pack upgrades the upper-valley shots without duplicating evidence beats', () => {
|
|
const initial = makeDirector({ project: nepalProjectFixture() });
|
|
initial.director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
|
const project = structuredClone(initial.director._project);
|
|
initial.restore();
|
|
project.scenes[0].appliedShotPacks = [{
|
|
id: 'bhote-koshi-nepal-evidence-pack',
|
|
version: 4,
|
|
}];
|
|
for (const shot of project.scenes[0].shots.slice(0, 8)) {
|
|
delete shot.layers['bhote-koshi-2026'];
|
|
}
|
|
const originalLength = project.scenes[0].shots.length;
|
|
const originalIds = project.scenes[0].shots.map(({ id }) => id);
|
|
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
|
|
const originalLocators = project.scenes[0].shots.map(
|
|
(shot) => structuredClone(shot.layers['bhote-koshi-locator']),
|
|
);
|
|
const { director, restore } = makeDirector({ project });
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
assert.deepEqual(
|
|
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
|
{ appended: false, reason: 'already-appended' },
|
|
);
|
|
assert.equal(scene.shots.length, originalLength);
|
|
assert.deepEqual(scene.shots.map(({ id }) => id), originalIds);
|
|
assert.deepEqual(scene.shots.map(({ camera }) => camera), originalCameras);
|
|
assert.deepEqual(
|
|
scene.shots.map((shot) => shot.layers['bhote-koshi-locator']),
|
|
originalLocators,
|
|
);
|
|
assert.deepEqual(scene.shots[8].camera, {
|
|
lat: 28.417,
|
|
lon: 85.3937,
|
|
alt: 9850,
|
|
heading: 146,
|
|
pitch: -34,
|
|
roll: 0,
|
|
});
|
|
assert.equal(scene.appliedShotPacks[0].version, 18);
|
|
assert.equal(Object.keys(scene.appliedShotPacks[0].shotBindings).length, 25);
|
|
assert.ok(scene.shots.every((shot) => (
|
|
shot.layers['bhote-koshi-2026']?.enabled === true
|
|
&& shot.layers['bhote-koshi-2026']?.params?.presentation === 'scene-beat'
|
|
)));
|
|
const afterUpgrade = structuredClone(scene);
|
|
assert.deepEqual(
|
|
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
|
{ appended: false, reason: 'already-appended' },
|
|
);
|
|
assert.deepEqual(scene, afterUpgrade);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
|
|
test('an older default project gains the complete selectable Nepal scene once', () => {
|
|
const project = legacyDefaultProjectWithoutNepalFixture();
|
|
const { director, restore } = makeDirector({
|
|
project,
|
|
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
|
});
|
|
try {
|
|
const nepal = director._project.scenes.find(({ title }) => title === 'Nepal Flood Incident');
|
|
assert.ok(nepal);
|
|
assert.equal(nepal.shots.length, 25);
|
|
assert.equal(nepal.shots[0].title, 'Global Incident Context');
|
|
assert.equal(nepal.shots.at(-1).title, 'Final view');
|
|
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a previously installed Nepal scene stays deleted when its marker remains', () => {
|
|
const project = legacyDefaultProjectWithoutNepalFixture();
|
|
project.installedBuiltInSceneIds = ['bhote-koshi-nepal-scene'];
|
|
const { director, restore } = makeDirector({ project });
|
|
try {
|
|
assert.equal(
|
|
director._project.scenes.some(({ title }) => title === 'Nepal Flood Incident'),
|
|
false,
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('public defaults include Nepal without an extra standalone flood recipe', () => {
|
|
assert.equal(SCENE_RECIPES.some((item) => item.id === 'bhote-koshi-flood'), false);
|
|
assert.equal(SCENE_RECIPES.filter((item) => item.id === 'bhote-koshi-nepal-scene').length, 1);
|
|
});
|
|
|
|
|
|
test('an existing public default project gains Nepal without replacing authored shots', () => {
|
|
const project = structuredClone(PROJECT_FIXTURE);
|
|
project.scenes[0].id = 'flights-radar';
|
|
const original = structuredClone(project.scenes[0].shots);
|
|
const { director, restore } = makeDirector({ project });
|
|
try {
|
|
assert.deepEqual(director._project.scenes[0].shots.map(({ id, camera }) => ({ id, camera })),
|
|
original.map(({ id, camera }) => ({ id, camera })));
|
|
assert.equal(director._project.scenes[1].title, 'Nepal Flood Incident');
|
|
assert.equal(director._project.scenes[1].shots.length, 25);
|
|
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('Nepal comparison shots load Esri beneath Vantor even from a saved OSM or photoreal shot', async () => {
|
|
const { director, styleManager, restore } = makeDirector({
|
|
project: nepalProjectFixture(),
|
|
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
|
});
|
|
try {
|
|
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
|
const scene = director._project.scenes[0];
|
|
const comparisonShots = scene.shots.filter((shot) =>
|
|
director._layerStatesForShot(scene, shot)['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison);
|
|
assert.ok(comparisonShots.length >= 2, 'the real comparison beats must be exercised');
|
|
for (const shot of comparisonShots) {
|
|
for (const prior of ['osm', 'photoreal']) {
|
|
shot.visual.mapStack = prior;
|
|
await director.loadShot(scene.id, shot.id, { flyDuration: 0.2 });
|
|
assert.equal(styleManager.visualStates.at(-1).mapStack, 'esri-imagery', shot.title);
|
|
}
|
|
}
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('all saved Nepal shots choose a usable map in keyed and keyless runtimes without rewriting the project', async () => {
|
|
let photorealAvailable = false;
|
|
const { director, styleManager, restore } = makeDirector({
|
|
project: nepalProjectFixture(),
|
|
isMapStackAvailable: (id) => id === 'photoreal' && photorealAvailable,
|
|
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
|
});
|
|
try {
|
|
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
|
const scene = director._project.scenes[0];
|
|
const savedShots = structuredClone(scene.shots);
|
|
for (const available of [false, true, false]) {
|
|
photorealAvailable = available;
|
|
for (const shot of scene.shots) {
|
|
const comparison = director._layerStatesForShot(scene, shot)
|
|
['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison;
|
|
const visual = director._visualStateForShot(shot);
|
|
assert.equal(visual.mapStack, available && !comparison ? 'photoreal' : 'esri-imagery', shot.title);
|
|
}
|
|
await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2 });
|
|
assert.equal(styleManager.visualStates.at(-1).mapStack, available ? 'photoreal' : 'esri-imagery');
|
|
}
|
|
assert.deepEqual(scene.shots, savedShots);
|
|
const unrelated = { visual: { mapStack: 'photoreal' }, layers: { flights: { enabled: true } } };
|
|
assert.equal(director._visualStateForShot(unrelated), unrelated.visual, 'other scenes keep their provider policy');
|
|
} finally {
|
|
await director.destroy();
|
|
restore();
|
|
}
|
|
});
|
|
|
|
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
|
|
const REGISTERED = [
|
|
'flights', 'military', 'earthquakes', 'satellites', 'rocket-launches', 'traffic',
|
|
'cctv', 'radio', 'bikeshare', 'ais-live-vessels', 'military-installations',
|
|
'military-awareness', 'local-datacenters', 'local-dams',
|
|
'telegeography-submarine-cables', 'local-firms',
|
|
];
|
|
|
|
/** Layers Space Missions permits while it isolates the globe (contextModePolicy). */
|
|
const SPACE_MISSIONS_ALLOWED = new Set(['rocket-launches', 'satellites', 'radio']);
|
|
|
|
const PROJECT_FIXTURE = {
|
|
version: 3,
|
|
scenes: [{
|
|
id: 'scene-1',
|
|
title: 'Fixture Scene',
|
|
shots: [
|
|
{
|
|
id: 'shot-a',
|
|
title: 'Shot A',
|
|
durationSec: 0.2,
|
|
holdSec: 0,
|
|
camera: { lat: 10, lon: 20, alt: 500000, heading: 0, pitch: -40, roll: 0 },
|
|
visual: { style: 'normal' },
|
|
layers: { flights: { enabled: true } },
|
|
},
|
|
{
|
|
id: 'shot-b',
|
|
title: 'Shot B',
|
|
durationSec: 0.2,
|
|
holdSec: 0,
|
|
camera: { lat: -30, lon: 140, alt: 900000, heading: 0, pitch: -40, roll: 0 },
|
|
visual: { style: 'retro' },
|
|
layers: { traffic: { enabled: true } },
|
|
},
|
|
],
|
|
}],
|
|
};
|
|
|
|
/**
|
|
* Stub the browser globals the director touches, headlessly. `storage` replaces
|
|
* the seeded entries (key -> raw string); setting `localStorage.full` makes
|
|
* every write throw a quota error.
|
|
*/
|
|
function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
|
|
const originalDocument = globalThis.document;
|
|
const originalLocalStorage = globalThis.localStorage;
|
|
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
|
|
|
globalThis.document = {
|
|
getElementById: () => null,
|
|
createElement: () => ({ classList: noopClassList, style: {}, appendChild() {}, remove() {} }),
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
body: { classList: noopClassList, appendChild() {} },
|
|
};
|
|
const stored = new Map(storage
|
|
? Object.entries(storage)
|
|
: [['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
|
globalThis.localStorage = {
|
|
stored,
|
|
full: false,
|
|
getItem: (key) => stored.get(key) ?? null,
|
|
setItem(key, value) {
|
|
if (this.full) throw new DOMException('quota exceeded', 'QuotaExceededError');
|
|
stored.set(key, value);
|
|
},
|
|
removeItem: (key) => stored.delete(key),
|
|
};
|
|
|
|
return () => {
|
|
globalThis.document = originalDocument;
|
|
globalThis.localStorage = originalLocalStorage;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Data manager double recording every reconcile call the director makes.
|
|
*
|
|
* Models the real abort contract (src/data/manager.js): an aborted transition
|
|
* is rolled back through the module's own disable() and answers false, so an
|
|
* abort leaves NO half-applied layer — which is the whole point of passing the
|
|
* signal rather than only checking a boolean afterwards.
|
|
*/
|
|
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
|
|
const enabled = new Map();
|
|
const pending = new Map();
|
|
const setEnabledCalls = [];
|
|
const setParamsCalls = [];
|
|
const committed = [];
|
|
return {
|
|
setEnabledCalls,
|
|
subscribeVisibilityRequests(listener) {
|
|
this.visibilityListener = listener;
|
|
return () => { this.visibilityListener = null; };
|
|
},
|
|
setParamsCalls,
|
|
committed,
|
|
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
|
getLayerLifecycleState: (id) => {
|
|
if (!registered.includes(id)) return null;
|
|
const on = !!enabled.get(id);
|
|
const target = pending.get(id);
|
|
return {
|
|
enabled: on,
|
|
lifecycleState:
|
|
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
|
|
};
|
|
},
|
|
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
|
|
getLayerParams: () => null,
|
|
async setEnabled(id, shouldEnable, { signal } = {}) {
|
|
setEnabledCalls.push({ id, enabled: shouldEnable, signal });
|
|
if (refuse(id, shouldEnable)) return false;
|
|
// Yield once so a stop landing during the transition is observable.
|
|
pending.set(id, shouldEnable);
|
|
await Promise.resolve();
|
|
if (pending.get(id) === shouldEnable) pending.delete(id);
|
|
if (signal?.aborted) return false;
|
|
enabled.set(id, shouldEnable);
|
|
committed.push({ id, enabled: shouldEnable });
|
|
return true;
|
|
},
|
|
setLayerParams(id, params) {
|
|
setParamsCalls.push({ id, params });
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Style manager double covering the camera, visual, and Context facades. */
|
|
function fakeStyleManager({ contextMode = null, exitFails = false } = {}) {
|
|
const manager = {
|
|
contextMode,
|
|
contextExits: [],
|
|
visualStates: [],
|
|
visualCalls: [],
|
|
runImmediateNavigation: (noun, navigate) => navigate(),
|
|
applyVisualState: async (visual, options = {}) => {
|
|
manager.visualStates.push(visual);
|
|
manager.visualCalls.push({ visual, isCurrent: options.isCurrent });
|
|
return true;
|
|
},
|
|
getCameraState: () => ({ lat: 0, lon: 0, alt: 1000, heading: 0, pitch: -40, roll: 0 }),
|
|
getVisualState: () => ({ style: 'normal' }),
|
|
setRecordingMode() {},
|
|
getContextModeState: () => ({ mode: manager.contextMode, entering: null }),
|
|
async setContextMode(mode) {
|
|
manager.contextExits.push(mode);
|
|
if (exitFails) return { ok: false, error: 'transition did not complete' };
|
|
manager.contextMode = null;
|
|
return { ok: true };
|
|
},
|
|
};
|
|
return manager;
|
|
}
|
|
|
|
/** Cesium viewer double whose flights complete on the next microtask turn. */
|
|
function fakeViewer() {
|
|
const flights = [];
|
|
const views = [];
|
|
let cancelled = 0;
|
|
return {
|
|
flights,
|
|
views,
|
|
get cancelledFlights() { return cancelled; },
|
|
camera: {
|
|
flyTo(options) {
|
|
flights.push(options);
|
|
Promise.resolve().then(() => options.complete?.());
|
|
},
|
|
setView(options) {
|
|
views.push(options);
|
|
},
|
|
cancelFlight() { cancelled++; },
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Yield enough turns for the director's pending awaits to advance. */
|
|
async function settle(turns = 8) {
|
|
for (let i = 0; i < turns; i++) await Promise.resolve();
|
|
}
|
|
|
|
/** Build a director over doubles, with the fixture project loaded. */
|
|
function makeDirector(options = {}) {
|
|
const restore = installSceneRuntime(options.project, { storage: options.storage });
|
|
const storage = globalThis.localStorage;
|
|
const viewer = fakeViewer();
|
|
const styleManager = fakeStyleManager(options.style);
|
|
const dataManager = fakeDataManager(options.data);
|
|
const director = new SceneDirector(viewer, styleManager, dataManager, {
|
|
isMapStackAvailable: options.isMapStackAvailable,
|
|
});
|
|
// Telemetry is only accumulated during a run; observable-failure assertions
|
|
// need the accumulator without driving a whole run.
|
|
director._activeRun = { events: [] };
|
|
return { director, viewer, styleManager, dataManager, storage, restore };
|
|
}
|
|
|
|
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
|
|
const { director, dataManager, restore } = makeDirector();
|
|
let owner = null;
|
|
const owners = [];
|
|
dataManager.layers = new Map([['flights', { module: {
|
|
setSceneMediaPlayback(value = null) { owner = value; if (value) owners.push(value.shotId); },
|
|
} }]]);
|
|
try {
|
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
|
assert.equal(owner, null, 'passive LOAD is not playback');
|
|
await director.replayShot('scene-1', 'shot-a');
|
|
assert.equal(owner?.shotId, 'shot-a');
|
|
assert.equal(owner.token.cancelled, false);
|
|
await director.loadShot('missing-scene', 'missing-shot');
|
|
assert.equal(owner?.shotId, 'shot-a', 'rejected navigation does not revoke another action');
|
|
director._running = true;
|
|
await director.loadShot('scene-1', 'shot-b');
|
|
assert.equal(owner?.shotId, 'shot-a', 'LOAD refused during a run cannot stop its media');
|
|
director._running = false;
|
|
await director.loadShot('scene-1', 'shot-b', { flyDuration: 0 });
|
|
assert.equal(owner, null, 'replacement LOAD revokes the prior replay');
|
|
await director.replayShot('scene-1', 'shot-a');
|
|
director.stopScene();
|
|
assert.equal(owner, null);
|
|
director._sleep = async () => {};
|
|
owners.length = 0;
|
|
await director.startScene('scene-1', { single: true, preview: false });
|
|
assert.deepEqual(owners, ['shot-a'], 'only enabled opt-in layers receive playback authority');
|
|
assert.equal(owner, null, 'run completion releases the last shot owner');
|
|
} finally { await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('cross-scene replay grants media ownership only after the previous scene releases its layers', async () => {
|
|
const { director, dataManager, restore } = makeDirector();
|
|
let owner = null;
|
|
const events = [];
|
|
const media = { setSceneMediaPlayback(value = null) { owner = value; } };
|
|
dataManager.layers = new Map([['flights', { module: media }]]);
|
|
const setEnabled = dataManager.setEnabled.bind(dataManager);
|
|
dataManager.setEnabled = async (id, enabled, options) => {
|
|
if (id === 'flights') {
|
|
events.push({ enabled, owner: owner?.shotId ?? null });
|
|
if (!enabled) media.setSceneMediaPlayback();
|
|
}
|
|
return setEnabled(id, enabled, options);
|
|
};
|
|
try {
|
|
const previous = director._project.scenes[0];
|
|
previous.releaseLayerIds = ['flights'];
|
|
const target = structuredClone(previous);
|
|
target.id = 'other-media-scene';
|
|
director._project.scenes.push(target);
|
|
await director.loadShot(previous.id, 'shot-a', { flyDuration: 0 });
|
|
events.length = 0;
|
|
const result = await director.replayShot(target.id, 'shot-a');
|
|
assert.equal(result.started, true);
|
|
assert.deepEqual(events.slice(0, 2), [
|
|
{ enabled: false, owner: null },
|
|
{ enabled: true, owner: 'shot-a' },
|
|
], 'old-scene disable must finish before granting the new owner');
|
|
assert.equal(owner?.sceneId, target.id);
|
|
assert.equal(owner?.token.cancelled, false);
|
|
director.stopScene();
|
|
assert.equal(owner, null);
|
|
} finally { await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('scene camera waits for provider completion and fade rather than the saved media hold', async () => {
|
|
const { director, viewer, dataManager, restore } = makeDirector();
|
|
let release;
|
|
let pending = true;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
try {
|
|
const scene = director._project.scenes[0];
|
|
scene.shots[0].layers.flights.params = { beatId: 'trimmed' };
|
|
dataManager.layers = new Map([['flights', { module: {
|
|
getSceneShotMediaHold: (id) => id === 'trimmed' ? { pending, maxWaitMs: 20000 } : null,
|
|
} }]]);
|
|
const waits = [];
|
|
director._sleep = async (ms) => { waits.push(ms); if (ms === 70) await gate; };
|
|
const run = director.startScene('scene-1', { single: true, preview: false });
|
|
await settle(40);
|
|
assert.equal(viewer.flights.length, 1, 'no next flight while source is starting, playing or fading');
|
|
assert.deepEqual(waits, [70]);
|
|
pending = false; release();
|
|
await run;
|
|
assert.equal(viewer.flights.length, 2, 'the next shot starts immediately after owned media completion');
|
|
} finally { release(); await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('Stop cancels a provider-owned shot hold and late completion cannot fly the next camera', async () => {
|
|
const { director, viewer, dataManager, restore } = makeDirector();
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
try {
|
|
dataManager.layers = new Map([['flights', { module: {
|
|
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 20000 }),
|
|
} }]]);
|
|
director._sleep = () => gate;
|
|
const run = director.startScene('scene-1', { single: true, preview: false });
|
|
await settle(40);
|
|
assert.equal(viewer.flights.length, 1);
|
|
director.stopScene('test stop'); release(); await run;
|
|
assert.equal(viewer.flights.length, 1);
|
|
assert.equal(director.running, false);
|
|
} finally { release(); await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('a provider-owned hold fails boundedly if its owner never settles', async () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
director.dataManager.layers = new Map([['flights', { module: {
|
|
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 0 }),
|
|
} }]]);
|
|
const scene = director._project.scenes[0];
|
|
await assert.rejects(director._holdShot(scene, scene.shots[0], { cancelled: false }), /bounded playback window/);
|
|
} finally { await director.destroy(); restore(); }
|
|
});
|
|
|
|
/** The layer map a shipped recipe declares, in normalized form. */
|
|
function recipeLayers(recipeId) {
|
|
const recipe = SCENE_RECIPES.find((item) => item.id === recipeId);
|
|
return Object.fromEntries(
|
|
Object.entries(recipe.layers).map(([id, enabled]) => [id, { enabled }]),
|
|
);
|
|
}
|
|
|
|
test('destroyed directors refuse seek, replay, adjacent and continuation without writes', async () => {
|
|
const { director, viewer, dataManager, restore } = makeDirector();
|
|
try {
|
|
await director.destroy();
|
|
director._loadedSceneId = 'scene-1';
|
|
assert.equal(await director.seekScene('scene-1', 0), false);
|
|
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
|
|
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
|
|
assert.equal((await director.continueScene('scene-1', 'shot-a')).started, false);
|
|
assert.equal(viewer.flights.length, 0);
|
|
assert.equal(dataManager.setParamsCalls.length, 0);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('replay, adjacent and seek report layer refusal rather than success', async () => {
|
|
const { director, restore } = makeDirector({ data: { refuse: () => true } });
|
|
try {
|
|
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
|
|
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
|
|
assert.equal(await director.seekScene('scene-1', 0), false);
|
|
} finally { await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('a seek waiting for run teardown is revoked by newer Stop, Load, Start or destroy', async () => {
|
|
for (const action of ['stop', 'load', 'start', 'destroy']) {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
let release;
|
|
director._waitForRunIdle = () => new Promise((resolve) => { release = resolve; });
|
|
director._running = true;
|
|
director._runToken = { cancelled: false };
|
|
const seek = director.seekScene('scene-1', 0);
|
|
assert.equal(director._pendingWork.has(seek), true);
|
|
let newer;
|
|
if (action === 'stop') director.stopScene();
|
|
if (action === 'load') newer = director.loadShot('scene-1', 'shot-b');
|
|
if (action === 'start') newer = director.startScene('scene-1');
|
|
if (action === 'destroy') newer = director.destroy();
|
|
director._running = false;
|
|
release();
|
|
assert.equal(await seek, false, action);
|
|
await newer;
|
|
} finally { await director.destroy(); restore(); }
|
|
}
|
|
});
|
|
|
|
test('explicit scene-layer OFF revokes continuation during enable, flight and hold; internal OFF does not', async () => {
|
|
for (const phase of ['enable', 'flight', 'hold']) {
|
|
const { director, dataManager, restore } = makeDirector();
|
|
try {
|
|
director._project.scenes[0].releaseLayerIds = ['flights'];
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
if (phase === 'enable') {
|
|
const enable = dataManager.setEnabled.bind(dataManager);
|
|
dataManager.setEnabled = async (...args) => { await gate; return enable(...args); };
|
|
} else if (phase === 'flight') director._flyCamera = () => gate;
|
|
else director._sleep = () => gate;
|
|
director._project.scenes[0].shots[0].holdSec = 1;
|
|
const run = director.startScene('scene-1', { single: true, preview: false });
|
|
await settle(40);
|
|
const token = director._runToken;
|
|
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'scene' });
|
|
assert.equal(token.cancelled, false);
|
|
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'user' });
|
|
assert.equal(token.cancelled, true, phase);
|
|
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'tool' });
|
|
release();
|
|
await run;
|
|
assert.equal(dataManager.committed.some(({ id, enabled }) => id === 'traffic' && enabled), false);
|
|
} finally { await director.destroy(); restore(); }
|
|
}
|
|
});
|
|
|
|
test('scene preview owns recording chrome while panel playback leaves it available', async () => {
|
|
for (const preview of [true, false]) {
|
|
const { director, styleManager, restore } = makeDirector();
|
|
const recording = [];
|
|
const playback = [];
|
|
let release;
|
|
const flight = new Promise((resolve) => { release = resolve; });
|
|
director._flyCamera = () => flight;
|
|
styleManager.setRecordingMode = (active) => recording.push(active);
|
|
director._setPlaybackActive = (active) => playback.push(active);
|
|
try {
|
|
const run = director.startScene('scene-1', { single: true, preview });
|
|
await settle(40);
|
|
assert.deepEqual(recording, preview ? [true] : []);
|
|
assert.deepEqual(playback, preview ? [true] : []);
|
|
director.stopScene();
|
|
release();
|
|
await run;
|
|
assert.deepEqual(recording, preview ? [true, false] : []);
|
|
assert.deepEqual(playback, preview ? [true, false] : []);
|
|
} finally { release(); await director.destroy(); restore(); }
|
|
}
|
|
});
|
|
|
|
test('the director reconciles only the layers a shot declares', async () => {
|
|
// Regression: _applyLayerStates walked the LIVE registry and forced every
|
|
// undeclared layer off, tearing down CCTV/vessels/fires with no restore pass.
|
|
// Pinned here rather than only on the helper, because the walk lived here.
|
|
const { director, dataManager, restore } = makeDirector();
|
|
try {
|
|
await director._applyLayerStates({ flights: { enabled: true }, satellites: { enabled: false } });
|
|
assert.deepEqual(
|
|
dataManager.setEnabledCalls.map(({ id, enabled }) => ({ id, enabled })),
|
|
[
|
|
{ id: 'flights', enabled: true },
|
|
{ id: 'satellites', enabled: false },
|
|
],
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a shot captured while tracking never re-establishes tracking on playback', async () => {
|
|
// Two writers on the camera is the documented jitter failure mode: the scene
|
|
// claims the camera, then a captured tracking id hands it straight back to
|
|
// the follow loop. Playback drops those keys on the way to the layer.
|
|
const { director, dataManager, restore } = makeDirector();
|
|
try {
|
|
await director._applyLayerStates({
|
|
flights: { enabled: true, params: { models3d: true, selectedFlightsTrackingId: 'a835af' } },
|
|
military: { enabled: true, params: { selectedMilitaryTrackingId: 'ae1460' } },
|
|
satellites: { enabled: true, params: { catalog: 'dense', selectedSatTrackingId: 25544 } },
|
|
});
|
|
|
|
const pushed = Object.fromEntries(dataManager.setParamsCalls.map((call) => [call.id, call.params]));
|
|
assert.deepEqual(pushed.flights, { models3d: true });
|
|
assert.deepEqual(pushed.satellites, { catalog: 'dense' });
|
|
// Nothing survived military's params, so nothing is pushed at all.
|
|
assert.equal(Object.hasOwn(pushed, 'military'), false);
|
|
for (const call of dataManager.setParamsCalls) {
|
|
for (const key of SCENE_TRACKING_PARAM_KEYS) {
|
|
assert.equal(Object.hasOwn(call.params, key), false, `${call.id} leaked ${key}`);
|
|
}
|
|
}
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a dirty Space Missions state is exited before a recipe applies its layers', async () => {
|
|
// Space Missions refuses every enable outside its own replay bundle. The old
|
|
// full-registry walk dismantled it by accident; the sparse policy never does,
|
|
// so all four Flights Radar enables were refused and reported as success.
|
|
const style = { contextMode: 'space-missions' };
|
|
const holder = {};
|
|
const data = {
|
|
refuse: (id, on) => on
|
|
&& holder.styleManager?.contextMode === 'space-missions'
|
|
&& !SPACE_MISSIONS_ALLOWED.has(id),
|
|
};
|
|
const { director, styleManager, dataManager, restore } = makeDirector({ style, data });
|
|
holder.styleManager = styleManager;
|
|
try {
|
|
const result = await director._applyLayerStates(recipeLayers('flights-radar'));
|
|
|
|
assert.deepEqual(styleManager.contextExits, ['off']);
|
|
assert.equal(styleManager.contextMode, null);
|
|
assert.deepEqual(result.refused, []);
|
|
assert.ok(result.applied.includes('flights'));
|
|
assert.deepEqual(
|
|
dataManager.setEnabledCalls.filter((call) => call.enabled).map((call) => call.id),
|
|
['flights'],
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('Orbital Watch does not compose over a Space Missions replay', async () => {
|
|
// Orbital Watch declares satellites, which the guard permits — so nothing is
|
|
// refused and a refusal-only check would pass while rocket-launches stayed
|
|
// on screen. Playback leaves an isolating mode whether or not it refuses.
|
|
const { director, styleManager, dataManager, restore } = makeDirector({
|
|
style: { contextMode: 'space-missions' },
|
|
});
|
|
try {
|
|
await director._applyLayerStates(recipeLayers('orbital-watch'));
|
|
assert.deepEqual(styleManager.contextExits, ['off']);
|
|
assert.equal(
|
|
dataManager.setEnabledCalls.some((call) => call.id === 'rocket-launches'),
|
|
false,
|
|
'the recipe never declares rocket-launches; exiting the mode is what clears it',
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a non-isolating context mode is left alone', async () => {
|
|
for (const contextMode of [null, 'flights']) {
|
|
const { director, styleManager, restore } = makeDirector({ style: { contextMode } });
|
|
try {
|
|
await director._applyLayerStates({ flights: { enabled: true } });
|
|
assert.deepEqual(styleManager.contextExits, [], `${contextMode} must not be exited`);
|
|
} finally {
|
|
restore();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a refused layer is reported, never counted as applied', async () => {
|
|
const { director, dataManager, restore } = makeDirector({
|
|
data: { refuse: (id) => id === 'flights' },
|
|
});
|
|
try {
|
|
const result = await director._applyLayerStates({
|
|
flights: { enabled: true, params: { models3d: true } },
|
|
traffic: { enabled: false },
|
|
});
|
|
|
|
assert.deepEqual(result.refused, ['flights']);
|
|
assert.deepEqual(result.applied, ['traffic']);
|
|
// Params must not be pushed at a layer whose transition was vetoed.
|
|
assert.deepEqual(dataManager.setParamsCalls, []);
|
|
const refusals = director._activeRun.events.filter((event) => event.type === 'shot_layers_refused');
|
|
assert.equal(refusals.length, 1);
|
|
assert.deepEqual(refusals[0].payload, { layerIds: ['flights'] });
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('cancellation between two layers ends the reconcile where it stands', async () => {
|
|
const { director, dataManager, restore } = makeDirector();
|
|
const token = { cancelled: false };
|
|
const inner = dataManager.setEnabled.bind(dataManager);
|
|
dataManager.setEnabled = async (id, on) => {
|
|
const settled = await inner(id, on);
|
|
if (id === 'flights') token.cancelled = true;
|
|
return settled;
|
|
};
|
|
try {
|
|
const result = await director._applyLayerStates({
|
|
flights: { enabled: true },
|
|
satellites: { enabled: true },
|
|
traffic: { enabled: true },
|
|
}, token);
|
|
|
|
assert.deepEqual(dataManager.setEnabledCalls.map((call) => call.id), ['flights']);
|
|
assert.equal(result.cancelled, true);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('STOP during a suspended visual transition lands no layer changes', async () => {
|
|
// Repro shape from review: applyVisualState suspends (a map-stack switch),
|
|
// STOP arrives, the visual resolves — and the shot's layer pass still ran.
|
|
const { director, viewer, styleManager, dataManager, restore } = makeDirector();
|
|
let releaseVisual;
|
|
styleManager.applyVisualState = (visual) => {
|
|
styleManager.visualStates.push(visual);
|
|
return new Promise((resolve) => { releaseVisual = resolve; });
|
|
};
|
|
try {
|
|
const run = director.startScene('scene-1', { single: true });
|
|
await settle();
|
|
assert.equal(styleManager.visualStates.length, 1, 'the run must be parked on the visual await');
|
|
|
|
director.stopScene('Stopped (Esc)');
|
|
releaseVisual();
|
|
await run;
|
|
|
|
assert.deepEqual(dataManager.setEnabledCalls, []);
|
|
assert.deepEqual(viewer.flights, []);
|
|
assert.equal(director.running, false);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('STOP between two layers lands no further layer changes', async () => {
|
|
const { director, dataManager, restore } = makeDirector({
|
|
project: {
|
|
version: 3,
|
|
scenes: [{
|
|
id: 'scene-1',
|
|
title: 'Fixture Scene',
|
|
shots: [{
|
|
id: 'shot-a',
|
|
title: 'Shot A',
|
|
durationSec: 0.2,
|
|
holdSec: 0,
|
|
camera: { lat: 10, lon: 20, alt: 500000, heading: 0, pitch: -40, roll: 0 },
|
|
visual: { style: 'normal' },
|
|
layers: { flights: { enabled: true }, satellites: { enabled: true }, traffic: { enabled: true } },
|
|
}],
|
|
}],
|
|
},
|
|
});
|
|
const inner = dataManager.setEnabled.bind(dataManager);
|
|
dataManager.setEnabled = async (id, on) => {
|
|
const settled = await inner(id, on);
|
|
if (id === 'flights') director.stopScene('Stopped (Esc)');
|
|
return settled;
|
|
};
|
|
try {
|
|
await director.startScene('scene-1', { single: true });
|
|
assert.deepEqual(dataManager.setEnabledCalls.map((call) => call.id), ['flights']);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('STOP aborts the layer transition in flight, not merely the next one', async () => {
|
|
// Checking the token AFTER the await is a backstop, not the fix: by then an
|
|
// un-aborted transition has already committed, and the pass returns without
|
|
// its params — the layer left enabled carrying stale ones. The signal is
|
|
// what actually stops the layer that is currently moving.
|
|
const { director, dataManager, restore } = makeDirector({
|
|
project: {
|
|
version: 3,
|
|
scenes: [{
|
|
id: 'scene-1',
|
|
title: 'Fixture Scene',
|
|
shots: [{
|
|
id: 'shot-a',
|
|
title: 'Shot A',
|
|
durationSec: 0.2,
|
|
holdSec: 0,
|
|
camera: { lat: 10, lon: 20, alt: 500000, heading: 0, pitch: -40, roll: 0 },
|
|
visual: { style: 'normal' },
|
|
layers: {
|
|
flights: { enabled: true, params: { models3d: true } },
|
|
satellites: { enabled: true },
|
|
},
|
|
}],
|
|
}],
|
|
},
|
|
});
|
|
const inner = dataManager.setEnabled.bind(dataManager);
|
|
dataManager.setEnabled = (id, on, options) => {
|
|
// STOP lands while the transition is in flight, before it can commit.
|
|
if (id === 'flights') director.stopScene('Stopped (Esc)');
|
|
return inner(id, on, options);
|
|
};
|
|
try {
|
|
await director.startScene('scene-1', { single: true });
|
|
|
|
assert.equal(dataManager.setEnabledCalls.length, 1, 'only the in-flight layer is touched');
|
|
assert.ok(
|
|
dataManager.setEnabledCalls[0].signal instanceof AbortSignal,
|
|
'the run must hand its abort signal to every transition',
|
|
);
|
|
assert.equal(dataManager.setEnabledCalls[0].signal.aborted, true, 'STOP must abort that signal');
|
|
assert.deepEqual(dataManager.committed, [], 'an aborted transition must not commit');
|
|
assert.deepEqual(dataManager.setParamsCalls, [], 'no params for a layer that never moved');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a newer LOAD aborts the previous LOAD transition rather than disowning it', async () => {
|
|
const { director, styleManager, dataManager, restore } = makeDirector();
|
|
const gates = [];
|
|
styleManager.applyVisualState = (visual, options = {}) => {
|
|
styleManager.visualCalls.push({ visual, isCurrent: options.isCurrent });
|
|
return new Promise((resolve) => { gates.push(resolve); });
|
|
};
|
|
try {
|
|
const first = director.loadShot('scene-1', 'shot-a');
|
|
await settle();
|
|
const second = director.loadShot('scene-1', 'shot-b');
|
|
gates[0]();
|
|
gates[1]();
|
|
await Promise.all([first, second]);
|
|
|
|
// Only the newer LOAD reconciled, and it carried a live (unaborted) signal.
|
|
assert.deepEqual(dataManager.setEnabledCalls.map((call) => call.id), ['traffic']);
|
|
assert.ok(dataManager.setEnabledCalls[0].signal instanceof AbortSignal);
|
|
assert.equal(dataManager.setEnabledCalls[0].signal.aborted, false);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('applyVisualState gates the map-stack switch on both sides of its await', () => {
|
|
// The other half of the contract, and the half these doubles cannot see.
|
|
// ui.js cannot be imported here (its mgrs dependency is CJS), so this pins
|
|
// the structure the way the repo pins other cross-module shape
|
|
// (cockpitMarkup.test.mjs), while qa-shots/scenes-audit.mjs proves the
|
|
// BEHAVIOUR against the real StyleManager in a browser.
|
|
//
|
|
// Gating only the post-await uniform commit is not enough: the stack switch
|
|
// is ITSELF a mutation. The controller invalidates a switch only when
|
|
// another setStack() arrives, and a winning state that omits `mapStack`
|
|
// never issues one — every normalized scene shot omits it — so a stale
|
|
// switch would otherwise stand on the globe.
|
|
const source = readShellSource();
|
|
const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/);
|
|
assert.ok(method, 'applyVisualState is missing from the UI shell source');
|
|
assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/);
|
|
|
|
const mapStackBlock = method[0].match(/if \(state\.mapStack\) \{[\s\S]*?\n {4}\}/);
|
|
assert.ok(mapStackBlock, 'the map-stack block is missing from applyVisualState');
|
|
const block = mapStackBlock[0];
|
|
|
|
// Before: an already-superseded caller must not start the switch at all.
|
|
assert.match(
|
|
block,
|
|
/if \(superseded\(\)\) return false;\s*const stackBefore =/,
|
|
'the switch must be skipped outright when the caller is already superseded',
|
|
);
|
|
// After: supersession that landed DURING the switch must put the globe back.
|
|
assert.match(
|
|
block,
|
|
/await this\._setMapStack\(state\.mapStack[\s\S]*?if \(superseded\(\)\) \{[\s\S]*?await this\._setMapStack\(stackBefore/,
|
|
'a switch superseded mid-flight must be reverted to the stack the winner inherited',
|
|
);
|
|
// And only what is still ours — a newer switch owns the globe, never revert it.
|
|
assert.match(block, /getSwitchGeneration/, 'the revert must consult the switch generation');
|
|
assert.match(
|
|
block,
|
|
/if \(globeIsStillOurs && stackBefore && landed !== stackBefore\) \{\s*await this\._setMapStack\(stackBefore/,
|
|
'the revert must be guarded by the generation check and the stack that actually landed',
|
|
);
|
|
});
|
|
|
|
test('a superseded LOAD is refused its visual commit', async () => {
|
|
// applyVisualState suspends on a map-stack switch and writes its shader
|
|
// uniforms AFTER that await. A stale LOAD resuming there would commit the
|
|
// look of a shot the operator has already moved past, so the director hands
|
|
// it a liveness predicate that is false by the time it would commit.
|
|
const { director, styleManager, restore } = makeDirector();
|
|
const gates = [];
|
|
styleManager.applyVisualState = (visual, options = {}) => {
|
|
styleManager.visualCalls.push({ visual, isCurrent: options.isCurrent });
|
|
return new Promise((resolve) => { gates.push(resolve); });
|
|
};
|
|
try {
|
|
const first = director.loadShot('scene-1', 'shot-a');
|
|
const second = director.loadShot('scene-1', 'shot-b');
|
|
gates[1]();
|
|
await settle();
|
|
gates[0]();
|
|
await Promise.all([first, second]);
|
|
|
|
const [stale, live] = styleManager.visualCalls;
|
|
assert.equal(typeof stale.isCurrent, 'function', 'the visual call must carry a liveness predicate');
|
|
assert.equal(stale.isCurrent(), false, 'the superseded LOAD must be refused its commit');
|
|
assert.equal(live.isCurrent(), true, 'the live LOAD must still be allowed to commit');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a run refuses the visual commit of a shot cancelled mid-transition', async () => {
|
|
const { director, styleManager, restore } = makeDirector();
|
|
let releaseVisual;
|
|
styleManager.applyVisualState = (visual, options = {}) => {
|
|
styleManager.visualCalls.push({ visual, isCurrent: options.isCurrent });
|
|
return new Promise((resolve) => { releaseVisual = resolve; });
|
|
};
|
|
const run = director.startScene('scene-1', { single: true });
|
|
try {
|
|
await settle();
|
|
const call = styleManager.visualCalls[0];
|
|
assert.equal(typeof call.isCurrent, 'function', 'the visual call must carry a liveness predicate');
|
|
assert.equal(call.isCurrent(), true, 'live while the run owns the shot');
|
|
|
|
director.stopScene('Stopped (Esc)');
|
|
assert.equal(call.isCurrent(), false, 'STOP must revoke the pending commit');
|
|
} finally {
|
|
// Always let the run finish: _finishRun() owns the progress interval, so a
|
|
// failed assertion that skipped this would leave a live timer behind and
|
|
// hang the suite instead of reporting.
|
|
director.stopScene('cleanup');
|
|
releaseVisual?.();
|
|
await run;
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('the newest LOAD wins when two loads race', async () => {
|
|
// Both loads suspend on their visual await; the OLDER one resolves second.
|
|
// Without a generation it completes last and overwrites the newer intent.
|
|
const { director, viewer, styleManager, dataManager, restore } = makeDirector();
|
|
const gates = [];
|
|
styleManager.applyVisualState = (visual) => {
|
|
styleManager.visualStates.push(visual);
|
|
return new Promise((resolve) => { gates.push(resolve); });
|
|
};
|
|
try {
|
|
const first = director.loadShot('scene-1', 'shot-a');
|
|
const second = director.loadShot('scene-1', 'shot-b');
|
|
assert.equal(gates.length, 2);
|
|
|
|
gates[1]();
|
|
await settle();
|
|
gates[0]();
|
|
await Promise.all([first, second]);
|
|
|
|
assert.deepEqual(
|
|
dataManager.setEnabledCalls.map(({ id, enabled }) => ({ id, enabled })),
|
|
[{ id: 'traffic', enabled: true }],
|
|
);
|
|
assert.equal(viewer.flights.length, 1);
|
|
assert.equal(director._selectedShotId, 'shot-b');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a scene run supersedes a LOAD still suspended on its visual await', async () => {
|
|
const { director, styleManager, dataManager, restore } = makeDirector();
|
|
let releaseLoadVisual;
|
|
let calls = 0;
|
|
styleManager.applyVisualState = (visual) => {
|
|
styleManager.visualStates.push(visual);
|
|
if (++calls === 1) return new Promise((resolve) => { releaseLoadVisual = resolve; });
|
|
return Promise.resolve();
|
|
};
|
|
try {
|
|
const load = director.loadShot('scene-1', 'shot-a');
|
|
await settle();
|
|
const run = director.startScene('scene-1', { single: true });
|
|
releaseLoadVisual();
|
|
await Promise.all([load, run]);
|
|
|
|
// Only the run's own shots reconciled; the stale LOAD's flights never did.
|
|
assert.deepEqual(
|
|
dataManager.setEnabledCalls.map((call) => call.id),
|
|
['flights', 'traffic'],
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('destroy drains cancelled LOAD work before its viewer can be discarded', async (t) => {
|
|
const restore = installSceneRuntime();
|
|
t.after(restore);
|
|
let finishVisual;
|
|
const dataManager = fakeDataManager();
|
|
const viewer = { camera: { cancelFlight() {} } };
|
|
const director = new SceneDirector(viewer, {
|
|
applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }),
|
|
}, dataManager);
|
|
const loading = director.loadShot('scene-1', 'shot-a');
|
|
let destroyed = false;
|
|
const stopping = director.destroy().then(() => { destroyed = true; });
|
|
await Promise.resolve();
|
|
assert.equal(destroyed, false);
|
|
finishVisual();
|
|
await Promise.all([loading, stopping]);
|
|
assert.equal(destroyed, true);
|
|
assert.equal(dataManager.setEnabledCalls.length, 0);
|
|
assert.equal((await director.startScene('scene-1')).reason, 'destroyed');
|
|
await director.destroy();
|
|
});
|
|
|
|
test('Scene snapshots are immutable and editing outcomes retain the affected shot and index', async () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
director._activeRun = null;
|
|
const seen = [];
|
|
const unsubscribe = director.subscribe((notification) => seen.push(notification));
|
|
assert.equal(seen[0].initial, true);
|
|
assert.equal(seen[0].state.selectedShotId, 'shot-a');
|
|
assert.ok(Object.isFrozen(seen[0].state));
|
|
director.captureShot();
|
|
const captured = seen.find(({ change }) => change?.type === 'shot-captured');
|
|
assert.equal(captured.change.index, 2);
|
|
assert.ok(Object.isFrozen(captured.change.shot.camera));
|
|
const id = captured.change.shot.id;
|
|
director.deleteShot('scene-1', id);
|
|
const deleted = seen.find(({ change }) => change?.type === 'shot-deleted');
|
|
assert.equal(deleted.change.index, 2);
|
|
assert.equal(deleted.change.shot.id, id);
|
|
assert.equal(captured.state.selectedShotId, id);
|
|
unsubscribe();
|
|
const count = seen.length;
|
|
director.captureShot();
|
|
assert.equal(seen.length, count);
|
|
await director.destroy();
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('Scene load outcomes exclude superseded and disposed completions', async () => {
|
|
const { director, styleManager, restore } = makeDirector();
|
|
try {
|
|
director._activeRun = null;
|
|
const seen = [];
|
|
director.subscribe(({ change }) => { if (change) seen.push(change); });
|
|
const pending = [];
|
|
styleManager.applyVisualState = () => new Promise((resolve) => pending.push(resolve));
|
|
const old = director.loadShot('scene-1', 'shot-a');
|
|
const current = director.loadShot('scene-1', 'shot-b');
|
|
pending[1]();
|
|
await current;
|
|
pending[0]();
|
|
await old;
|
|
assert.deepEqual(seen.filter((change) => change.type === 'shot-loaded').map((change) => change.shot.id), ['shot-b']);
|
|
const late = director.loadShot('scene-1', 'shot-a');
|
|
const disposal = director.destroy();
|
|
const count = seen.length;
|
|
pending[2]();
|
|
await Promise.all([late, disposal]);
|
|
director.subscribe(() => assert.fail('disposed director must not notify'));
|
|
assert.equal(seen.length, count);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('invalid and unsupported imports retain the current project, selection and saved bytes', async () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
const project = director._project;
|
|
const selected = director._selectedShotId;
|
|
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
|
|
for (const value of ['{}', '{"version":99,"scenes":[]}', '{"scenes":[{"shots":"bad"}]}']) {
|
|
await director.importProjectFile({ name: 'bad.json', text: async () => value });
|
|
assert.equal(director._project, project);
|
|
assert.equal(director._selectedShotId, selected);
|
|
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
|
assert.match(director._presentation.status, /Import failed: \$/);
|
|
}
|
|
let read = false;
|
|
await director.importProjectFile({ size: 6 * 1024 * 1024, text: async () => { read = true; } });
|
|
assert.equal(read, false);
|
|
assert.equal(director._project, project);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('newer imports win delayed file reads, and an empty project is preserved', async () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
let release;
|
|
const older = director.importProjectFile({ name: 'old.json', text: () => new Promise((resolve) => { release = resolve; }) });
|
|
await director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
|
|
release(JSON.stringify(PROJECT_FIXTURE));
|
|
await older;
|
|
assert.deepEqual(director._project.scenes, []);
|
|
assert.equal(director._selectedSceneId, null);
|
|
assert.deepEqual(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes, []);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('unsupported stored documents cannot be overwritten by fallback edits', async () => {
|
|
const { director, restore } = makeDirector({ project: { version: 99, scenes: [] } });
|
|
try {
|
|
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
|
|
assert.ok(director._storageReadError);
|
|
director._project.scenes[0].title = 'Fallback edit';
|
|
director._saveProject();
|
|
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
|
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
|
assert.equal(director._storageReadError, null);
|
|
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 6);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('valid import settles a cancelled load before replacing the project', async () => {
|
|
const { director, styleManager, restore } = makeDirector();
|
|
let release;
|
|
styleManager.applyVisualState = () => new Promise((resolve) => { release = resolve; });
|
|
try {
|
|
const before = director._project;
|
|
const load = director.loadShot('scene-1', 'shot-a');
|
|
await settle();
|
|
const importing = director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
|
|
await settle();
|
|
assert.equal(director._project, before, 'old work still owns cleanup');
|
|
release(true);
|
|
await Promise.all([load, importing]);
|
|
assert.deepEqual(director._project.scenes, []);
|
|
assert.equal(director._pendingWork.size, 0);
|
|
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('a delayed import cannot publish after disposal', async () => {
|
|
const { director, restore } = makeDirector();
|
|
let release;
|
|
try {
|
|
const before = director._project;
|
|
const reading = director.importProjectFile({ name: 'empty.json', text: () => new Promise((resolve) => { release = resolve; }) });
|
|
await director.destroy();
|
|
release('{"version":3,"scenes":[]}');
|
|
await reading;
|
|
assert.equal(director._project, before);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('invalid authored edits cannot persist an unreadable project over a good save', () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
const before = localStorage.getItem('godsEyeView.sceneProject.v2');
|
|
let notice;
|
|
director._toastStorageError = (message) => { notice = message; };
|
|
director._project.scenes[0].shots[0].camera.lat = 91;
|
|
director._saveProject();
|
|
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before);
|
|
assert.match(notice, /camera.lat/);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('zero camera pitch is preserved by both immediate placement and ordinary flight', async () => {
|
|
const { director, viewer, restore } = makeDirector();
|
|
try {
|
|
let placed;
|
|
viewer.camera.setView = (options) => { placed = options; };
|
|
const pose = { lat: 10, lon: 20, alt: 500, heading: 0, pitch: 0, roll: 0 };
|
|
director._setCameraView(pose);
|
|
assert.equal(placed.orientation.pitch, 0);
|
|
await director._flyCamera(pose, 0.2, { cancelled: false });
|
|
assert.equal(viewer.flights.at(-1).orientation.pitch, 0);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('camera refusal starts no authored frame or playback clock', async () => {
|
|
const { director, styleManager, viewer, restore } = makeDirector();
|
|
try {
|
|
const shot = director._project.scenes[0].shots[0];
|
|
shot.move = { from: { ...shot.camera, altitudeReference: 'ellipsoid' }, easing: 'linear' };
|
|
styleManager.runImmediateNavigation = () => false;
|
|
assert.equal((await director.startScene('scene-1', { single: true })).reason, 'camera-unavailable');
|
|
assert.equal(director._cameraMotion.active, false);
|
|
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
|
|
assert.deepEqual(viewer.flights, []);
|
|
} finally { restore(); }
|
|
});
|
|
|
|
test('an unreadable saved project does not overwrite its recovery checkpoint (#971)', async () => {
|
|
const unreadable = '{"version":99,"scenes":[]}';
|
|
const warn = console.warn;
|
|
console.warn = () => {};
|
|
const { director, storage, restore } = makeDirector({ storage: {
|
|
'godsEyeView.sceneProject.v2': unreadable,
|
|
'godsEyeView.sceneProject.checkpoint.v1': 'EARLIER-CHECKPOINT',
|
|
} });
|
|
try {
|
|
assert.equal(storage.stored.get('godsEyeView.sceneProject.v2'), unreadable);
|
|
assert.equal(storage.stored.get('godsEyeView.sceneProject.checkpoint.v1'), 'EARLIER-CHECKPOINT');
|
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('a capture, update or import that cannot be saved keeps the "Scene not saved" status (#971)', async () => {
|
|
const warn = console.warn;
|
|
console.warn = () => {};
|
|
const { director, storage, restore } = makeDirector();
|
|
let status = '';
|
|
director.subscribe(({ state }) => { status = state.status; });
|
|
try {
|
|
storage.full = true;
|
|
director._selectedSceneId = 'scene-1';
|
|
director._selectedShotId = 'shot-a';
|
|
director.captureShot();
|
|
assert.match(status, /^Scene not saved/, 'failed capture keeps the save failure');
|
|
director.updateSelectedShot();
|
|
assert.match(status, /^Scene not saved/, 'failed update keeps the save failure');
|
|
assert.equal(await director.importProjectFile(
|
|
{ name: 'fixture.json' },
|
|
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
|
|
), true);
|
|
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
|
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('startScene resolves its result, resumes after a shot without a scene id, and counts distinct scenes (#971)', async () => {
|
|
const { director, restore } = makeDirector();
|
|
try {
|
|
const options = { single: true, preview: false };
|
|
assert.deepEqual(await director.startScene('scene-1', options), { started: true, shots: 2 });
|
|
assert.equal(director._lastRun.scenesRun, 1, 'one scene with two shots is one scene run');
|
|
assert.deepEqual(
|
|
await director.startScene(undefined, { ...options, afterShotId: 'shot-a' }),
|
|
{ started: true, shots: 1 },
|
|
);
|
|
} finally { await director.destroy(); restore(); }
|
|
});
|
|
|
|
test('a caught run failure resolves as run-failed, not success (#971)', async () => {
|
|
const { director, styleManager, restore } = makeDirector();
|
|
const warn = console.warn;
|
|
console.warn = () => {};
|
|
styleManager.applyVisualState = async () => { throw new Error('visual failed'); };
|
|
try {
|
|
assert.deepEqual(
|
|
await director.startScene('scene-1', { single: true, preview: false }),
|
|
{ started: false, reason: 'run-failed' },
|
|
);
|
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
|
});
|