import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { toolcraftReducer } from "./reducer"; import { createState } from "./reducer-test-utils"; import { createToolcraftExternalStore } from "./toolcraft-external-store"; import type { ToolcraftCommand } from "./types"; type TransientCommand = Extract< ToolcraftCommand, { type: | "canvas.setOffset" | "canvas.setViewport" | "timeline.setCurrentTime"; } >; describe("createToolcraftExternalStore", () => { it("keeps the transient dispatcher limited to the three hot commands", () => { const store = createToolcraftExternalStore(createState()); expectTypeOf[0]>().toEqualTypeOf< TransientCommand >(); }); it("matches direct reducer transitions for representative durable commands", () => { const initialState = createState(); const store = createToolcraftExternalStore(initialState); const commands: ToolcraftCommand[] = [ { target: "selectedLayer.opacity", type: "controls.setValue", value: 40, }, { durationSeconds: 12, type: "timeline.setDuration" }, { controlId: "selectedLayer.opacity", controlLabel: "Opacity", timeSeconds: 2, type: "timeline.upsertControlKeyframe", value: 40, valueLabel: "40", }, { size: { height: 900, unit: "px", width: 1600 }, type: "canvas.setSize", }, { asset: { dataUrl: "data:image/png;base64,AA==", fileName: "reference.png", id: "media-reference", layerId: "layer-reference", mimeType: "image/png", position: { x: 12, y: 24 }, }, type: "media.import", }, { layer: { id: "layer-added", name: "Added layer", visible: true, }, type: "layers.add", }, { type: "history.undo" }, { type: "controls.reset" }, ]; let expectedState = initialState; for (const command of commands) { expectedState = toolcraftReducer(expectedState, command); store.dispatch(command); expect(store.getCommittedState()).toEqual(expectedState); } }); it("caches the effective snapshot and ignores repeated transient values", () => { const initialState = createState(); const store = createToolcraftExternalStore(initialState); const listener = vi.fn(); store.subscribe(listener); expect(store.getState()).toBe(initialState); expect(store.getState()).toBe(store.getState()); store.dispatchTransient({ currentTimeSeconds: 2, type: "timeline.setCurrentTime", }); const transientSnapshot = store.getState(); expect(transientSnapshot).not.toBe(initialState); expect(transientSnapshot.timeline.currentTimeSeconds).toBe(2); expect(store.getCommittedState()).toBe(initialState); expect(store.getState()).toBe(transientSnapshot); expect(listener).toHaveBeenCalledTimes(1); store.dispatchTransient({ currentTimeSeconds: 2, type: "timeline.setCurrentTime", }); expect(store.getState()).toBe(transientSnapshot); expect(listener).toHaveBeenCalledTimes(1); }); it("notifies playback, viewport, and durable selectors independently", () => { const store = createToolcraftExternalStore(createState()); const fullListener = vi.fn(); const playbackListener = vi.fn(); const offsetListener = vi.fn(); const zoomListener = vi.fn(); const valueListener = vi.fn(); store.subscribe(fullListener); store.subscribeSelector( (state) => state.timeline.currentTimeSeconds, playbackListener, ); store.subscribeSelector( (state) => state.canvas.offset, offsetListener, (previous, next) => previous.x === next.x && previous.y === next.y, ); store.subscribeSelector((state) => state.canvas.zoom, zoomListener); store.subscribeSelector( (state) => state.values["selectedLayer.opacity"], valueListener, ); store.dispatchTransient({ currentTimeSeconds: 1.5, type: "timeline.setCurrentTime", }); expect(playbackListener).toHaveBeenCalledTimes(1); expect(offsetListener).not.toHaveBeenCalled(); expect(zoomListener).not.toHaveBeenCalled(); expect(valueListener).not.toHaveBeenCalled(); store.dispatchTransient({ offset: { x: 20, y: -10 }, type: "canvas.setOffset", }); expect(playbackListener).toHaveBeenCalledTimes(1); expect(offsetListener).toHaveBeenCalledTimes(1); expect(zoomListener).not.toHaveBeenCalled(); store.dispatchTransient({ offset: { x: 20, y: -10 }, type: "canvas.setViewport", zoom: 150, }); expect(offsetListener).toHaveBeenCalledTimes(1); expect(zoomListener).toHaveBeenCalledTimes(1); store.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 55, }); expect(playbackListener).toHaveBeenCalledTimes(1); expect(offsetListener).toHaveBeenCalledTimes(1); expect(zoomListener).toHaveBeenCalledTimes(1); expect(valueListener).toHaveBeenCalledTimes(1); expect(fullListener).toHaveBeenCalledTimes(4); }); it("cleans up full and selector subscriptions", () => { const store = createToolcraftExternalStore(createState()); const fullListener = vi.fn(); const selectorListener = vi.fn(); const unsubscribeFull = store.subscribe(fullListener); const unsubscribeSelector = store.subscribeSelector( (state) => state.timeline.currentTimeSeconds, selectorListener, ); unsubscribeFull(); unsubscribeFull(); unsubscribeSelector(); unsubscribeSelector(); store.dispatchTransient({ currentTimeSeconds: 1, type: "timeline.setCurrentTime", }); expect(fullListener).not.toHaveBeenCalled(); expect(selectorListener).not.toHaveBeenCalled(); }); it("commits one transient lane without capturing the concurrent lane", () => { const initialState = createState(); const store = createToolcraftExternalStore(initialState); store.dispatchTransient({ currentTimeSeconds: 3, type: "timeline.setCurrentTime", }); store.dispatchTransient({ offset: { x: 32, y: -16 }, type: "canvas.setViewport", zoom: 140, }); const effectiveBeforeCommit = store.getState(); store.commitTransient("playback"); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(3); expect(store.getCommittedState().canvas).toBe(initialState.canvas); expect(store.getState()).toBe(effectiveBeforeCommit); expect(store.getState().canvas).toMatchObject({ offset: { x: 32, y: -16 }, zoom: 140, }); store.commitTransient("viewport"); expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 32, y: -16 }, zoom: 140, }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(3); expect(store.getState()).toBe(effectiveBeforeCommit); }); it("commits both transient lanes when no lane is specified", () => { const store = createToolcraftExternalStore(createState()); store.dispatchTransient({ currentTimeSeconds: 2, type: "timeline.setCurrentTime", }); store.dispatchTransient({ offset: { x: -8, y: 14 }, type: "canvas.setViewport", zoom: 125, }); store.commitTransient(); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: -8, y: 14 }, zoom: 125 }, timeline: { currentTimeSeconds: 2 }, }); }); it("preserves unrelated overlays and commits only hot lanes changed by durable work", () => { const initialState = createState(); const store = createToolcraftExternalStore(initialState); store.dispatchTransient({ currentTimeSeconds: 3, type: "timeline.setCurrentTime", }); store.dispatchTransient({ offset: { x: 18, y: 9 }, type: "canvas.setViewport", zoom: 130, }); store.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 60, }); expect(store.getCommittedState()).toMatchObject({ canvas: initialState.canvas, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 60 }, }); expect(store.getState()).toMatchObject({ canvas: { offset: { x: 18, y: 9 }, zoom: 130 }, timeline: { currentTimeSeconds: 3 }, }); 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: 18, y: 9 }, size: { height: 480, unit: "px", width: 640 }, zoom: 130, }); store.dispatch({ expanded: true, type: "timeline.setExpanded" }); expect(store.getCommittedState()).toMatchObject({ canvas: { offset: { x: 0, y: 0 }, size: { height: 480, unit: "px", width: 640 }, zoom: 100, }, timeline: { currentTimeSeconds: 0, expanded: true }, }); expect(store.getState().timeline.currentTimeSeconds).toBe(3); expect(store.getState().canvas).toMatchObject({ offset: { x: 18, y: 9 }, zoom: 130, }); store.dispatch({ type: "canvas.zoomIn" }); expect(store.getCommittedState().canvas).toMatchObject({ offset: { x: 0, y: 0 }, zoom: 140, }); expect(store.getState().canvas.offset).toEqual({ x: 18, y: 9 }); expect(store.getCommittedState().timeline.currentTimeSeconds).toBe(0); expect(store.getState().timeline.currentTimeSeconds).toBe(3); }); });