From 4df1773c95410b646757985cf6b9000466618c3c Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 01:50:29 +0200 Subject: [PATCH] Add tests and enhance local draft storage handling --- package.json | 2 + src/app/App.tsx | 17 +-- src/app/editor-store.ts | 32 +++-- src/main.tsx | 12 +- src/serialization/local-draft-storage.ts | 135 +++++++++++++++--- .../serialization/local-draft-storage.test.ts | 117 +++++++++++++++ tests/unit/package-scripts.test.ts | 24 ++++ 7 files changed, 296 insertions(+), 43 deletions(-) create mode 100644 tests/serialization/local-draft-storage.test.ts create mode 100644 tests/unit/package-scripts.test.ts diff --git a/package.json b/package.json index 15a14d64..f7d392c8 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "test:typecheck": "tsc --noEmit", "lint": "eslint .", "format": "prettier --write .", "test": "vitest run", + "test:browser": "playwright test", "test:watch": "vitest", "test:e2e": "playwright test" }, diff --git a/src/app/App.tsx b/src/app/App.tsx index ffda017a..fc9d11bf 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -9,6 +9,7 @@ import { useEditorStoreState } from "./use-editor-store"; interface AppProps { store: EditorStore; + initialStatusMessage?: string; } function describeSelection(selectionKind: EditorSelection["kind"]): string { @@ -34,10 +35,10 @@ function getErrorMessage(error: unknown): string { return "An unexpected error occurred."; } -export function App({ store }: AppProps) { +export function App({ store, initialStatusMessage }: AppProps) { const editorState = useEditorStoreState(store); const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name); - const [statusMessage, setStatusMessage] = useState("Viewport shell ready."); + const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Viewport shell ready."); const importInputRef = useRef(null); useEffect(() => { @@ -57,17 +58,13 @@ export function App({ store }: AppProps) { }; const handleSaveDraft = () => { - const didSave = store.saveDraft(); - setStatusMessage(didSave ? "Local draft saved." : "Local draft storage is unavailable."); + const result = store.saveDraft(); + setStatusMessage(result.message); }; const handleLoadDraft = () => { - try { - const didLoad = store.loadDraft(); - setStatusMessage(didLoad ? "Local draft loaded." : "No local draft was found."); - } catch (error) { - setStatusMessage(getErrorMessage(error)); - } + const result = store.loadDraft(); + setStatusMessage(result.message); }; const handleExportJson = () => { diff --git a/src/app/editor-store.ts b/src/app/editor-store.ts index 8aaf3dd6..1c481fdf 100644 --- a/src/app/editor-store.ts +++ b/src/app/editor-store.ts @@ -5,8 +5,10 @@ import type { ToolMode } from "../core/tool-mode"; import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document"; import { DEFAULT_SCENE_DRAFT_STORAGE_KEY, + type LoadSceneDocumentDraftResult, loadSceneDocumentDraft, type KeyValueStorage, + type SaveSceneDocumentDraftResult, saveSceneDocumentDraft } from "../serialization/local-draft-storage"; import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json"; @@ -28,6 +30,9 @@ interface EditorStoreOptions { type EditorStoreListener = () => void; +export type EditorDraftSaveResult = SaveSceneDocumentDraftResult; +export type EditorDraftLoadResult = LoadSceneDocumentDraftResult; + export class EditorStore { private document: SceneDocument; private selection: EditorSelection = { kind: "none" }; @@ -128,28 +133,33 @@ export class EditorStore { this.emit(); } - saveDraft(): boolean { + saveDraft(): EditorDraftSaveResult { if (this.storage === null) { - return false; + return { + status: "error", + message: "Browser local storage is unavailable." + }; } - saveSceneDocumentDraft(this.storage, this.document, this.storageKey); - return true; + return saveSceneDocumentDraft(this.storage, this.document, this.storageKey); } - loadDraft(): boolean { + loadDraft(): EditorDraftLoadResult { if (this.storage === null) { - return false; + return { + status: "error", + message: "Browser local storage is unavailable." + }; } - const document = loadSceneDocumentDraft(this.storage, this.storageKey); + const draftResult = loadSceneDocumentDraft(this.storage, this.storageKey); - if (document === null) { - return false; + if (draftResult.status !== "loaded") { + return draftResult; } - this.replaceDocument(document); - return true; + this.replaceDocument(draftResult.document); + return draftResult; } exportDocumentJson(): string { diff --git a/src/main.tsx b/src/main.tsx index c34f9610..2678d5f5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,7 @@ import ReactDOM from "react-dom/client"; import { App } from "./app/App"; import "./app/app.css"; import { createEditorStore } from "./app/editor-store"; -import { getBrowserStorage, loadOrCreateSceneDocument } from "./serialization/local-draft-storage"; +import { getBrowserStorageAccess, loadOrCreateSceneDocument } from "./serialization/local-draft-storage"; const rootElement = document.getElementById("root"); @@ -12,14 +12,16 @@ if (rootElement === null) { throw new Error("Expected #root element to bootstrap the editor."); } -const storage = getBrowserStorage(); +const storageAccess = getBrowserStorageAccess(); +const bootstrapResult = loadOrCreateSceneDocument(storageAccess.storage); const editorStore = createEditorStore({ - initialDocument: loadOrCreateSceneDocument(storage), - storage + initialDocument: bootstrapResult.document, + storage: storageAccess.storage }); +const initialStatusMessage = [storageAccess.diagnostic, bootstrapResult.diagnostic].filter(Boolean).join(" ") || undefined; ReactDOM.createRoot(rootElement).render( - + ); diff --git a/src/serialization/local-draft-storage.ts b/src/serialization/local-draft-storage.ts index e6bea3b5..04e9de4e 100644 --- a/src/serialization/local-draft-storage.ts +++ b/src/serialization/local-draft-storage.ts @@ -8,38 +8,139 @@ export interface KeyValueStorage { removeItem(key: string): void; } +export interface BrowserStorageAccessResult { + storage: KeyValueStorage | null; + diagnostic: string | null; +} + +export type SaveSceneDocumentDraftResult = + | { status: "saved"; message: string } + | { status: "error"; message: string }; + +export type LoadSceneDocumentDraftResult = + | { status: "loaded"; document: SceneDocument; message: string } + | { status: "missing"; message: string } + | { status: "error"; message: string }; + +export interface LoadOrCreateSceneDocumentResult { + document: SceneDocument; + diagnostic: string | null; +} + export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft"; -export function getBrowserStorage(): KeyValueStorage | null { - if (typeof window === "undefined") { - return null; +function getErrorDetail(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); } - return window.localStorage; + return "Unknown error."; +} + +function formatStorageDiagnostic(prefix: string, error: unknown): string { + return `${prefix} ${getErrorDetail(error)}`; +} + +export function getBrowserStorageAccess(): BrowserStorageAccessResult { + if (typeof window === "undefined") { + return { + storage: null, + diagnostic: null + }; + } + + try { + return { + storage: window.localStorage, + diagnostic: null + }; + } catch (error) { + return { + storage: null, + diagnostic: formatStorageDiagnostic("Browser local storage is unavailable.", error) + }; + } +} + +export function getBrowserStorage(): KeyValueStorage | null { + return getBrowserStorageAccess().storage; } export function saveSceneDocumentDraft( storage: KeyValueStorage, document: SceneDocument, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY -) { - storage.setItem(key, serializeSceneDocument(document)); -} +): SaveSceneDocumentDraftResult { + try { + storage.setItem(key, serializeSceneDocument(document)); -export function loadSceneDocumentDraft(storage: KeyValueStorage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument | null { - const rawDocument = storage.getItem(key); - - if (rawDocument === null) { - return null; + return { + status: "saved", + message: "Local draft saved." + }; + } catch (error) { + return { + status: "error", + message: formatStorageDiagnostic("Local draft could not be saved.", error) + }; } - - return parseSceneDocumentJson(rawDocument); } -export function loadOrCreateSceneDocument(storage: KeyValueStorage | null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument { +export function loadSceneDocumentDraft( + storage: KeyValueStorage, + key = DEFAULT_SCENE_DRAFT_STORAGE_KEY +): LoadSceneDocumentDraftResult { + try { + const rawDocument = storage.getItem(key); + + if (rawDocument === null) { + return { + status: "missing", + message: "No local draft was found." + }; + } + + return { + status: "loaded", + document: parseSceneDocumentJson(rawDocument), + message: "Local draft loaded." + }; + } catch (error) { + return { + status: "error", + message: formatStorageDiagnostic("Stored local draft could not be loaded.", error) + }; + } +} + +export function loadOrCreateSceneDocument( + storage: KeyValueStorage | null, + key = DEFAULT_SCENE_DRAFT_STORAGE_KEY +): LoadOrCreateSceneDocumentResult { if (storage === null) { - return createEmptySceneDocument(); + return { + document: createEmptySceneDocument(), + diagnostic: null + }; } - return loadSceneDocumentDraft(storage, key) ?? createEmptySceneDocument(); + const draftResult = loadSceneDocumentDraft(storage, key); + + switch (draftResult.status) { + case "loaded": + return { + document: draftResult.document, + diagnostic: null + }; + case "missing": + return { + document: createEmptySceneDocument(), + diagnostic: null + }; + case "error": + return { + document: createEmptySceneDocument(), + diagnostic: `${draftResult.message} Starting with a fresh empty document.` + }; + } } diff --git a/tests/serialization/local-draft-storage.test.ts b/tests/serialization/local-draft-storage.test.ts new file mode 100644 index 00000000..1d96d824 --- /dev/null +++ b/tests/serialization/local-draft-storage.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document"; +import { + DEFAULT_SCENE_DRAFT_STORAGE_KEY, + getBrowserStorageAccess, + loadOrCreateSceneDocument, + loadSceneDocumentDraft, + saveSceneDocumentDraft, + type KeyValueStorage +} from "../../src/serialization/local-draft-storage"; + +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(createEmptySceneDocument()); + expect(result.diagnostic).toContain("Stored local draft 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); + } + } + }); + + 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") + }), + createEmptySceneDocument() + ); + + expect(result.status).toBe("error"); + expect(result.message).toContain("quota exceeded"); + }); +}); diff --git a/tests/unit/package-scripts.test.ts b/tests/unit/package-scripts.test.ts new file mode 100644 index 00000000..4f58d2d1 --- /dev/null +++ b/tests/unit/package-scripts.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +interface PackageManifest { + scripts?: Record; +} + +function readPackageManifest(): PackageManifest { + return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as PackageManifest; +} + +describe("package scripts", () => { + it("exposes the expected verification script contract", () => { + const packageManifest = readPackageManifest(); + + expect(packageManifest.scripts).toBeDefined(); + expect(packageManifest.scripts?.["test"]).toBeDefined(); + expect(packageManifest.scripts?.["test:browser"]).toBeDefined(); + expect(packageManifest.scripts?.["test:e2e"]).toBeDefined(); + expect(packageManifest.scripts?.["typecheck"]).toBeDefined(); + expect(packageManifest.scripts?.["test:typecheck"]).toBeDefined(); + }); +});