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 { getToolcraftHistoryPatchDomains, isToolcraftControlsResetHistoryPatch, } from "./history-patch-metadata"; import { createState } from "./reducer-test-utils"; import { createToolcraftExternalStore } from "../composition/public-state"; function expectViewportOverlay( store: ReturnType, ): void { expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 0, y: 0 }, zoom: 100, }); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); } function createIntrinsicMediaStateAt(timeSeconds: number) { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true, sizing: { mode: "intrinsic-media" }, upload: true, }, panels: { controls: { sections: [ { id: "test-section-1", controls: { source: { applicability: { mode: "always" as const }, defaultValue: null, target: "media.source", type: "fileDrop", }, }, title: "Source", }, ], title: "Controls", }, }, }, modules: [ mediaSourceModule(), timelineModule({ mode: "playback", }), ], }); return createToolcraftState(schema, { 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 }, sourceTarget: "media.source", }, ], timeline: { currentTimeSeconds: timeSeconds, isPlaying: false, }, }); } describe("Toolcraft external-store history boundary", () => { it.each(["controls.reset", "controls.resetTargets"] as const)( "keeps %s product timeline values separate from committed and effective playback", (resetType) => { const initial = createState(); const defaultTimelineValue = { currentTimeSeconds: 50 }; const liveTimelineValue = { currentTimeSeconds: 75 }; const store = createToolcraftExternalStore({ ...initial, defaults: { ...initial.defaults, timeline: defaultTimelineValue, layers: 50, }, values: { ...initial.values, timeline: liveTimelineValue, layers: 75 }, }); store.dispatchTransient({ type: "timeline.setCurrentTime", currentTimeSeconds: 3, }); store.dispatchTransient({ type: "canvas.setViewport", offset: { x: 24, y: -12 }, zoom: 150, }); for (const [type, value] of [ ["controls.reset", defaultTimelineValue], ["history.undo", liveTimelineValue], ["history.redo", defaultTimelineValue], ] as const) { store.dispatch( type === "controls.reset" && resetType === "controls.resetTargets" ? { type: resetType, targets: ["timeline", "layers"] } : { type }, ); expect(store.getCommittedState().values.timeline).toBe(value); expect(store.getCommittedState().values.layers).toBe( value.currentTimeSeconds, ); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); expect(store.hasTransient("playback")).toBe(true); expectViewportOverlay(store); } }, ); it.each(["controls.reset", "controls.resetTargets"] as const)( "normalizes %s private structural history before Undo/Redo across transient lanes", (resetType) => { const initial = createIntrinsicMediaStateAt(3); const store = createToolcraftExternalStore({ ...initial, defaults: { ...initial.defaults, timeline: 50, layers: 50 }, values: { ...initial.values, timeline: 75, layers: 75 }, }); store.dispatchTransient({ type: "timeline.setCurrentTime", currentTimeSeconds: 0, }); store.dispatchTransient({ type: "canvas.setViewport", offset: { x: 24, y: -12 }, zoom: 150, }); store.dispatch( resetType === "controls.resetTargets" ? { type: resetType, targets: ["timeline", "layers", "media.source"] } : { type: resetType }, ); const patch = store.getCommittedState().history.undo.at(-1)!; const data = getToolcraftHistoryPatchDomains(patch)!; expect(data.state.before.timeline).toMatchObject({ currentTimeSeconds: 3, }); expect(data.state.after.timeline).toMatchObject({ currentTimeSeconds: 0, }); expect(data.values.before).toMatchObject({ timeline: 75, layers: 75 }); expect(data.values.after).toMatchObject({ timeline: 50, layers: 50 }); expectViewportOverlay(store); store.dispatch({ type: "history.undo" }); expect(store.getCommittedState().values).toMatchObject({ timeline: 75, layers: 75, }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(3); expect(store.getState().timeline.currentTimeSeconds).toBe(3); expect(store.getCommittedState().mediaAssets).toEqual( initial.mediaAssets, ); expect(store.getCommittedState().history.redo.at(-1)).toBe(patch); expectViewportOverlay(store); store.dispatch({ type: "history.redo" }); expect(store.getCommittedState().values).toMatchObject({ timeline: 50, layers: 50, }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(0); expect(store.getCommittedState().mediaAssets).toEqual([]); expect(store.getCommittedState().history.undo.at(-1)).toBe(patch); expectViewportOverlay(store); }, ); it("preserves reset provenance while merging durable and effective lanes", () => { const store = createToolcraftExternalStore(createState()); store.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 12, }); store.dispatchTransient({ currentTimeSeconds: 3, type: "timeline.setCurrentTime", }); store.dispatch({ type: "controls.reset" }); const patch = store.getCommittedState().history.undo.at(-1); expect(isToolcraftControlsResetHistoryPatch(patch)).toBe(true); store.dispatch({ type: "history.undo" }); expect(store.getCommittedState().history.redo.at(-1)).toBe(patch); store.dispatch({ type: "history.redo" }); expect(store.getCommittedState().history.undo.at(-1)).toBe(patch); }); it("treats canvas-shaped product targets as values across undo and redo", () => { const baseState = createState(); const defaults = { canvas: { offset: { x: 1, y: 2 }, zoom: 10 }, "canvas.offset": { x: 3, y: 4 }, "canvas.zoom": 20, }; const changed = { canvas: { offset: { x: 11, y: 12 }, zoom: 110 }, "canvas.offset": { x: 13, y: 14 }, "canvas.zoom": 120, }; const store = createToolcraftExternalStore({ ...baseState, defaults: { ...baseState.defaults, ...defaults }, values: { ...baseState.values, ...defaults }, }); for (const [target, value] of Object.entries(changed)) { store.dispatch({ target, type: "controls.setValue", value }); } store.dispatchTransient({ offset: { x: 24, y: -12 }, type: "canvas.setViewport", zoom: 150, }); for (let index = 0; index < 3; index += 1) { store.dispatch({ type: "history.undo" }); expectViewportOverlay(store); } expect(store.getCommittedState().values).toMatchObject(defaults); for (let index = 0; index < 3; index += 1) { store.dispatch({ type: "history.redo" }); expectViewportOverlay(store); } expect(store.getCommittedState().values).toMatchObject(changed); }); it("retains a committed-only timeline patch when deleting the last media", () => { const store = createToolcraftExternalStore(createIntrinsicMediaStateAt(3)); store.dispatchTransient({ currentTimeSeconds: 0, type: "timeline.setCurrentTime", }); store.dispatchTransient({ offset: { x: 24, y: -12 }, type: "canvas.setViewport", zoom: 150, }); store.dispatch({ mediaId: "media-1", type: "media.delete" }); expect(store.getCommittedState()).toMatchObject({ mediaAssets: [], timeline: { currentTimeSeconds: 0 }, }); expect(store.getCommittedState().history.undo.at(-1)).toMatchObject({ after: { timeline: { currentTimeSeconds: 0 } }, before: { timeline: { currentTimeSeconds: 3 } }, }); expectViewportOverlay(store); store.dispatch({ type: "history.undo" }); expect(store.getCommittedState()).toMatchObject({ mediaAssets: [{ id: "media-1" }], timeline: { currentTimeSeconds: 3 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); store.dispatch({ type: "history.redo" }); expect(store.getCommittedState()).toMatchObject({ mediaAssets: [], timeline: { currentTimeSeconds: 0 }, }); expect(store.getState().canvas).toMatchObject({ offset: { x: 24, y: -12 }, zoom: 150, }); }); it("preserves untouched entries while merging a grouped value patch", () => { const store = createToolcraftExternalStore(createState()); store.dispatch({ durationSeconds: 12, type: "timeline.setDuration" }); const untouchedTimelinePatch = store.getCommittedState().history.undo[0]; store.dispatch({ history: "merge", historyGroup: "opacity-drag", target: "selectedLayer.opacity", type: "controls.setValue", value: 60, }); store.dispatchTransient({ currentTimeSeconds: 3, type: "timeline.setCurrentTime", }); store.dispatchTransient({ offset: { x: 24, y: -12 }, type: "canvas.setViewport", zoom: 150, }); store.dispatch({ history: "merge", historyGroup: "opacity-drag", target: "selectedLayer.opacity", type: "controls.setValue", value: 45, }); const history = store.getCommittedState().history; expect(history.undo).toHaveLength(2); expect(history.undo[0]).toBe(untouchedTimelinePatch); expect(history.undo[1]).toMatchObject({ after: { "selectedLayer.opacity": 45 }, before: { "selectedLayer.opacity": 75 }, group: "opacity-drag", }); store.dispatch({ type: "history.undo" }); expect(store.getCommittedState().values["selectedLayer.opacity"]).toBe(75); expect(store.getCommittedState().history.redo.at(-1)).toBe(history.undo[1]); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); store.dispatch({ type: "history.redo" }); expect(store.getCommittedState().values["selectedLayer.opacity"]).toBe(45); expect(store.getCommittedState().history.undo.at(-1)).toBe(history.undo[1]); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 24, y: -12 }, zoom: 150 }, timeline: { currentTimeSeconds: 3 }, }); }); });