Add tests and enhance local draft storage handling

This commit is contained in:
2026-03-31 01:50:29 +02:00
parent e23bcac025
commit 4df1773c95
7 changed files with 296 additions and 43 deletions

View File

@@ -8,9 +8,11 @@
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test:typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test": "vitest run", "test": "vitest run",
"test:browser": "playwright test",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },

View File

@@ -9,6 +9,7 @@ import { useEditorStoreState } from "./use-editor-store";
interface AppProps { interface AppProps {
store: EditorStore; store: EditorStore;
initialStatusMessage?: string;
} }
function describeSelection(selectionKind: EditorSelection["kind"]): string { function describeSelection(selectionKind: EditorSelection["kind"]): string {
@@ -34,10 +35,10 @@ function getErrorMessage(error: unknown): string {
return "An unexpected error occurred."; return "An unexpected error occurred.";
} }
export function App({ store }: AppProps) { export function App({ store, initialStatusMessage }: AppProps) {
const editorState = useEditorStoreState(store); const editorState = useEditorStoreState(store);
const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name); 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<HTMLInputElement | null>(null); const importInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -57,17 +58,13 @@ export function App({ store }: AppProps) {
}; };
const handleSaveDraft = () => { const handleSaveDraft = () => {
const didSave = store.saveDraft(); const result = store.saveDraft();
setStatusMessage(didSave ? "Local draft saved." : "Local draft storage is unavailable."); setStatusMessage(result.message);
}; };
const handleLoadDraft = () => { const handleLoadDraft = () => {
try { const result = store.loadDraft();
const didLoad = store.loadDraft(); setStatusMessage(result.message);
setStatusMessage(didLoad ? "Local draft loaded." : "No local draft was found.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
}; };
const handleExportJson = () => { const handleExportJson = () => {

View File

@@ -5,8 +5,10 @@ import type { ToolMode } from "../core/tool-mode";
import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document"; import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document";
import { import {
DEFAULT_SCENE_DRAFT_STORAGE_KEY, DEFAULT_SCENE_DRAFT_STORAGE_KEY,
type LoadSceneDocumentDraftResult,
loadSceneDocumentDraft, loadSceneDocumentDraft,
type KeyValueStorage, type KeyValueStorage,
type SaveSceneDocumentDraftResult,
saveSceneDocumentDraft saveSceneDocumentDraft
} from "../serialization/local-draft-storage"; } from "../serialization/local-draft-storage";
import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json"; import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json";
@@ -28,6 +30,9 @@ interface EditorStoreOptions {
type EditorStoreListener = () => void; type EditorStoreListener = () => void;
export type EditorDraftSaveResult = SaveSceneDocumentDraftResult;
export type EditorDraftLoadResult = LoadSceneDocumentDraftResult;
export class EditorStore { export class EditorStore {
private document: SceneDocument; private document: SceneDocument;
private selection: EditorSelection = { kind: "none" }; private selection: EditorSelection = { kind: "none" };
@@ -128,28 +133,33 @@ export class EditorStore {
this.emit(); this.emit();
} }
saveDraft(): boolean { saveDraft(): EditorDraftSaveResult {
if (this.storage === null) { if (this.storage === null) {
return false; return {
status: "error",
message: "Browser local storage is unavailable."
};
} }
saveSceneDocumentDraft(this.storage, this.document, this.storageKey); return saveSceneDocumentDraft(this.storage, this.document, this.storageKey);
return true;
} }
loadDraft(): boolean { loadDraft(): EditorDraftLoadResult {
if (this.storage === null) { 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) { if (draftResult.status !== "loaded") {
return false; return draftResult;
} }
this.replaceDocument(document); this.replaceDocument(draftResult.document);
return true; return draftResult;
} }
exportDocumentJson(): string { exportDocumentJson(): string {

View File

@@ -4,7 +4,7 @@ import ReactDOM from "react-dom/client";
import { App } from "./app/App"; import { App } from "./app/App";
import "./app/app.css"; import "./app/app.css";
import { createEditorStore } from "./app/editor-store"; 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"); const rootElement = document.getElementById("root");
@@ -12,14 +12,16 @@ if (rootElement === null) {
throw new Error("Expected #root element to bootstrap the editor."); throw new Error("Expected #root element to bootstrap the editor.");
} }
const storage = getBrowserStorage(); const storageAccess = getBrowserStorageAccess();
const bootstrapResult = loadOrCreateSceneDocument(storageAccess.storage);
const editorStore = createEditorStore({ const editorStore = createEditorStore({
initialDocument: loadOrCreateSceneDocument(storage), initialDocument: bootstrapResult.document,
storage storage: storageAccess.storage
}); });
const initialStatusMessage = [storageAccess.diagnostic, bootstrapResult.diagnostic].filter(Boolean).join(" ") || undefined;
ReactDOM.createRoot(rootElement).render( ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<App store={editorStore} /> <App store={editorStore} initialStatusMessage={initialStatusMessage} />
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -8,38 +8,139 @@ export interface KeyValueStorage {
removeItem(key: string): void; 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 const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
export function getBrowserStorage(): KeyValueStorage | null { function getErrorDetail(error: unknown): string {
if (typeof window === "undefined") { if (error instanceof Error && error.message.trim().length > 0) {
return null; 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( export function saveSceneDocumentDraft(
storage: KeyValueStorage, storage: KeyValueStorage,
document: SceneDocument, document: SceneDocument,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
) { ): SaveSceneDocumentDraftResult {
storage.setItem(key, serializeSceneDocument(document)); try {
} storage.setItem(key, serializeSceneDocument(document));
export function loadSceneDocumentDraft(storage: KeyValueStorage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument | null { return {
const rawDocument = storage.getItem(key); status: "saved",
message: "Local draft saved."
if (rawDocument === null) { };
return null; } 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) { 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.`
};
}
} }

View File

@@ -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<string, string>();
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");
});
});

View File

@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
interface PackageManifest {
scripts?: Record<string, string>;
}
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();
});
});