import { describe, expect, it } from "vitest"; import { createBoxBrush } from "../../src/document/brushes"; import { DEFAULT_PROJECT_NAME, SCENE_DOCUMENT_VERSION, createDefaultSceneEditorPreferences, createEmptyProjectDocument, createEmptyProjectScene, createEmptySceneDocument, createProjectDocumentFromSceneDocument } from "../../src/document/scene-document"; import { serializeSceneDocument } from "../../src/serialization/scene-document-json"; import { DEFAULT_SCENE_DRAFT_STORAGE_KEY, getBrowserStorageAccess, loadOrCreateSceneDocument, loadSceneDocumentDraft, saveSceneDocumentDraft, type KeyValueStorage } from "../../src/serialization/local-draft-storage"; import { createDefaultViewportLayoutState } from "../../src/viewport-three/viewport-layout"; class MemoryStorage implements KeyValueStorage { private readonly values = new Map(); getItem(key: string): string | null { return this.values.get(key) ?? null; } setItem(key: string, value: string): void { this.values.set(key, value); } removeItem(key: string): void { this.values.delete(key); } } class ThrowingStorage implements KeyValueStorage { constructor( private readonly options: { onGetItem?: Error; onSetItem?: Error; onRemoveItem?: Error; } = {} ) {} getItem(): string | null { if (this.options.onGetItem !== undefined) { throw this.options.onGetItem; } return null; } setItem(): void { if (this.options.onSetItem !== undefined) { throw this.options.onSetItem; } } removeItem(): void { if (this.options.onRemoveItem !== undefined) { throw this.options.onRemoveItem; } } } describe("local draft storage", () => { it("falls back to a fresh document when stored draft JSON is invalid", () => { const storage = new MemoryStorage(); storage.setItem(DEFAULT_SCENE_DRAFT_STORAGE_KEY, "{invalid-json"); const result = loadOrCreateSceneDocument(storage); expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION); expect(result.document).toEqual(createEmptyProjectDocument()); expect(result.diagnostic).toContain("Stored autosave could not be loaded."); expect(result.diagnostic).toContain( "Starting with a fresh empty document." ); }); it("reports browser storage access failures without throwing", () => { const originalDescriptor = Object.getOwnPropertyDescriptor( window, "localStorage" ); Object.defineProperty(window, "localStorage", { configurable: true, get() { throw new Error("access denied"); } }); try { const result = getBrowserStorageAccess(); expect(result.storage).toBeNull(); expect(result.diagnostic).toContain( "Browser local storage is unavailable." ); expect(result.diagnostic).toContain("access denied"); } finally { if (originalDescriptor !== undefined) { Object.defineProperty(window, "localStorage", originalDescriptor); } else { Reflect.deleteProperty(window, "localStorage"); } } }); it("returns an error result when reading from storage throws", () => { const result = loadSceneDocumentDraft( new ThrowingStorage({ onGetItem: new Error("blocked read") }) ); expect(result.status).toBe("error"); expect(result.message).toContain("blocked read"); }); it("returns an error result when saving to storage throws", () => { const result = saveSceneDocumentDraft( new ThrowingStorage({ onSetItem: new Error("quota exceeded") }), createEmptyProjectDocument() ); expect(result.status).toBe("error"); expect(result.message).toContain("quota exceeded"); }); it("stores and restores editor viewport layout state alongside the document draft", () => { const storage = new MemoryStorage(); const viewportLayoutState = createDefaultViewportLayoutState(); viewportLayoutState.layoutMode = "quad"; viewportLayoutState.activePanelId = "bottomRight"; viewportLayoutState.panels.topLeft.displayMode = "wireframe"; viewportLayoutState.panels.topLeft.cameraState.target = { x: 8, y: 3, z: -5 }; viewportLayoutState.panels.topLeft.cameraState.perspectiveOrbit.theta = 1.25; viewportLayoutState.panels.topLeft.cameraState.orthographicZoom = 2.5; expect( saveSceneDocumentDraft( storage, createProjectDocumentFromSceneDocument( createEmptySceneDocument({ name: "Viewport Draft" }) ), viewportLayoutState ) ).toEqual({ status: "saved", message: "Autosave updated." }); const result = loadSceneDocumentDraft(storage); expect(result.status).toBe("loaded"); if (result.status !== "loaded") { return; } expect(result.document.scenes[result.document.activeSceneId]?.name).toBe( "Viewport Draft" ); expect(result.viewportLayoutState).toMatchObject({ layoutMode: "quad", activePanelId: "bottomRight", panels: { topLeft: { displayMode: "wireframe", cameraState: { target: { x: 8, y: 3, z: -5 }, perspectiveOrbit: { theta: 1.25 }, orthographicZoom: 2.5 } } } }); }); it("stores and restores all project scenes in autosave drafts", () => { const storage = new MemoryStorage(); const document = { ...createEmptyProjectDocument({ name: "Castle Campaign", sceneName: "Entry" }), activeSceneId: "scene-hall", scenes: { "scene-main": createEmptyProjectScene({ id: "scene-main", name: "Entry" }), "scene-hall": createEmptyProjectScene({ id: "scene-hall", name: "Hallway", loadingScreen: { colorHex: "#253142", headline: "Crossing over", description: "Preparing the next hallway scene." } }) } }; expect(saveSceneDocumentDraft(storage, document)).toEqual({ status: "saved", message: "Autosave updated." }); const result = loadSceneDocumentDraft(storage); expect(result.status).toBe("loaded"); if (result.status !== "loaded") { return; } expect(result.document.name).toBe("Castle Campaign"); expect(result.document.activeSceneId).toBe("scene-hall"); expect(Object.keys(result.document.scenes)).toEqual([ "scene-main", "scene-hall" ]); expect(result.document.scenes["scene-hall"]?.loadingScreen).toEqual({ colorHex: "#253142", headline: "Crossing over", description: "Preparing the next hallway scene." }); }); it("loads older raw scene-document drafts without requiring viewport layout state", () => { const storage = new MemoryStorage(); storage.setItem( DEFAULT_SCENE_DRAFT_STORAGE_KEY, serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" })) ); const result = loadSceneDocumentDraft(storage); expect(result.status).toBe("loaded"); if (result.status !== "loaded") { return; } expect(result.document.name).toBe(DEFAULT_PROJECT_NAME); expect(result.document.scenes[result.document.activeSceneId]?.name).toBe( "Legacy Draft" ); expect(result.viewportLayoutState).toBeNull(); expect(result.message).toBe("Recovered latest autosave."); }); it("reports recovered autosaves through bootstrap diagnostics", () => { const storage = new MemoryStorage(); saveSceneDocumentDraft( storage, createProjectDocumentFromSceneDocument( createEmptySceneDocument({ name: "Recovered Scene" }) ) ); const result = loadOrCreateSceneDocument(storage); expect(result.document.scenes[result.document.activeSceneId]?.name).toBe( "Recovered Scene" ); expect(result.diagnostic).toBe("Recovered latest autosave."); }); it("refuses to save an invalid scene document draft", () => { const invalidBrush = createBoxBrush({ id: "brush-invalid" }); invalidBrush.faces.posX.materialId = "missing-material"; const result = saveSceneDocumentDraft(new MemoryStorage(), { ...createProjectDocumentFromSceneDocument(createEmptySceneDocument()), scenes: { "scene-main": { id: "scene-main", name: "Untitled Scene", loadingScreen: { colorHex: "#0d1117", headline: null, description: null }, editorPreferences: createDefaultSceneEditorPreferences(), world: createEmptySceneDocument().world, brushes: { [invalidBrush.id]: invalidBrush }, terrains: {}, paths: {}, modelInstances: {}, entities: {}, interactionLinks: {} } } }); expect(result.status).toBe("error"); expect(result.message).toContain("validation error"); }); });