mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat: add Nepal flood incident scene
This commit is contained in:
1 parent
76c20be277
commit
60768ccc0b
48 files changed
+15431
-161
No files matched your search
@@ -17,7 +17,550 @@ import test from 'node:test';
|
||||
|
||||
import { SceneDirector } from './director.js';
|
||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||
import { SCENE_RECIPES } from './recipes.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('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);
|
||||
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 = [
|
||||
@@ -98,6 +641,10 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
|
||||
const committed = [];
|
||||
return {
|
||||
setEnabledCalls,
|
||||
subscribeVisibilityRequests(listener) {
|
||||
this.visibilityListener = listener;
|
||||
return () => { this.visibilityListener = null; };
|
||||
},
|
||||
setParamsCalls,
|
||||
committed,
|
||||
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
||||
@@ -174,13 +721,67 @@ function makeDirector(options = {}) {
|
||||
const viewer = fakeViewer();
|
||||
const styleManager = fakeStyleManager(options.style);
|
||||
const dataManager = fakeDataManager(options.data);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager);
|
||||
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, 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);
|
||||
@@ -189,6 +790,104 @@ function recipeLayers(recipeId) {
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user