Add tests and enhance local draft storage handling
This commit is contained in:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
12
src/main.tsx
12
src/main.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.`
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
tests/serialization/local-draft-storage.test.ts
Normal file
117
tests/serialization/local-draft-storage.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
tests/unit/package-scripts.test.ts
Normal file
24
tests/unit/package-scripts.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user