Add tests and enhance local draft storage handling
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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/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>
|
||||
);
|
||||
|
||||
@@ -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.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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