import { describe, expect, it } from "vitest"; import { layersModule } from "../modules/built-ins/layers/layers-module"; import { mediaSourceModule } from "../modules/built-ins/media-source/media-source-module"; import { timelineModule } from "../modules/built-ins/timeline/timeline-module"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { readCanvas } from "./persistence-reader-canvas"; import { createToolcraftPersistenceSnapshot, parseToolcraftPersistenceSnapshot, parseToolcraftPersistenceSnapshotResult, } from "../composition/public-persistence"; import { createPersistentTestSchema as createPersistentSchema } from "./persistence.test-support"; 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: [ { policy: "prepared-source", asset: { sourceSize: { height: 64, unit: "px", width: 64 }, assetKind: "image", lifecycle: "ready", resourceRef: "data:image/png;base64,AAAA", fileName: "source.png", id: "media-1", layerId: "layer-1", mimeType: "image/png", position: { x: 0, y: 0 }, }, }, ], 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", size: { height: 720, unit: "px", width: 1280 }, sourceSize: { height: 64, unit: "px", width: 64 }, }, ], 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: 2, }); 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({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: { controls: { sections: [ { id: "test-section-2", controls: { opacity: { applicability: { mode: "always" as const }, defaultValue: 75, target: "selectedLayer.opacity", type: "slider", }, }, }, ], title: "Controls", }, }, }, modules: [], }); const state = createToolcraftState(schema, { layers: [ { displayName: "Unpersisted Layer", id: "unpersisted-layer", kind: "layer", name: "unpersisted-layer", visible: true, }, ], mediaAssets: [ { sourceSize: { height: 64, unit: "px", width: 64 }, assetKind: "image", lifecycle: "ready", resourceRef: "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({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true, upload: true }, media: { defaultAssets: [ { ingressPolicy: "prepared-source", position: { x: 0, y: 0 }, sourceSize: { height: 512, unit: "px", width: 512 }, 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: [ { id: "test-section-3", controls: { background: { applicability: { mode: "always" as const }, defaultValue: null, target: "media.background", type: "fileDrop", }, }, }, ], title: "Controls", }, }, persistence: { storage: "localStorage", }, }, modules: [mediaSourceModule()], }); 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(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } 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: schema.persistence.version, }); 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: 3, }), ), ).toBeUndefined(); expect( parseToolcraftPersistenceSnapshot(schema, "not json"), ).toBeUndefined(); }); it("rejects every recognized non-current snapshot version", () => { const schema = defineToolcraft({ base: { canvas: { enabled: true }, identity: { id: "migration-test", title: "Migration Test" }, panels: { controls: { sections: [ { id: "test-section-4", controls: { opacity: { applicability: { mode: "always" as const }, defaultValue: 75, max: 100, min: 0, target: "appearance.opacity", type: "slider", }, }, }, ], title: "Controls", }, }, }, modules: [], }); const v1 = JSON.stringify({ state: { values: { "appearance.opacity": 50 } }, version: 1, }); const v3 = JSON.stringify({ state: {}, version: 3 }); const malformedV1 = JSON.stringify({ state: "not-an-object", version: 1 }); const malformedV3 = JSON.stringify({ state: null, version: 3 }); for (const rawValue of [v1, v3, malformedV1, malformedV3]) { expect(parseToolcraftPersistenceSnapshotResult(schema, rawValue)).toEqual( { kind: "incompatible-version" }, ); } for (const rawValue of [v1, v3]) { expect( parseToolcraftPersistenceSnapshot(schema, rawValue), ).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, }, }); }); });