import { describe, expect, it } from "vitest"; import { timelineModule } from "../modules/built-ins/timeline/timeline-module"; import { mediaSourceModule } from "../modules/built-ins/media-source/media-source-module"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { toolcraftReducer } from "../composition/public-state"; import { createState } from "./reducer-test-utils"; describe("toolcraftReducer timeline", () => { it("canonicalizes seeded and live Color keyframes", () => { const app = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: false }, panels: { controls: { sections: [ { id: "test-section-1", controls: { fill: { applicability: { mode: "always" as const }, defaultValue: "#112233", target: "shape.fill", type: "color", }, }, title: "Appearance", }, ], title: "Controls", }, }, }, modules: [ timelineModule({ mode: "keyframes", }), ], }); const seeded = createToolcraftState(app, { timeline: { keyframeGroups: [ { controlId: "shape.fill", keyframes: [ { controlId: "shape.fill", controlLabel: "Fill", id: "shape.fill::0", timeSeconds: 0, value: "#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: "#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({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true, sizing: { mode: "intrinsic-media" }, upload: true, }, panels: {}, }, modules: [mediaSourceModule(), timelineModule({ mode: "playback" })], }); const state = createToolcraftState(app, { mediaAssets: [ { sourceSize: { height: 320, unit: "px", width: 512 }, 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 }, 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); }); });