fix(director): keep scene results and save status accurate (#971)

- Guard against legacy pack bootstrap during unreadable storage recovery
- Report failed saves honestly across captures, updates, and imports
- Resolve default scene id correctly when advancing past a specific shot
- Count unique scenes executed rather than queued shots in run telemetry
- Return run failure status when playback throws instead of false success
- Pin five regression scenarios in src/scenes/director971.test.mjs
This commit is contained in:
djabhi31 committed 2026-10-14 22:29:59 +05:30
1 parent 3ec31e599e
commit 2b6e45e3ef
3 files changed
+247 -29

No files matched your search

+36 -29
View File
@@ -183,20 +183,22 @@ export class SceneDirector {
...this._presentation,
hasRun: !!this._lastRunJson,
}));
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
if (!this._storageReadError) {
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
}
}
}
}
@@ -316,13 +318,14 @@ export class SceneDirector {
this._toastStorageError(
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
);
return;
return false;
}
this._project.updatedAt = new Date().toISOString();
try {
const payload = JSON.stringify(this._project);
parseSceneDocument(payload);
localStorage.setItem(STORAGE_KEY, payload);
return true;
} catch (e) {
// Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed —
@@ -336,6 +339,7 @@ export class SceneDirector {
? `Scene not saved — ${e.message}`
: undefined,
);
return false;
}
}
@@ -889,12 +893,12 @@ export class SceneDirector {
}
this._selectedSceneId = scene.id;
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
this._saveProject();
const saved = this._saveProject();
if (render) {
this._renderSceneSelect();
this._renderShotList();
}
if (announce) {
if (announce && saved) {
this._updateStatus(
marker
? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
@@ -939,9 +943,9 @@ export class SceneDirector {
scene.shots.push(shot);
this._selectedShotId = shot.id;
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(`Captured: ${scene.title} / ${shot.title}`);
if (saved) this._updateStatus(`Captured: ${scene.title} / ${shot.title}`);
}
/**
@@ -967,9 +971,9 @@ export class SceneDirector {
shot.visual = this.styleManager.getVisualState();
shot.layers = this._captureLayerStates();
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(`Updated: ${scene.title} / ${shot.title}`);
if (saved) this._updateStatus(`Updated: ${scene.title} / ${shot.title}`);
}
/**
@@ -1686,13 +1690,13 @@ export class SceneDirector {
) {
if (this._running) return { started: false, reason: 'already-running' };
let queue = this._buildPlaybackQueue(
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
{ single },
);
const startSceneId =
sceneId || this._selectedSceneId || this._project.scenes[0]?.id;
let queue = this._buildPlaybackQueue(startSceneId, { single });
if (afterShotId !== null) {
const index = queue.findIndex(
({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
({ scene, shot }) =>
scene.id === startSceneId && shot.id === afterShotId,
);
if (index < 0) return { started: false, reason: 'shot-not-found' };
queue = queue.slice(index + 1);
@@ -1764,7 +1768,7 @@ export class SceneDirector {
title: 'Editable Scene Run',
startedAt: new Date().toISOString(),
estimatedDurationSec,
scenesRun: queue.length,
scenesRun: new Set(queue.map(({ scene }) => scene.id)).size,
events: [],
};
@@ -1772,6 +1776,7 @@ export class SceneDirector {
this._logEvent('scene_run_start', { count: queue.length });
this._setPlaybackKeyboardEnabled(true);
let runResult = { started: true, shots: queue.length };
try {
await playSceneQueue(queue, {
token,
@@ -1786,9 +1791,11 @@ export class SceneDirector {
this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
runResult = { started: false, reason: 'run-failed' };
} finally {
this._finishRun();
}
return runResult;
}
/**
@@ -2014,9 +2021,9 @@ export class SceneDirector {
this._selectedShotId =
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
this._loadedSceneId = null;
this._saveProject();
const saved = this._saveProject();
this._publish({ type: 'project-imported', project });
this._updateStatus(`Imported ${file.name}`);
if (saved) this._updateStatus(`Imported ${file.name}`);
return true;
} catch (error) {
if (
+210
View File
@@ -0,0 +1,210 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneDirector } from './director.js';
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
const CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1';
const pose = {
lat: 10,
lon: 20,
alt: 500000,
heading: 0,
pitch: -40,
roll: 0,
};
const shot = (id) => ({
id,
title: id,
durationSec: 0.2,
holdSec: 0,
camera: pose,
visual: { style: 'normal' },
layers: {},
});
const project = JSON.stringify({
version: 3,
scenes: [{ id: 's', title: 'S', shots: [shot('a'), shot('b')] }],
});
function classList() {
return {
add() {},
remove() {},
toggle() {},
contains: () => false,
};
}
function installDocument() {
globalThis.document = {
getElementById: () => null,
createElement: () => ({
classList: classList(),
style: {},
dataset: {},
appendChild() {},
remove() {},
click() {},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
}),
addEventListener() {},
removeEventListener() {},
body: { classList: classList(), appendChild() {} },
};
}
function make(initial, { styleOverrides = {}, dataOverrides = {} } = {}) {
const stored = new Map(Object.entries(initial));
const storage = {
stored,
full: false,
getItem: (key) => stored.get(key) ?? null,
setItem(key, value) {
if (this.full)
throw new DOMException('quota exceeded', 'QuotaExceededError');
stored.set(key, String(value));
},
removeItem: (key) => stored.delete(key),
};
globalThis.localStorage = storage;
const viewer = {
camera: {
flyTo: (options) => Promise.resolve().then(() => options.complete?.()),
cancelFlight() {},
},
};
const style = {
runImmediateNavigation: (_name, run) => run(),
getCameraState: () => pose,
getVisualState: () => ({ style: 'normal' }),
setRecordingMode() {},
applyVisualState: async () => true,
...styleOverrides,
};
const data = {
getAll: () => [],
getLayerParams: () => null,
setEnabled: async () => true,
setLayerParams: () => true,
...dataOverrides,
};
return { director: new SceneDirector(viewer, style, data), storage };
}
function observeStatus(director) {
let status = '';
director.subscribe(({ state }) => {
status = state.status;
});
return () => status;
}
test('SceneDirector keeps recovery data and failed-save status accurate (#971)', async (t) => {
const priorDocument = globalThis.document;
const priorStorage = globalThis.localStorage;
const priorWarn = console.warn;
console.warn = () => {};
installDocument();
t.after(() => {
console.warn = priorWarn;
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
if (priorStorage === undefined) delete globalThis.localStorage;
else globalThis.localStorage = priorStorage;
});
let made = make({
[STORAGE_KEY]: '{"version":99,"scenes":[]}',
[CHECKPOINT_KEY]: 'EARLIER-CHECKPOINT',
});
assert.equal(
made.storage.stored.get(STORAGE_KEY),
'{"version":99,"scenes":[]}',
);
assert.equal(made.storage.stored.get(CHECKPOINT_KEY), 'EARLIER-CHECKPOINT');
await made.director.destroy();
made = make({ [STORAGE_KEY]: project });
const status = observeStatus(made.director);
made.storage.full = true;
made.director.captureShot();
assert.match(status(), /^Scene not saved/);
made.director.updateSelectedShot();
assert.match(status(), /^Scene not saved/);
assert.equal(
await made.director.importProjectFile(
{ name: 'fixture.json' },
{ prepared: { project: JSON.parse(project), assets: [] } },
),
true,
);
assert.match(status(), /^Scene not saved/);
await made.director.destroy();
});
test('SceneDirector returns results, resolves implicit ids, and counts scenes', async (t) => {
const priorDocument = globalThis.document;
const priorStorage = globalThis.localStorage;
installDocument();
t.after(() => {
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
if (priorStorage === undefined) delete globalThis.localStorage;
else globalThis.localStorage = priorStorage;
});
const made = make({ [STORAGE_KEY]: project });
const options = { single: true, preview: false };
assert.deepEqual(await made.director.startScene('s', options), {
started: true,
shots: 2,
});
assert.equal(made.director._lastRun.scenesRun, 1);
assert.deepEqual(
await made.director.startScene(undefined, {
...options,
afterShotId: 'a',
}),
{ started: true, shots: 1 },
);
await made.director.destroy();
});
test('SceneDirector does not report caught failures as success', async (t) => {
const priorDocument = globalThis.document;
const priorStorage = globalThis.localStorage;
const priorWarn = console.warn;
console.warn = () => {};
installDocument();
t.after(() => {
console.warn = priorWarn;
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
if (priorStorage === undefined) delete globalThis.localStorage;
else globalThis.localStorage = priorStorage;
});
const made = make(
{ [STORAGE_KEY]: project },
{
styleOverrides: {
applyVisualState: async () => {
throw new Error('visual failed');
},
},
},
);
assert.deepEqual(
await made.director.startScene('s', {
single: true,
preview: false,
}),
{ started: false, reason: 'run-failed' },
);
await made.director.destroy();
});