diff --git a/tests/domain/editor-store.test.ts b/tests/domain/editor-store.test.ts index 4a63370a..e7d85049 100644 --- a/tests/domain/editor-store.test.ts +++ b/tests/domain/editor-store.test.ts @@ -92,7 +92,7 @@ describe("EditorStore", () => { }); expect(writerStore.saveDraft()).toEqual({ status: "saved", - message: "Local draft saved." + message: "Autosave updated." }); const readerStore = createEditorStore({ @@ -102,7 +102,7 @@ describe("EditorStore", () => { expect(readerStore.loadDraft()).toMatchObject({ status: "loaded", - message: "Local draft loaded." + message: "Recovered latest autosave." }); expect(readerStore.getState().document.name).toBe("Draft Scene"); expect(readerStore.getState().viewportLayoutMode).toBe("quad"); diff --git a/tests/unit/app-project-persistence.integration.test.tsx b/tests/unit/app-project-persistence.integration.test.tsx new file mode 100644 index 00000000..b1305c01 --- /dev/null +++ b/tests/unit/app-project-persistence.integration.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { + const viewportHostInstances: Array<{ + setTransformCommitHandler: ReturnType; + mount: ReturnType; + dispose: ReturnType; + updateWorld: ReturnType; + updateAssets: ReturnType; + updateDocument: ReturnType; + setPanelId: ReturnType; + setViewMode: ReturnType; + setDisplayMode: ReturnType; + setCameraState: ReturnType; + setBrushSelectionChangeHandler: ReturnType; + setCameraStateChangeHandler: ReturnType; + setCreationPreviewChangeHandler: ReturnType; + setCreationCommitHandler: ReturnType; + setTransformSessionChangeHandler: ReturnType; + setTransformCancelHandler: ReturnType; + setWhiteboxHoverLabelChangeHandler: ReturnType; + setWhiteboxSelectionMode: ReturnType; + setWhiteboxSnapSettings: ReturnType; + setToolMode: ReturnType; + setCreationPreview: ReturnType; + setTransformSession: ReturnType; + focusSelection: ReturnType; + }> = []; + + class MockViewportHost { + setPanelId = vi.fn(); + mount = vi.fn(); + dispose = vi.fn(); + updateWorld = vi.fn(); + updateAssets = vi.fn(); + updateDocument = vi.fn(); + setViewMode = vi.fn(); + setDisplayMode = vi.fn(); + setCameraState = vi.fn(); + setBrushSelectionChangeHandler = vi.fn(); + setCameraStateChangeHandler = vi.fn(); + setCreationPreviewChangeHandler = vi.fn(); + setCreationCommitHandler = vi.fn(); + setTransformSessionChangeHandler = vi.fn(); + setTransformCommitHandler = vi.fn(); + setTransformCancelHandler = vi.fn(); + setWhiteboxHoverLabelChangeHandler = vi.fn(); + setWhiteboxSelectionMode = vi.fn(); + setWhiteboxSnapSettings = vi.fn(); + setToolMode = vi.fn(); + setCreationPreview = vi.fn(); + setTransformSession = vi.fn(); + focusSelection = vi.fn(); + + constructor() { + viewportHostInstances.push(this); + } + } + + return { + MockViewportHost, + viewportHostInstances + }; +}); + +const saveProjectPackageMock = vi.fn(async () => new Uint8Array([1, 2, 3])); +const loadProjectPackageMock = vi.fn(); + +vi.mock("../../src/viewport-three/viewport-host", () => ({ + ViewportHost: MockViewportHost +})); + +vi.mock("../../src/assets/project-asset-storage", () => ({ + getBrowserProjectAssetStorageAccess: vi.fn(async () => ({ + storage: null, + diagnostic: null + })) +})); + +vi.mock("../../src/serialization/project-package", () => ({ + PROJECT_PACKAGE_FILE_EXTENSION: ".we3d", + saveProjectPackage: saveProjectPackageMock, + loadProjectPackage: loadProjectPackageMock +})); + +import { App } from "../../src/app/App"; +import { createEditorStore } from "../../src/app/editor-store"; + +describe("App project persistence controls", () => { + beforeEach(() => { + viewportHostInstances.length = 0; + saveProjectPackageMock.mockClear(); + loadProjectPackageMock.mockClear(); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => ({}) as never); + vi.stubGlobal("URL", { + createObjectURL: vi.fn(() => "blob:project"), + revokeObjectURL: vi.fn() + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("shows Save Project and Load Project instead of the old draft/json actions", async () => { + render(); + + await waitFor(() => { + expect(viewportHostInstances.length).toBeGreaterThan(0); + }); + + expect(screen.getByRole("button", { name: "Save Project" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Load Project" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save Draft" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Load Draft" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Export JSON" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Import JSON" })).not.toBeInTheDocument(); + }); + + it("invokes Save Project when Cmd/Ctrl+S is pressed", async () => { + const store = createEditorStore(); + + render(); + + await waitFor(() => { + expect(viewportHostInstances.length).toBeGreaterThan(0); + }); + + fireEvent.keyDown(window, { + code: "KeyS", + ctrlKey: true + }); + + await waitFor(() => { + expect(saveProjectPackageMock).toHaveBeenCalledWith(store.getState().document, null); + }); + }); +}); diff --git a/tests/unit/editor-autosave.test.ts b/tests/unit/editor-autosave.test.ts new file mode 100644 index 00000000..f3a8918e --- /dev/null +++ b/tests/unit/editor-autosave.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { EditorAutosaveController } from "../../src/serialization/editor-autosave"; + +describe("EditorAutosaveController", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("debounces repeated autosave schedules into one save", () => { + const saveDraft = vi.fn(() => ({ + status: "saved" as const, + message: "Autosave updated." + })); + const autosave = new EditorAutosaveController({ + debounceMs: 200, + saveDraft + }); + + autosave.schedule(); + autosave.schedule(); + autosave.schedule(); + + vi.advanceTimersByTime(199); + expect(saveDraft).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(saveDraft).toHaveBeenCalledTimes(1); + }); + + it("flushes a pending autosave immediately", () => { + const saveDraft = vi.fn(() => ({ + status: "saved" as const, + message: "Autosave updated." + })); + const autosave = new EditorAutosaveController({ + debounceMs: 200, + saveDraft + }); + + autosave.schedule(); + const flushResult = autosave.flush(); + + expect(flushResult).toEqual({ + status: "saved", + message: "Autosave updated." + }); + expect(saveDraft).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(200); + expect(saveDraft).toHaveBeenCalledTimes(1); + }); + + it("reports autosave failures through the completion callback", () => { + const onComplete = vi.fn(); + const autosave = new EditorAutosaveController({ + debounceMs: 100, + onComplete, + saveDraft: () => ({ + status: "error" as const, + message: "Autosave could not be saved. quota exceeded" + }) + }); + + autosave.schedule(); + vi.advanceTimersByTime(100); + + expect(onComplete).toHaveBeenCalledWith({ + status: "error", + message: "Autosave could not be saved. quota exceeded" + }); + }); +});