import { describe, expect, it } from "vitest"; import { createTimelineSchema } from "../react/timeline/timeline-panel-test-utils"; import type { ResolvedToolcraftAppSchema } from "../schema/resolved-app-schema"; import { getToolcraftCollectionItemControlAddress } from "./collection-control-address"; import { toolcraftCollectionItemControlAddressPrefix } from "./collection-control-address"; import { createToolcraftState } from "./create-template-state"; import { mergeToolcraftInitialState } from "../composition/public-persistence"; import type { ToolcraftInitialState } from "./types"; function createSchema() { const base = createTimelineSchema(); const controls = { sections: [ { controls: { notes: { applicability: { mode: "always", origin: "explicit" }, defaultValue: [], itemControls: { color: { defaultValue: "#14B8A6", type: "color" }, opacity: { defaultValue: 60, keyframeable: true, max: 100, min: 0, type: "slider", }, }, selectionTarget: "storyboard.selectedNote", target: "storyboard.notes", type: "collectionActions", }, }, id: "notes", }, ], title: "Controls", } satisfies NonNullable; return { ...base, panels: { ...base.panels, controls }, } satisfies ResolvedToolcraftAppSchema; } function persistedState(): ToolcraftInitialState { const controlId = getToolcraftCollectionItemControlAddress( "storyboard.notes", 0, "opacity", ); return { timeline: { keyframeGroups: [ { controlId, keyframes: [ { controlId, controlLabel: "Opacity", id: "opacity-0", timeSeconds: 0, value: 60, valueLabel: "60", }, ], label: "Opacity", }, ], }, values: { "storyboard.notes": [{ color: "#14B8A6", opacity: 60 }], "storyboard.selectedNote": 0, }, }; } describe("persisted collection keyframes", () => { it("retains a valid selection and nested track from one persisted snapshot", () => { const merged = mergeToolcraftInitialState(persistedState()); const state = createToolcraftState(createSchema(), merged); expect(state.values["storyboard.selectedNote"]).toBe(0); expect(state.timeline.keyframeGroups).toHaveLength(1); }); it("keeps valid explicit selection and explicit nested tracks with a parent replacement", () => { const explicit = persistedState(); const state = createToolcraftState( createSchema(), mergeToolcraftInitialState(persistedState(), explicit), ); expect(state.values["storyboard.selectedNote"]).toBe(0); expect(state.timeline.keyframeGroups).toHaveLength(1); }); it("rejects malformed reserved groups while retaining ordinary top-level groups", () => { const valid = persistedState(); const ordinary = { controlId: "motion.progress", keyframes: [ { controlId: "motion.progress", controlLabel: "Progress", id: "progress-0", timeSeconds: 0, value: 0, valueLabel: "0", }, ], label: "Progress", }; const malformedReserved = { ...ordinary, controlId: `${toolcraftCollectionItemControlAddressPrefix}malformed`, keyframes: ordinary.keyframes.map((keyframe) => ({ ...keyframe, controlId: `${toolcraftCollectionItemControlAddressPrefix}malformed`, id: "malformed-0", })), label: "Malformed", }; const state = createToolcraftState(createSchema(), { ...valid, timeline: { ...valid.timeline, keyframeGroups: [ ...valid.timeline!.keyframeGroups!, malformedReserved, ordinary, ], }, }); expect(state.timeline.keyframeGroups.map((group) => group.controlId)).toEqual([ valid.timeline!.keyframeGroups![0]!.controlId, "motion.progress", ]); }); it("always clears a selected keyframe pruned inside an otherwise retained group", () => { const valid = persistedState(); const [group] = valid.timeline!.keyframeGroups!; const state = createToolcraftState(createSchema(), { ...valid, timeline: { ...valid.timeline, keyframeGroups: [ { ...group!, keyframes: [ ...group!.keyframes, { ...group!.keyframes[0]!, id: "invalid-selected", value: 500, }, ], }, ], selectedKeyframeId: "invalid-selected", }, }); expect(state.timeline.keyframeGroups[0]?.keyframes.map(({ id }) => id)).toEqual([ "opacity-0", ]); expect(state.timeline.selectedKeyframeId).toBeNull(); }); });