import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { toolcraftReducer } from "./reducer"; import { createState } from "./reducer-test-utils"; describe("toolcraftReducer timeline", () => { it("canonicalizes seeded and live Color keyframes", () => { const app = defineToolcraft({ canvas: { enabled: false }, panels: { controls: { sections: [ { controls: { fill: { defaultValue: "#112233", target: "shape.fill", type: "color", }, }, title: "Appearance", }, ], title: "Controls", }, timeline: { mode: "keyframes" }, }, }); const seeded = createToolcraftState(app, { timeline: { keyframeGroups: [ { controlId: "shape.fill", keyframes: [ { controlId: "shape.fill", controlLabel: "Fill", id: "shape.fill::0", timeSeconds: 0, value: { hex: "#223344" }, valueLabel: "#223344", }, ], label: "Fill", }, ], }, }); expect( seeded.timeline.keyframeGroups[0]?.keyframes[0]?.value, ).toBe("#223344"); const changed = toolcraftReducer(seeded, { controlId: "shape.fill", controlLabel: "Fill", timeSeconds: 1, type: "timeline.upsertControlKeyframe", value: { hex: "#334455" }, valueLabel: "#334455", }); expect( changed.timeline.keyframeGroups[0]?.keyframes.at(-1)?.value, ).toBe("#334455"); const invalid = toolcraftReducer(changed, { controlId: "shape.fill", controlLabel: "Fill", timeSeconds: 2, type: "timeline.upsertControlKeyframe", value: { hex: "invalid" }, valueLabel: "invalid", }); expect(invalid).toBe(changed); }); it("updates timeline playback state without recording history", () => { const paused = toolcraftReducer(createState(), { type: "timeline.togglePlayback" }); const loopDisabled = toolcraftReducer(paused, { type: "timeline.toggleLoop" }); const scrubbed = toolcraftReducer(loopDisabled, { currentTimeSeconds: 4.25, type: "timeline.setCurrentTime", }); expect(scrubbed.timeline.isPlaying).toBe(false); expect(scrubbed.timeline.isLooping).toBe(false); expect(scrubbed.timeline.currentTimeSeconds).toBe(4.25); expect(scrubbed.history.undo).toHaveLength(0); }); it("keeps loop state when timeline duration changes", () => { const state = toolcraftReducer( createState({ timeline: { currentTimeSeconds: 9, durationSeconds: 10, isLooping: true, isPlaying: true, }, }), { target: "selectedLayer.opacity", type: "controls.setValue", value: 42, }, ); const resized = toolcraftReducer(state, { durationSeconds: 6, type: "timeline.setDuration", }); expect(resized.timeline.durationSeconds).toBe(6); expect(resized.timeline.currentTimeSeconds).toBe(6); expect(resized.timeline.isPlaying).toBe(true); expect(resized.timeline.isLooping).toBe(true); expect(resized.values).toEqual(state.values); expect(resized.values["selectedLayer.opacity"]).toBe(42); expect(resized.defaults).toEqual(state.defaults); }); it("restarts playback from the beginning when play is pressed at the non-looping end", () => { const paused = toolcraftReducer(createState(), { type: "timeline.togglePlayback" }); const loopDisabled = toolcraftReducer(paused, { type: "timeline.toggleLoop" }); const ended = toolcraftReducer(loopDisabled, { currentTimeSeconds: loopDisabled.timeline.durationSeconds, type: "timeline.setCurrentTime", }); const replaying = toolcraftReducer(ended, { type: "timeline.togglePlayback" }); expect(replaying.timeline.currentTimeSeconds).toBe(0); expect(replaying.timeline.isPlaying).toBe(true); expect(replaying.history.undo).toHaveLength(0); }); it("pauses and resets upload-dependent timeline playback when deleting the last media asset", () => { const app = defineToolcraft({ canvas: { enabled: true, sizing: { mode: "intrinsic-media" }, upload: true, }, panels: { timeline: { mode: "playback" }, }, }); const state = createToolcraftState(app, { 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: 320, unit: "px", width: 512 }, }, ], timeline: { currentTimeSeconds: 3.5, isPlaying: true, }, }); const deleted = toolcraftReducer(state, { mediaId: "media-1", type: "media.delete", }); expect(deleted.mediaAssets).toEqual([]); expect(deleted.timeline.currentTimeSeconds).toBe(0); expect(deleted.timeline.isPlaying).toBe(false); expect(deleted.history.undo.at(-1)?.after).toMatchObject({ mediaAssets: [], timeline: expect.objectContaining({ currentTimeSeconds: 0, isPlaying: false, }), }); }); it("stores timeline expanded state without recording history", () => { const expanded = toolcraftReducer(createState(), { expanded: true, type: "timeline.setExpanded", }); const collapsed = toolcraftReducer(expanded, { type: "timeline.toggleExpanded" }); expect(expanded.timeline.expanded).toBe(true); expect(expanded.history.undo).toHaveLength(0); expect(collapsed.timeline.expanded).toBe(false); expect(collapsed.history.undo).toHaveLength(0); }); it("adds, updates, and removes control-owned timeline keyframes", () => { const baseState = createState(); const emptyTimelineState = { ...baseState, timeline: { ...baseState.timeline, currentTimeSeconds: 2, expanded: false, keyframeGroups: [], selectedKeyframeId: null, }, }; const keyed = toolcraftReducer(emptyTimelineState, { controlId: "selectedLayer.opacity", controlLabel: "Opacity", type: "timeline.toggleControlKeyframes", value: 75, valueLabel: "75%", }); expect(keyed.timeline.expanded).toBe(true); expect(keyed.timeline.selectedKeyframeId).toBe("selectedLayer.opacity::2"); expect(keyed.timeline.keyframeGroups).toEqual([ { controlId: "selectedLayer.opacity", keyframes: [ { controlId: "selectedLayer.opacity", controlLabel: "Opacity", id: "selectedLayer.opacity::2", timeSeconds: 2, value: 75, valueLabel: "75%", }, ], label: "Opacity", }, ]); expect(keyed.history.undo.at(-1)?.label).toBe("Add control keyframe"); const updated = toolcraftReducer(keyed, { controlId: "selectedLayer.opacity", controlLabel: "Opacity", type: "timeline.upsertControlKeyframe", value: 55, valueLabel: "55%", }); expect(updated.timeline.keyframeGroups[0]?.keyframes).toHaveLength(1); expect(updated.timeline.keyframeGroups[0]?.keyframes[0]?.valueLabel).toBe("55%"); expect(updated.timeline.keyframeGroups[0]?.keyframes[0]?.value).toBe(55); expect(updated.history.undo.at(-1)?.label).toBe("Set control keyframe"); const removed = toolcraftReducer(updated, { controlId: "selectedLayer.opacity", controlLabel: "Opacity", type: "timeline.toggleControlKeyframes", value: 55, valueLabel: "55%", }); expect(removed.timeline.keyframeGroups).toEqual([]); expect(removed.timeline.selectedKeyframeId).toBeNull(); expect(removed.history.undo.at(-1)?.label).toBe("Delete control keyframes"); }); it("records timeline keyframe edits in history", () => { const baseState = createState(); const stateWithKeyframe = { ...baseState, timeline: { ...baseState.timeline, keyframeGroups: [ { controlId: "opacity", keyframes: [ { controlId: "opacity", controlLabel: "Opacity", id: "opacity-0.75", timeSeconds: 0.75, valueLabel: "Opacity 20%", }, ], label: "Opacity", }, ], }, }; const moved = toolcraftReducer(stateWithKeyframe, { keyframeId: "opacity-0.75", timeSeconds: 2, type: "timeline.moveKeyframe", }); const undone = toolcraftReducer(moved, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(moved.timeline.selectedKeyframeId).toBe("opacity::2"); expect( moved.timeline.keyframeGroups .flatMap((group) => group.keyframes) .find((keyframe) => keyframe.id === "opacity::2")?.timeSeconds, ).toBe(2); expect(moved.history.undo.at(-1)?.label).toBe("Move keyframe"); expect( undone.timeline.keyframeGroups .flatMap((group) => group.keyframes) .some((keyframe) => keyframe.id === "opacity-0.75"), ).toBe(true); expect( redone.timeline.keyframeGroups .flatMap((group) => group.keyframes) .some((keyframe) => keyframe.id === "opacity::2"), ).toBe(true); }); });