import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { createState } from "./reducer-test-utils"; import { createToolcraftExternalStore } from "./toolcraft-external-store"; import type { ToolcraftCommand } from "./types"; const resetCommands: Array<{ command: ToolcraftCommand; label: string }> = [ { command: { type: "controls.reset" }, label: "global reset" }, { command: { targets: ["media.source"], type: "controls.resetTargets", }, label: "target reset", }, ]; const playbackStopCommands: Array<{ command: ToolcraftCommand; label: string; }> = [ { command: { isPlaying: false, type: "timeline.setPlaying" }, label: "setPlaying(false)", }, { command: { type: "timeline.togglePlayback" }, label: "togglePlayback" }, ]; const playbackStartCommands: Array<{ command: ToolcraftCommand; label: string; }> = [ { command: { isPlaying: true, type: "timeline.setPlaying" }, label: "setPlaying(true)", }, { command: { type: "timeline.togglePlayback" }, label: "togglePlayback" }, ]; function createIntrinsicMediaState() { const schema = defineToolcraft({ canvas: { enabled: true, sizing: { mode: "intrinsic-media" }, upload: true, }, panels: { controls: { sections: [ { controls: { source: { defaultValue: null, target: "media.source", type: "fileDrop", }, }, title: "Source", }, ], title: "Controls", }, timeline: { mode: "playback" }, }, }); return createToolcraftState(schema, { 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 }, sourceTarget: "media.source", }, ], timeline: { currentTimeSeconds: 0, isPlaying: true, }, }); } function applyConcurrentOverlays( store: ReturnType, ): void { store.dispatchTransient({ currentTimeSeconds: 3, type: "timeline.setCurrentTime", }); store.dispatchTransient({ offset: { x: 24, y: -12 }, type: "canvas.setViewport", zoom: 150, }); } describe("createToolcraftExternalStore cross-lane semantics", () => { it.each(playbackStopCommands)( "materializes visible playback when $label semantically stops playback", ({ command }) => { const keyframeGroups = [ { controlId: "selectedLayer.opacity", keyframes: [ { controlId: "selectedLayer.opacity", controlLabel: "Opacity", id: "selectedLayer.opacity::1", timeSeconds: 1, value: 50, valueLabel: "50", }, ], label: "Opacity", }, ]; const store = createToolcraftExternalStore( createState({ timeline: { durationSeconds: 12, isLooping: true, isPlaying: true, keyframeGroups, }, }), ); applyConcurrentOverlays(store); store.dispatch(command); expect(store.getCommittedState().timeline).toMatchObject({ currentTimeSeconds: 3, durationSeconds: 12, isLooping: true, isPlaying: false, keyframeGroups, }); expect(store.getState().timeline).toEqual( store.getCommittedState().timeline, ); expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 0, y: 0 }, zoom: 100, }); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); }, ); it.each(playbackStartCommands)( "$label does not commit playback or viewport overlays when starting playback", ({ command }) => { const store = createToolcraftExternalStore( createState({ timeline: { isPlaying: false } }), ); applyConcurrentOverlays(store); store.dispatch(command); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0, isPlaying: true }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3, isPlaying: true }, }); }, ); it.each(resetCommands)("clears stale playback when $label removes the last intrinsic media", ({ command }) => { const store = createToolcraftExternalStore(createIntrinsicMediaState()); applyConcurrentOverlays(store); store.dispatch(command); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, mediaAssets: [], timeline: { currentTimeSeconds: 0, isPlaying: false }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 0, isPlaying: false }, }); }); it("preserves playback through media import and transform, then clears it on last-media delete", () => { const initialState = createIntrinsicMediaState(); const emptyState = { ...initialState, layers: [], mediaAssets: [], selectedLayerId: null, timeline: { ...initialState.timeline, currentTimeSeconds: 0, isPlaying: false, }, }; const store = createToolcraftExternalStore(emptyState); applyConcurrentOverlays(store); store.dispatch({ asset: { dataUrl: "data:image/png;base64,BBBB", fileName: "imported.png", mimeType: "image/png", position: { x: 0, y: 0 }, size: { height: 480, unit: "px", width: 640 }, sourceTarget: "media.source", }, type: "media.import", }); const mediaId = store.getCommittedState().mediaAssets[0]?.id; expect(mediaId).toBeTruthy(); expect(store.getCommittedState().mediaAssets).toHaveLength(1); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); store.dispatch({ mediaId: mediaId as string, operation: "rotate-right", type: "media.transform", }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); const transformedMedia = store.getCommittedState().mediaAssets[0]; expect( transformedMedia?.assetKind === "image" ? transformedMedia.transform?.rotationDeg : undefined, ).toBe(90); store.dispatch({ mediaId: mediaId as string, type: "media.delete" }); expect(store.getCommittedState().mediaAssets).toEqual([]); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); }); it("keeps both overlays uncommitted through unrelated history undo and redo", () => { const store = createToolcraftExternalStore(createState()); store.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 40, }); applyConcurrentOverlays(store); store.dispatch({ type: "history.undo" }); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 75 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); store.dispatch({ type: "history.redo" }); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 40 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); }); it("uses transient time for keyframes without committing playback or viewport", () => { const initialState = createState({ timeline: { keyframeGroups: [ { controlId: "selectedLayer.opacity", keyframes: [ { controlId: "selectedLayer.opacity", controlLabel: "Opacity", id: "selectedLayer.opacity::0", timeSeconds: 0, value: 75, valueLabel: "75", }, ], label: "Opacity", }, ], }, }); const store = createToolcraftExternalStore(initialState); applyConcurrentOverlays(store); store.dispatch({ keyframeId: "selectedLayer.opacity::0", type: "timeline.selectKeyframe", }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); store.dispatch({ controlId: "selectedLayer.opacity", controlLabel: "Opacity", type: "timeline.upsertControlKeyframe", value: 40, valueLabel: "40", }); const keyframeIds = store .getCommittedState() .timeline.keyframeGroups.flatMap((group) => group.keyframes.map((keyframe) => keyframe.id), ); const historyPatch = store.getCommittedState().history.undo.at(-1); expect(keyframeIds).toContain("selectedLayer.opacity::3"); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); expect(historyPatch?.before.timeline).toMatchObject({ currentTimeSeconds: 0 }); expect(historyPatch?.after.timeline).toMatchObject({ currentTimeSeconds: 0 }); expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 0, y: 0 }, zoom: 100, }); store.dispatch({ type: "history.undo" }); expect( store.getCommittedState().timeline.keyframeGroups[0]?.keyframes, ).toHaveLength(1); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); store.dispatch({ type: "history.redo" }); expect( store .getCommittedState() .timeline.keyframeGroups[0]?.keyframes.map((keyframe) => keyframe.id), ).toContain("selectedLayer.opacity::3"); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); store.dispatch({ durationSeconds: 2, type: "timeline.setDuration" }); expect(store.getCommittedState().timeline).toMatchObject({ currentTimeSeconds: 2, durationSeconds: 2, }); expect(store.getState().timeline.currentTimeSeconds).toBe(2); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); }); it("preserves untouched hot fields while committing durable canvas work", () => { const store = createToolcraftExternalStore(createState()); applyConcurrentOverlays(store); store.dispatch({ size: { height: 480, unit: "px", width: 640 }, type: "canvas.setSize", }); expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 0, y: 0 }, size: { height: 480, unit: "px", width: 640 }, zoom: 100, }); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); store.dispatch({ type: "canvas.zoomIn" }); expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 0, y: 0 }, zoom: 160, }); expect(store.getState().canvas.offset).toEqual({ x: 24, y: -12 }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); }); it("public hot dispatch commits only its lane and preserves the concurrent lane", () => { const store = createToolcraftExternalStore(createState()); applyConcurrentOverlays(store); store.dispatch({ currentTimeSeconds: 5, type: "timeline.setCurrentTime", }); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 5 }, }); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); store.dispatch({ offset: { x: 30, y: 15 }, type: "canvas.setOffset", }); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 30, y: 15 }, zoom: 100 }, timeline: { currentTimeSeconds: 5 }, }); expect(store.getState().canvas).toMatchObject({ offset: { x: 30, y: 15 }, zoom: 150, }); }); it("leaves both overlays uncommitted for an unrelated control transition", () => { const store = createToolcraftExternalStore(createState()); applyConcurrentOverlays(store); store.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 55, }); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 55 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); }); });