import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { createToolcraftPersistenceController } from "./persistence-controller"; function createSchema(storage: "localStorage" | "none" = "localStorage") { return defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { opacity: { defaultValue: 75, target: "appearance.opacity", type: "slider", }, }, id: "appearance", title: "Appearance", }, ], title: "Controls", }, }, persistence: storage === "none" ? { storage: "none" } : { include: ["values", "panels"], key: "toolcraft:persistence-controller-test:state:v1", storage: "localStorage", version: 1, }, }); } afterEach(() => { vi.useRealTimers(); }); describe("createToolcraftPersistenceController", () => { it("debounces writes and flushes the latest committed snapshot", () => { vi.useFakeTimers(); const schema = createSchema(); let state = createToolcraftState(schema); const setItem = vi.fn(); const controller = createToolcraftPersistenceController({ getCommittedState: () => state, schema, storage: { setItem }, }); controller.schedule(); state = createToolcraftState(schema, { values: { "appearance.opacity": 50 }, }); controller.schedule(); vi.advanceTimersByTime(119); expect(setItem).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(setItem).toHaveBeenCalledTimes(1); expect(JSON.parse(setItem.mock.calls[0]![1])).toMatchObject({ state: { values: { "appearance.opacity": 50 } }, version: 1, }); }); it("flushes a pending committed snapshot before disposal", () => { vi.useFakeTimers(); const schema = createSchema(); const state = createToolcraftState(schema, { values: { "appearance.opacity": 50 }, }); const setItem = vi.fn(); const controller = createToolcraftPersistenceController({ getCommittedState: () => state, schema, storage: { setItem }, }); controller.schedule(); expect(controller.dispose()).toEqual({ status: "success" }); expect(setItem).toHaveBeenCalledTimes(1); }); it("does not access storage when persistence is disabled", () => { const schema = createSchema("none"); const setItem = vi.fn(); const controller = createToolcraftPersistenceController({ getCommittedState: () => createToolcraftState(schema), schema, storage: { setItem }, }); controller.schedule(); expect(controller.flush()).toEqual({ status: "disabled" }); expect(controller.dispose()).toEqual({ status: "disabled" }); expect(setItem).not.toHaveBeenCalled(); }); it("reports quota failure once without an autonomous retry", () => { vi.useFakeTimers(); const schema = createSchema(); const setItem = vi.fn(() => { throw new DOMException("Storage full", "QuotaExceededError"); }); const controller = createToolcraftPersistenceController({ getCommittedState: () => createToolcraftState(schema), schema, storage: { setItem }, }); controller.schedule(); vi.advanceTimersByTime(120); expect(controller.getStatus()).toEqual({ reason: "quota", status: "failed", }); expect(setItem).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1_000); expect(setItem).toHaveBeenCalledTimes(1); }); it("preserves a newer unsupported snapshot by blocking writes", () => { const schema = createSchema(); const setItem = vi.fn(); const controller = createToolcraftPersistenceController({ blockedReason: "newer-version", getCommittedState: () => createToolcraftState(schema), schema, storage: { setItem }, }); controller.schedule(); expect(controller.flush()).toEqual({ reason: "incompatible-version", status: "failed", }); expect(controller.dispose()).toEqual({ reason: "incompatible-version", status: "failed", }); expect(setItem).not.toHaveBeenCalled(); }); it("preserves the current snapshot while an external migration blocks writes", () => { vi.useFakeTimers(); const schema = createSchema(); const setItem = vi.fn(); let blocked = true; const controller = createToolcraftPersistenceController({ getCommittedState: () => createToolcraftState(schema), getWriteBlock: () => blocked ? { reason: "unavailable", status: "failed" } : null, schema, storage: { setItem }, }); controller.schedule(); vi.advanceTimersByTime(120); expect(controller.getStatus()).toEqual({ reason: "unavailable", status: "failed", }); expect(setItem).not.toHaveBeenCalled(); blocked = false; controller.schedule(); vi.advanceTimersByTime(120); expect(controller.getStatus()).toEqual({ status: "success" }); expect(setItem).toHaveBeenCalledTimes(1); }); });