mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,625 @@
|
||||
// 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 fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneDirector } from './director.js';
|
||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
|
||||
/** 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. */
|
||||
function installSceneRuntime(project = PROJECT_FIXTURE) {
|
||||
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() {} },
|
||||
};
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify(project),
|
||||
setItem() {},
|
||||
removeItem() {},
|
||||
};
|
||||
|
||||
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 setEnabledCalls = [];
|
||||
const setParamsCalls = [];
|
||||
const committed = [];
|
||||
return {
|
||||
setEnabledCalls,
|
||||
setParamsCalls,
|
||||
committed,
|
||||
getAll: () => registered.map((id) => ({ id, enabled: !!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.
|
||||
await Promise.resolve();
|
||||
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 = [];
|
||||
let cancelled = 0;
|
||||
return {
|
||||
flights,
|
||||
get cancelledFlights() { return cancelled; },
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
Promise.resolve().then(() => options.complete?.());
|
||||
},
|
||||
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);
|
||||
const viewer = fakeViewer();
|
||||
const styleManager = fakeStyleManager(options.style);
|
||||
const dataManager = fakeDataManager(options.data);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager);
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/** 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('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 = fs.readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/);
|
||||
assert.ok(method, 'applyVisualState is missing from ui.js');
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Scene recipes optimized for short social clips.
|
||||
* Each recipe is deterministic so repeated runs produce similar footage.
|
||||
*/
|
||||
|
||||
export const SCENE_RECIPES = [
|
||||
{
|
||||
id: 'flights-radar',
|
||||
title: 'Global Flights Radar',
|
||||
durationSec: 30,
|
||||
style: 'retro',
|
||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
|
||||
layers: {
|
||||
flights: true,
|
||||
satellites: false,
|
||||
earthquakes: false,
|
||||
traffic: false,
|
||||
},
|
||||
post: {
|
||||
bloom: 62,
|
||||
sharpen: true,
|
||||
detectionMode: 'OFF',
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 20.0, lon: -30.0, alt: 19000000, heading: 25, pitch: -65, roll: 0, duration: 6, hold: 1 },
|
||||
{ lat: 46.0, lon: 2.0, alt: 8000000, heading: 40, pitch: -52, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.0, lon: 139.0, alt: 4200000, heading: 22, pitch: -46, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 37.6, lon: -122.4, alt: 1700000, heading: 8, pitch: -40, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 0.0, lon: -20.0, alt: 12000000, heading: -10, pitch: -70, roll: 0, duration: 4, hold: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'orbital-watch',
|
||||
title: 'Orbital Watch',
|
||||
durationSec: 32,
|
||||
style: 'surveillance',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
flights: false,
|
||||
satellites: true,
|
||||
earthquakes: false,
|
||||
traffic: false,
|
||||
},
|
||||
post: {
|
||||
bloom: 58,
|
||||
sharpen: false,
|
||||
detectionMode: 'SPARSE',
|
||||
styleParams: {
|
||||
surveillance: {
|
||||
gain: 0.62,
|
||||
bloom: 0.38,
|
||||
scanlineStr: 0.9,
|
||||
pixelation: 2.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 28.0, lon: -82.0, alt: 22000000, heading: 0, pitch: -82, roll: 0, duration: 7, hold: 1 },
|
||||
{ lat: 10.0, lon: 20.0, alt: 12000000, heading: 18, pitch: -74, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.7, lon: 139.7, alt: 6500000, heading: 24, pitch: -64, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 48.8, lon: 2.3, alt: 3000000, heading: 32, pitch: -58, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 30.0, lon: -25.0, alt: 15000000, heading: -20, pitch: -78, roll: 0, duration: 4, hold: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'thermal-threats',
|
||||
title: 'Thermal Threat Board',
|
||||
durationSec: 26,
|
||||
style: 'thermal',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
flights: false,
|
||||
satellites: false,
|
||||
earthquakes: true,
|
||||
traffic: false,
|
||||
},
|
||||
post: {
|
||||
bloom: 72,
|
||||
sharpen: true,
|
||||
detectionMode: 'OFF',
|
||||
styleParams: {
|
||||
thermal: {
|
||||
sensitivity: 0.84,
|
||||
bloom: 0.78,
|
||||
mode: 0.0,
|
||||
pixelation: 1.9,
|
||||
},
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 37.0, lon: -121.0, alt: 5200000, heading: 20, pitch: -62, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.7, lon: 140.0, alt: 3100000, heading: 5, pitch: -55, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: -36.8, lon: 174.7, alt: 2600000, heading: -12, pitch: -50, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 38.0, lon: -10.0, alt: 9000000, heading: 12, pitch: -70, roll: 0, duration: 4, hold: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'city-overload',
|
||||
title: 'City Overload',
|
||||
durationSec: 30,
|
||||
style: 'surveillance',
|
||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
|
||||
layers: {
|
||||
flights: true,
|
||||
satellites: true,
|
||||
earthquakes: false,
|
||||
traffic: true,
|
||||
},
|
||||
post: {
|
||||
bloom: 65,
|
||||
sharpen: true,
|
||||
detectionMode: 'PANOPTIC',
|
||||
styleParams: {
|
||||
surveillance: {
|
||||
gain: 0.68,
|
||||
bloom: 0.42,
|
||||
scanlineStr: 1.0,
|
||||
pixelation: 2.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 40.73, lon: -74.0, alt: 1500000, heading: 25, pitch: -44, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 40.73, lon: -74.0, alt: 550000, heading: 58, pitch: -36, roll: 0, duration: 4, hold: 1 },
|
||||
{ lat: 40.73, lon: -74.0, alt: 260000, heading: 102, pitch: -33, roll: 0, duration: 4, hold: 1 },
|
||||
{ lat: 34.05, lon: -118.24, alt: 1200000, heading: 45, pitch: -42, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 51.5, lon: -0.12, alt: 1200000, heading: 22, pitch: -44, roll: 0, duration: 5, hold: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'omniscience-pullback',
|
||||
title: 'Omniscience Pullback',
|
||||
durationSec: 36,
|
||||
style: 'retro',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
flights: true,
|
||||
satellites: true,
|
||||
earthquakes: true,
|
||||
traffic: true,
|
||||
},
|
||||
post: {
|
||||
bloom: 68,
|
||||
sharpen: true,
|
||||
detectionMode: 'SPARSE',
|
||||
styleParams: {
|
||||
retro: {
|
||||
pixelation: 4.4,
|
||||
distortion: 0.42,
|
||||
instability: 0.58,
|
||||
},
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 35.68, lon: 139.76, alt: 280000, heading: 30, pitch: -26, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.68, lon: 139.76, alt: 900000, heading: 26, pitch: -38, roll: 0, duration: 4, hold: 1 },
|
||||
{ lat: 35.68, lon: 139.76, alt: 3800000, heading: 18, pitch: -52, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 20.0, lon: 110.0, alt: 9500000, heading: 8, pitch: -66, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 5.0, lon: 30.0, alt: 18000000, heading: -8, pitch: -78, roll: 0, duration: 6, hold: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getSceneRecipeById(id) {
|
||||
return SCENE_RECIPES.find((recipe) => recipe.id === id) || null;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// src/scenes/scenePolicy.js — pure decisions for cinematic scene playback.
|
||||
//
|
||||
// The scene director was written when the app shipped four data layers, and its
|
||||
// shot recipes (src/scenes/recipes.js) still declare exactly those four:
|
||||
// flights, satellites, earthquakes, traffic. The registry has since grown to
|
||||
// sixteen. The original reconcile walked the LIVE registry and forced every
|
||||
// layer absent from the shot to off, so a recipe that never had an opinion
|
||||
// about CCTV, vessels, fires, radio, cables, dams or datacenters silently tore
|
||||
// them down — and nothing puts them back, because playback has no restore pass.
|
||||
//
|
||||
// A shot's layer map is an assertion about the layers it NAMES, not a claim of
|
||||
// authority over every layer that will ever exist. Recipes already spell out
|
||||
// the layers they want OFF (see 'thermal-threats', which declares
|
||||
// flights/satellites/traffic false), so honouring only the declared keys keeps
|
||||
// every authored intent intact while leaving undeclared layers alone.
|
||||
//
|
||||
// Operator-captured shots are unaffected: captureShot() snapshots the whole
|
||||
// registry (director._captureLayerStates), so those shots declare all sixteen
|
||||
// keys and still reconcile in full.
|
||||
|
||||
import { contextLayerEnableBlockReason } from '../contextModePolicy.js';
|
||||
|
||||
/**
|
||||
* Layer params that re-establish a tracked contact — and with it a SECOND
|
||||
* writer on the camera.
|
||||
*
|
||||
* A capture taken while following a plane snapshots that layer's whole param
|
||||
* set, tracking id included. Replaying it would hand the camera back to the
|
||||
* follow loop the scene just took it from (see director._claimCameraOwnership
|
||||
* and src/data/trackedCamera.js): the shot flight and the follow camera then
|
||||
* both write the frame, which is the documented jitter failure mode.
|
||||
*
|
||||
* So playback NEVER re-establishes tracking. The keys stay in the stored
|
||||
* capture — a project file is a record of what the operator saw, and a future
|
||||
* reader may want it — they are simply dropped on the way to the layer.
|
||||
*
|
||||
* @constant {ReadonlyArray<string>}
|
||||
*/
|
||||
export const SCENE_TRACKING_PARAM_KEYS = Object.freeze([
|
||||
'selectedFlightsTrackingId',
|
||||
'selectedMilitaryTrackingId',
|
||||
'selectedSatTrackingId',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Selection params that look like the ones above and are deliberately KEPT.
|
||||
*
|
||||
* `selectedCameraId` (cctv) activates a camera and raises its monitor plane.
|
||||
* It never writes viewer.trackedEntity or moves the camera — only the separate
|
||||
* `focusSelected` option does that — so it is composition the operator meant
|
||||
* to capture, not a second writer on the camera. The vessels layer exposes no
|
||||
* getParams() surface at all, so its selection cannot be captured today.
|
||||
*
|
||||
* The list exists so the decision is RECORDED rather than implied by absence:
|
||||
* scenePolicy.test.mjs sweeps every layer's getParams() for the selection
|
||||
* naming family and requires each match to appear on this list or the one
|
||||
* above. A future param named `trackedVesselMmsi` therefore cannot slip
|
||||
* through merely by not matching the older `selected…TrackingId` spelling.
|
||||
* @constant {ReadonlyArray<string>}
|
||||
*/
|
||||
export const SCENE_KEPT_SELECTION_PARAM_KEYS = Object.freeze([
|
||||
'selectedCameraId',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Names belonging to the selection/tracking family, whatever their spelling.
|
||||
* Deliberately wider than the three params that exist today — the sweep's job
|
||||
* is to force a decision about a NEW name, not to recognise the current ones.
|
||||
* @constant {RegExp}
|
||||
*/
|
||||
export const SCENE_SELECTION_PARAM_PATTERN =
|
||||
/^(?:selected|tracked)[A-Z0-9]|TrackingId$|(?:Mmsi|Norad|Icao|Callsign)$/;
|
||||
|
||||
/**
|
||||
* Drop every camera-tracking key from one shot's layer params.
|
||||
*
|
||||
* @param {Object|undefined} params Params as stored on the shot.
|
||||
* @returns {Object|undefined} Params safe to push at the layer, or undefined
|
||||
* when nothing survives (a params bag that was tracking and nothing else).
|
||||
*/
|
||||
export function stripSceneTrackingParams(params) {
|
||||
if (!params || typeof params !== 'object') return undefined;
|
||||
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key))) return params;
|
||||
|
||||
const kept = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (SCENE_TRACKING_PARAM_KEYS.includes(key)) continue;
|
||||
kept[key] = value;
|
||||
}
|
||||
return Object.keys(kept).length ? kept : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved layer id used ONLY to interrogate a Context mode's enable guard.
|
||||
*
|
||||
* The probe asks "would this mode refuse a layer it has no opinion about?" —
|
||||
* so it must name a layer that can never exist. The double-underscore form is
|
||||
* outside the kebab-case convention every real layer id follows, and
|
||||
* scenePolicy.test.mjs asserts no registered layer claims it, so the reservation
|
||||
* is enforced rather than assumed.
|
||||
* @constant {string}
|
||||
*/
|
||||
export const SCENE_EXCLUSIVITY_PROBE_LAYER_ID = '__scene-exclusivity-probe__';
|
||||
|
||||
/**
|
||||
* Whether an active Context mode must be exited before a shot's layers can be
|
||||
* applied.
|
||||
*
|
||||
* Space Missions is the shipped case: it isolates replay data, so its guard
|
||||
* (contextLayerEnableBlockReason) refuses every enable outside its own bundle.
|
||||
* A recipe declaring only flights/satellites/earthquakes/traffic would have
|
||||
* those enables refused outright — and Orbital Watch, whose satellites the
|
||||
* guard does permit, would still play over the mode's rocket-launches replay
|
||||
* it never declared. Either way the shot is not the composition it describes.
|
||||
*
|
||||
* The verdict is read off the guard itself rather than a mode name: a mode
|
||||
* that refuses an arbitrary unrelated layer is by definition isolating, so a
|
||||
* future isolating mode is covered the day its branch is added to the policy.
|
||||
*
|
||||
* @param {string|null} contextMode Active (or entering) Context mode.
|
||||
* @returns {boolean} Whether playback must leave the mode first.
|
||||
*/
|
||||
export function sceneRequiresContextModeExit(contextMode) {
|
||||
if (!contextMode) return false;
|
||||
return contextLayerEnableBlockReason({
|
||||
contextMode,
|
||||
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
|
||||
}) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered layer reconcile plan for one shot.
|
||||
*
|
||||
* Only layers the shot explicitly declares are touched. Declared layers that
|
||||
* are no longer registered (an imported or long-stored project referencing a
|
||||
* retired layer) are skipped rather than pushed at the data manager. Camera
|
||||
* tracking params are stripped here, on the way out — see
|
||||
* SCENE_TRACKING_PARAM_KEYS.
|
||||
*
|
||||
* @param {Object.<string, { enabled: boolean, params?: Object }>} targetStates
|
||||
* The shot's normalized layer map.
|
||||
* @param {Set<string>|Iterable<string>} [registeredIds] Layer ids the data
|
||||
* manager currently knows about. Omit to skip the registration filter.
|
||||
* @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>}
|
||||
*/
|
||||
export function sceneLayerPlan(targetStates, registeredIds) {
|
||||
const known = registeredIds instanceof Set
|
||||
? registeredIds
|
||||
: (registeredIds ? new Set(registeredIds) : null);
|
||||
|
||||
const plan = [];
|
||||
for (const [id, target] of Object.entries(targetStates || {})) {
|
||||
if (known && !known.has(id)) continue;
|
||||
plan.push({
|
||||
id,
|
||||
enabled: !!(target && target.enabled),
|
||||
params: target && target.params ? stripSceneTrackingParams(target.params) : undefined,
|
||||
});
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
SCENE_EXCLUSIVITY_PROBE_LAYER_ID,
|
||||
SCENE_KEPT_SELECTION_PARAM_KEYS,
|
||||
SCENE_SELECTION_PARAM_PATTERN,
|
||||
SCENE_TRACKING_PARAM_KEYS,
|
||||
sceneLayerPlan,
|
||||
sceneRequiresContextModeExit,
|
||||
stripSceneTrackingParams,
|
||||
} from './scenePolicy.js';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
|
||||
/**
|
||||
* Every key each layer publishes from getParams() — the exact surface
|
||||
* _captureLayerStates() snapshots into a shot.
|
||||
* @returns {Map<string, string[]>} layer source file → published param keys
|
||||
*/
|
||||
function sweepLayerParamKeys() {
|
||||
const byFile = new Map();
|
||||
const dataDir = new URL('../data/', import.meta.url);
|
||||
for (const entry of fs.readdirSync(dataDir)) {
|
||||
if (!entry.endsWith('.js')) continue;
|
||||
const source = fs.readFileSync(new URL(entry, dataDir), 'utf8');
|
||||
// Every getParams() in this codebase is a plain object return; take the
|
||||
// body up to its closing brace and read the keys it publishes.
|
||||
const body = source.match(/\n {2}getParams\(\)\s*\{[\s\S]*?\n {2}\},/);
|
||||
if (!body) continue;
|
||||
// A key always follows `{` or `,` — which matches both the multi-line
|
||||
// returns and the single-line `return { passive: … }` form, while a
|
||||
// ternary's `? x : y` (no brace or comma before the identifier) does not.
|
||||
const keys = [...body[0].matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*:/g)].map((match) => match[1]);
|
||||
if (keys.length) byFile.set(entry, keys);
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
|
||||
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
|
||||
const REGISTERED = new Set([
|
||||
'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',
|
||||
]);
|
||||
|
||||
test('a shot only reconciles the layers it declares', () => {
|
||||
const plan = sceneLayerPlan({ flights: { enabled: true } }, REGISTERED);
|
||||
assert.deepEqual(plan, [{ id: 'flights', enabled: true, params: undefined }]);
|
||||
});
|
||||
|
||||
test('undeclared layers are never torn down by a four-layer recipe', () => {
|
||||
// Regression: the reconcile used to walk the live registry and force every
|
||||
// absent layer off, so a recipe authored against four layers destroyed the
|
||||
// twelve added since — with no restore pass to put them back.
|
||||
const plan = sceneLayerPlan(
|
||||
{ flights: { enabled: true }, satellites: { enabled: false } },
|
||||
REGISTERED,
|
||||
);
|
||||
const touched = plan.map((entry) => entry.id);
|
||||
assert.deepEqual(touched, ['flights', 'satellites']);
|
||||
for (const untouched of ['cctv', 'radio', 'local-dams', 'local-datacenters', 'local-firms']) {
|
||||
assert.ok(!touched.includes(untouched), `${untouched} must be left alone`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an explicit false in a recipe still disables that layer', () => {
|
||||
const plan = sceneLayerPlan(
|
||||
{ earthquakes: { enabled: true }, flights: { enabled: false }, traffic: { enabled: false } },
|
||||
REGISTERED,
|
||||
);
|
||||
assert.deepEqual(
|
||||
plan.filter((entry) => !entry.enabled).map((entry) => entry.id),
|
||||
['flights', 'traffic'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an operator-captured shot declaring every layer still reconciles in full', () => {
|
||||
// captureShot() snapshots the whole registry, so full reconcile is preserved.
|
||||
const captured = Object.fromEntries(
|
||||
[...REGISTERED].map((id) => [id, { enabled: id === 'cctv' }]),
|
||||
);
|
||||
const plan = sceneLayerPlan(captured, REGISTERED);
|
||||
assert.equal(plan.length, REGISTERED.size);
|
||||
assert.deepEqual(plan.filter((entry) => entry.enabled).map((entry) => entry.id), ['cctv']);
|
||||
});
|
||||
|
||||
test('layers no longer registered are skipped, not pushed at the data manager', () => {
|
||||
const plan = sceneLayerPlan(
|
||||
{ flights: { enabled: true }, 'retired-layer': { enabled: true } },
|
||||
REGISTERED,
|
||||
);
|
||||
assert.deepEqual(plan.map((entry) => entry.id), ['flights']);
|
||||
});
|
||||
|
||||
test('per-layer params ride along only when the shot carries them', () => {
|
||||
const plan = sceneLayerPlan(
|
||||
{ satellites: { enabled: true, params: { catalog: 'dense' } }, flights: { enabled: true } },
|
||||
REGISTERED,
|
||||
);
|
||||
assert.deepEqual(plan[0], { id: 'satellites', enabled: true, params: { catalog: 'dense' } });
|
||||
assert.equal(plan[1].params, undefined);
|
||||
});
|
||||
|
||||
test('missing or malformed target maps produce an empty plan', () => {
|
||||
assert.deepEqual(sceneLayerPlan(undefined, REGISTERED), []);
|
||||
assert.deepEqual(sceneLayerPlan({}, REGISTERED), []);
|
||||
assert.deepEqual(sceneLayerPlan({ flights: null }, REGISTERED), [
|
||||
{ id: 'flights', enabled: false, params: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('every shipped recipe declares only registered layer ids', () => {
|
||||
for (const recipe of SCENE_RECIPES) {
|
||||
for (const layerId of Object.keys(recipe.layers || {})) {
|
||||
assert.ok(REGISTERED.has(layerId), `${recipe.id} declares unknown layer ${layerId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('camera-tracking params never survive into the plan', () => {
|
||||
const plan = sceneLayerPlan({
|
||||
flights: { enabled: true, params: { models3d: true, selectedFlightsTrackingId: 'a835af' } },
|
||||
military: { enabled: true, params: { selectedMilitaryTrackingId: 'ae1460' } },
|
||||
}, REGISTERED);
|
||||
assert.deepEqual(plan[0].params, { models3d: true });
|
||||
// A params bag that was tracking and nothing else leaves nothing to push.
|
||||
assert.equal(plan[1].params, undefined);
|
||||
});
|
||||
|
||||
test('stripping leaves a tracking-free params object untouched', () => {
|
||||
const params = { catalog: 'dense', showOrbits: true };
|
||||
assert.equal(stripSceneTrackingParams(params), params);
|
||||
assert.equal(stripSceneTrackingParams(undefined), undefined);
|
||||
assert.equal(stripSceneTrackingParams(null), undefined);
|
||||
assert.ok(SCENE_TRACKING_PARAM_KEYS.length >= 3);
|
||||
});
|
||||
|
||||
test('every selection-shaped layer param is classified, whatever its spelling', () => {
|
||||
// Forward-compat. The earlier sweep only recognised `selected…TrackingId`,
|
||||
// so a param named `trackedVesselMmsi` would have slipped past and a capture
|
||||
// taken while following that contact would recreate the two-camera-writer
|
||||
// bug. The family pattern is deliberately wider than today's three names:
|
||||
// any match must be explicitly stripped or explicitly kept.
|
||||
const classified = new Set([...SCENE_TRACKING_PARAM_KEYS, ...SCENE_KEPT_SELECTION_PARAM_KEYS]);
|
||||
const swept = sweepLayerParamKeys();
|
||||
assert.ok(swept.size >= 6, `expected the known layer param surfaces, saw ${swept.size}`);
|
||||
|
||||
const seen = new Set();
|
||||
for (const [file, keys] of swept) {
|
||||
for (const key of keys) {
|
||||
if (!SCENE_SELECTION_PARAM_PATTERN.test(key)) continue;
|
||||
seen.add(key);
|
||||
assert.ok(
|
||||
classified.has(key),
|
||||
`${file} publishes selection param "${key}" — strip it (SCENE_TRACKING_PARAM_KEYS) `
|
||||
+ 'or record why it is safe (SCENE_KEPT_SELECTION_PARAM_KEYS)',
|
||||
);
|
||||
}
|
||||
}
|
||||
// The documented lists must describe reality, not outlive it.
|
||||
for (const key of classified) {
|
||||
assert.ok(seen.has(key), `"${key}" is classified but no layer publishes it any more`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the family pattern catches selection names the old sweep would have missed', () => {
|
||||
for (const evader of ['trackedVesselMmsi', 'selectedVesselId', 'trackedNorad', 'primaryTargetIcao']) {
|
||||
assert.ok(SCENE_SELECTION_PARAM_PATTERN.test(evader), `${evader} must be caught`);
|
||||
}
|
||||
for (const ordinary of ['models3d', 'catalog', 'showOrbits', 'densityScale', 'coverageMode', 'calibration']) {
|
||||
assert.equal(SCENE_SELECTION_PARAM_PATTERN.test(ordinary), false, `${ordinary} is not a selection param`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the exclusivity probe id is reserved — no real layer may claim it', () => {
|
||||
// The probe asks a mode "would you refuse a layer you have no opinion
|
||||
// about?". If a real layer ever took this id, the probe would be asking
|
||||
// about a layer the mode DOES have an opinion on, and an isolating mode
|
||||
// could read as non-isolating.
|
||||
for (const entry of LAYER_STATE_REGISTRY) {
|
||||
assert.notEqual(entry.id, SCENE_EXCLUSIVITY_PROBE_LAYER_ID);
|
||||
}
|
||||
assert.equal(REGISTERED.has(SCENE_EXCLUSIVITY_PROBE_LAYER_ID), false);
|
||||
// Every real id is kebab-case; the sentinel deliberately is not.
|
||||
for (const entry of LAYER_STATE_REGISTRY) {
|
||||
assert.match(entry.id, /^[a-z][a-z0-9-]*$/, `${entry.id} breaks the layer-id convention`);
|
||||
}
|
||||
assert.doesNotMatch(SCENE_EXCLUSIVITY_PROBE_LAYER_ID, /^[a-z][a-z0-9-]*$/);
|
||||
});
|
||||
|
||||
test('an isolating context mode must be exited before a shot applies', () => {
|
||||
// Read off the shared guard, not a mode name: Space Missions refuses every
|
||||
// enable outside its replay bundle, so a shot applied inside it is not the
|
||||
// composition it describes.
|
||||
assert.equal(sceneRequiresContextModeExit('space-missions'), true);
|
||||
assert.equal(sceneRequiresContextModeExit('flights'), false);
|
||||
assert.equal(sceneRequiresContextModeExit(null), false);
|
||||
assert.equal(sceneRequiresContextModeExit(undefined), false);
|
||||
});
|
||||
|
||||
test('shipped recipes touch only their four declared layers', () => {
|
||||
for (const recipe of SCENE_RECIPES) {
|
||||
const declared = Object.entries(recipe.layers || {})
|
||||
.map(([id, enabled]) => [id, { enabled }]);
|
||||
const plan = sceneLayerPlan(Object.fromEntries(declared), REGISTERED);
|
||||
assert.equal(plan.length, Object.keys(recipe.layers || {}).length, recipe.id);
|
||||
assert.ok(plan.length <= 4, `${recipe.id} should not reach beyond its declared layers`);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user