import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { isToolcraftControlsResetHistoryPatch } from "./history-patch-metadata"; import { createState } from "./reducer-test-utils"; import { createToolcraftExternalStore } from "./toolcraft-external-store"; 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({ 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: timeSeconds, isPlaying: false, }, }); } describe("Toolcraft external-store history boundary", () => { 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 }, }); }); });