import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { readCanvas } from "./persistence-reader-canvas"; import { createToolcraftPersistenceSnapshot, mergeToolcraftInitialState, parseToolcraftPersistenceSnapshot, parseToolcraftPersistenceSnapshotResult, } from "./persistence"; function createPersistentSchema() { return defineToolcraft({ canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 }, upload: true, }, panels: { controls: { sections: [ { controls: { opacity: { defaultValue: 75, max: 100, min: 0, target: "selectedLayer.opacity", type: "slider", }, prompt: { defaultValue: "Describe the effect", target: "generation.prompt", type: "text", }, }, }, ], title: "Controls", }, layers: true, timeline: { mode: "keyframes" }, }, persistence: { include: ["values", "canvas", "panels", "timeline", "layers"], key: "toolcraft:persistence-test:state:v1", storage: "localStorage", version: 1, }, }); } describe("Toolcraft template state persistence", () => { it("hydrates valid canvas modes and leaves invalid legacy modes to the finite default", () => { const size = { height: 720, unit: "px" as const, width: 1280 }; expect(readCanvas({ mode: "infinite", size })).toMatchObject({ mode: "infinite", size, }); expect(readCanvas({ mode: "invalid", size })).toEqual({ size }); }); it("serializes only allowed runtime slices and schema control values", () => { const schema = createPersistentSchema(); const state = createToolcraftState(schema, { canvas: { offset: { x: 12, y: -8 }, zoom: 82, }, layers: [ { displayName: "Layer 1", id: "layer-1", kind: "layer", name: "layer-1", visible: true, }, ], mediaAssets: [ { dataUrl: "data:image/png;base64,AAAA", fileName: "source.png", id: "media-1", layerId: "layer-1", mimeType: "image/png", position: { x: 0, y: 0 }, size: { height: 64, unit: "px", width: 64 }, }, ], panels: { controls: { collapsedSections: { [schema.panels.controls?.sections[0]?.id ?? "missing"]: true, }, offset: { x: 20, y: 10 }, snapEdge: "left", }, timeline: { hidden: true }, }, selectedLayerId: "layer-1", timeline: { currentTimeSeconds: 2, durationSeconds: 10, expanded: true, keyframeGroups: [ { controlId: "selectedLayer.opacity", keyframes: [ { controlId: "selectedLayer.opacity", controlLabel: "Opacity", id: "keyframe-1", timeSeconds: 2, value: 75, valueLabel: "75%", }, ], label: "Opacity", }, ], }, values: { "generation.prompt": "Persisted prompt", "legacy.target": "remove me", "selectedLayer.opacity": 42, }, }); const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence); expect(snapshot).toMatchObject({ state: { canvas: { mode: "finite", offset: { x: 12, y: -8 }, size: { height: 720, unit: "px", width: 1280 }, zoom: 82, }, layers: [ { id: "layer-1", name: "layer-1", visible: true, }, ], mediaAssets: [ { fileName: "source.png", id: "media-1", layerId: "layer-1", }, ], panels: { controls: { collapsedSections: expect.any(Object), offset: { x: 20, y: 10 }, snapEdge: "left", }, timeline: { hidden: true }, }, selectedLayerId: "layer-1", timeline: { currentTimeSeconds: 2, durationSeconds: 10, expanded: true, }, values: { "generation.prompt": "Persisted prompt", "selectedLayer.opacity": 42, }, }, version: 1, }); expect( snapshot?.state.timeline?.keyframeGroups?.[0]?.keyframes[0]?.value, ).toBe(75); expect(snapshot?.state.values).not.toHaveProperty("legacy.target"); }); it("omits state slices for capabilities absent from the resolved plan", () => { const schema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { opacity: { defaultValue: 75, target: "selectedLayer.opacity", type: "slider", }, }, }, ], title: "Controls", }, }, }); const state = createToolcraftState(schema, { layers: [ { displayName: "Unpersisted Layer", id: "unpersisted-layer", kind: "layer", name: "unpersisted-layer", visible: true, }, ], mediaAssets: [ { dataUrl: "data:image/png;base64,AAAA", fileName: "unpersisted.png", id: "unpersisted-media", layerId: "unpersisted-layer", mimeType: "image/png", position: { x: 0, y: 0 }, size: { height: 64, unit: "px", width: 64 }, }, ], selectedLayerId: "unpersisted-layer", timeline: { currentTimeSeconds: 2, durationSeconds: 10, expanded: true, }, }); expect(schema.persistence).toMatchObject({ additionalValueTargets: [], include: ["canvas", "panels", "values"], }); const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence); expect(snapshot?.state).not.toHaveProperty("layers"); expect(snapshot?.state).not.toHaveProperty("mediaAssets"); expect(snapshot?.state).not.toHaveProperty("selectedLayerId"); expect(snapshot?.state).not.toHaveProperty("timeline"); }); it("persists media state when the app opts into media persistence", () => { const schema = defineToolcraft({ canvas: { enabled: true, upload: true }, media: { defaultAssets: [ { dataUrl: "data:image/png;base64,AAAA", fileName: "background.png", id: "default-background", layerId: "default-background-layer", mimeType: "image/png", sourceTarget: "media.background", }, ], }, panels: { controls: { sections: [ { controls: { background: { defaultValue: null, target: "media.background", type: "fileDrop", }, }, }, ], title: "Controls", }, }, persistence: { include: ["values", "panels", "media"], key: "toolcraft:media-persistence-test:state:v1", storage: "localStorage", version: 1, }, }); const state = createToolcraftState(schema, { mediaAssets: [] }); const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence); const parsed = parseToolcraftPersistenceSnapshot( schema, JSON.stringify(snapshot), ); expect(snapshot?.state.mediaAssets).toEqual([]); expect(parsed?.mediaAssets).toEqual([]); expect(createToolcraftState(schema, parsed).mediaAssets).toEqual([]); }); it("parses matching versions and ignores stale targets or invalid versions", () => { const schema = createPersistentSchema(); const persistedSectionId = schema.panels.controls?.sections.find((section) => Object.values(section.controls).some( (control) => control.target === "selectedLayer.opacity", ), )?.id; const rawValue = JSON.stringify({ state: { panels: { controls: { collapsedSections: { [persistedSectionId!]: true, "unknown-section": true, }, snapEdge: "left", }, timeline: { hidden: true }, }, values: { "generation.prompt": "Restored prompt", "legacy.target": "remove me", "selectedLayer.opacity": 55, }, }, version: 1, }); expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({ panels: { controls: { collapsedSections: { [persistedSectionId!]: true }, snapEdge: "left", }, timeline: { hidden: true }, }, values: { "generation.prompt": "Restored prompt", "selectedLayer.opacity": 55, }, }); expect( parseToolcraftPersistenceSnapshot( schema, JSON.stringify({ state: { values: { "generation.prompt": "Old" } }, version: 2 }), ), ).toBeUndefined(); expect(parseToolcraftPersistenceSnapshot(schema, "not json")).toBeUndefined(); }); it("migrates older snapshots and reports newer snapshots without consuming them", () => { const schema = defineToolcraft({ canvas: { enabled: true }, identity: { id: "migration-test", title: "Migration Test" }, panels: { controls: { sections: [ { controls: { opacity: { defaultValue: 75, max: 100, min: 0, target: "appearance.opacity", type: "slider", }, }, }, ], title: "Controls", }, }, }); const v1 = JSON.stringify({ state: { values: { "appearance.opacity": 50 } }, version: 1, }); const v3 = JSON.stringify({ state: {}, version: 3 }); expect(parseToolcraftPersistenceSnapshotResult(schema, v1)).toEqual({ fromVersion: 1, kind: "migrated", state: { values: { "appearance.opacity": 50 } }, }); expect(parseToolcraftPersistenceSnapshotResult(schema, v3)).toMatchObject({ kind: "newer-version", payload: { state: {}, version: 3 }, }); expect(parseToolcraftPersistenceSnapshot(schema, v3)).toBeUndefined(); }); it("preserves an explicitly empty controls section collapse record", () => { const schema = createPersistentSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const rawValue = JSON.stringify({ state: { panels: { controls: { collapsedSections: {} }, }, }, version: schema.persistence.version, }); expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({ panels: { controls: { collapsedSections: {} }, }, }); }); it("accepts only snap edges allowed by each panel contract", () => { const schema = createPersistentSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const rawValue = JSON.stringify({ state: { panels: { controls: { snapEdge: "top" }, layers: { snapEdge: "right" }, timeline: { snapEdge: "bottom" }, toolbar: { snapEdge: "left" }, }, }, version: schema.persistence.version, }); expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({ panels: { layers: { snapEdge: "right" }, timeline: { snapEdge: "bottom" }, }, }); }); it("replaces invalid persisted control values with schema defaults", () => { const schema = createPersistentSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const rawValue = JSON.stringify({ state: { values: { "generation.prompt": 42, "selectedLayer.opacity": 200, }, }, version: schema.persistence.version, }); expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({ values: { "generation.prompt": "Describe the effect", "selectedLayer.opacity": 75, }, }); }); it("lets explicit initial state override persisted state", () => { expect( mergeToolcraftInitialState( { canvas: { offset: { x: 10, y: 20 }, zoom: 80, }, panels: { controls: { offset: { x: 4, y: 8 } }, }, values: { "generation.prompt": "Persisted", }, }, { canvas: { zoom: 95, }, panels: { controls: { collapsed: true }, }, values: { "generation.prompt": "Explicit", }, }, ), ).toEqual({ canvas: { offset: { x: 10, y: 20 }, zoom: 95, }, panels: { controls: { collapsed: true, offset: { x: 4, y: 8 } }, }, values: { "generation.prompt": "Explicit", }, }); }); });