import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { createToolcraftState } from "../../state/create-template-state"; import { createToolcraftExternalStore } from "../../composition/public-state"; import { useToolcraftPersistence } from "./use-toolcraft-persistence"; const storageKey = "toolcraft:persistence-regressions:state:v2"; const initialLayout = { x: 120 }; function createFixture() { const schema = defineToolcraft({ base: { identity: { id: "persistence-regressions", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, persistence: { storage: "localStorage", additionalValueTargets: ["composition.layout"], }, }, modules: [], }); const store = createToolcraftExternalStore( createToolcraftState(schema, { values: { "composition.layout": initialLayout }, }), ); return { schema, store }; } function savedLayout() { return JSON.parse(window.localStorage.getItem(storageKey) ?? "{}").state ?.values?.["composition.layout"]; } function advance(milliseconds: number) { act(() => { vi.advanceTimersByTime(milliseconds); }); } beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); window.localStorage.clear(); }); describe("Toolcraft persistence hook regressions", () => { it("autosaves only an additional value update at the 120 ms deadline", () => { const { schema, store } = createFixture(); const writes = vi.spyOn(Storage.prototype, "setItem"); renderHook(() => useToolcraftPersistence(schema, store)); advance(119); expect(writes).not.toHaveBeenCalled(); advance(1); expect(savedLayout()).toEqual(initialLayout); expect(writes).toHaveBeenCalledTimes(1); act(() => { store.dispatch({ type: "controls.setValue", target: "composition.layout", value: { x: 240 }, }); }); advance(119); expect(savedLayout()).toEqual(initialLayout); expect(writes).toHaveBeenCalledTimes(1); advance(1); expect(savedLayout()).toEqual({ x: 240 }); expect(writes).toHaveBeenCalledTimes(2); }); it("ignores excluded values and transient viewport changes without postponing additional values", () => { const { schema, store } = createFixture(); const writes = vi.spyOn(Storage.prototype, "setItem"); renderHook(() => useToolcraftPersistence(schema, store)); advance(120); act(() => { store.dispatch({ type: "controls.setValue", target: "excluded", value: 1, }); }); advance(200); expect(writes).toHaveBeenCalledTimes(1); act(() => { store.dispatch({ type: "controls.setValue", target: "composition.layout", value: { x: 240 }, }); }); advance(100); act(() => { store.dispatch({ type: "controls.setValue", target: "excluded", value: 2, }); store.dispatchTransient({ type: "canvas.setOffset", offset: { x: 20, y: 30 }, }); }); advance(20); expect(savedLayout()).toEqual({ x: 240 }); expect(writes).toHaveBeenCalledTimes(2); expect( JSON.parse(window.localStorage.getItem(storageKey)!).state.values, ).not.toHaveProperty("excluded"); }); it.each(["pagehide", "unmount"] as const)( "flushes the latest additional value on %s", (boundary) => { const { schema, store } = createFixture(); const writes = vi.spyOn(Storage.prototype, "setItem"); const view = renderHook(() => useToolcraftPersistence(schema, store)); advance(120); act(() => { store.dispatch({ type: "controls.setValue", target: "composition.layout", value: { x: 240 }, }); store.dispatch({ type: "controls.setValue", target: "composition.layout", value: { x: 360 }, }); }); advance(119); expect(savedLayout()).toEqual(initialLayout); act(() => { if (boundary === "pagehide") window.dispatchEvent(new Event("pagehide")); else view.unmount(); }); expect(savedLayout()).toEqual({ x: 360 }); expect(writes).toHaveBeenCalledTimes(2); view.unmount(); act(() => { store.dispatch({ type: "controls.setValue", target: "composition.layout", value: { x: 480 }, }); }); advance(200); expect(savedLayout()).toEqual({ x: 360 }); expect(writes).toHaveBeenCalledTimes(2); }, ); it("reports unavailable storage without throwing when its browser getter is denied", () => { const { schema, store } = createFixture(); const getter = vi .spyOn(window, "localStorage", "get") .mockImplementation(() => { throw new DOMException("Storage denied", "SecurityError"); }); const view = renderHook(() => useToolcraftPersistence(schema, store)); advance(120); expect(view.result.current).toEqual({ reason: "unavailable", status: "failed", }); act(() => { window.dispatchEvent(new Event("pagehide")); }); expect(view.result.current).toEqual({ reason: "unavailable", status: "failed", }); view.unmount(); expect(getter).toHaveBeenCalledTimes(1); }); });