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",
"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"
},

View File

@@ -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<HTMLInputElement | null>(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 = () => {

View File

@@ -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 {

View File

@@ -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(
<React.StrictMode>
<App store={editorStore} />
<App store={editorStore} initialStatusMessage={initialStatusMessage} />
</React.StrictMode>
);

View File

@@ -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.`
};
}
}

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();
});
});