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 { getToolcraftCollectionItemControlAddress } from "./collection-control-address"; import { createToolcraftState } from "./create-template-state"; import { toolcraftReducer } from "../composition/public-state"; import { importImageMedia } from "./reducer-media-test-utils"; import type { ToolcraftState } from "./types"; function createState( collectionTarget: string, selectionTarget: string, withSourceMedia = false, ) { return createToolcraftState( defineToolcraft({ base: { canvas: { enabled: true, ...(withSourceMedia ? { sizing: { mode: "intrinsic-media" as const }, upload: true } : {}), }, identity: { id: "collection-domains", title: "Collection domains" }, panels: { controls: { sections: [ { id: "notes", controls: { ...(withSourceMedia ? { source: { applicability: { mode: "always" as const }, target: "source", type: "fileDrop" as const, }, } : {}), notes: { applicability: { mode: "always" }, defaultValue: [], itemControls: { label: { defaultValue: "Note", type: "text", }, opacity: { defaultValue: 60, keyframeable: true, max: 100, min: 0, type: "slider", }, }, selectionTarget, target: collectionTarget, type: "collectionActions", }, }, }, ], title: "Notes", }, }, }, modules: [ timelineModule({ mode: "keyframes" }), ...(withSourceMedia ? [mediaSourceModule()] : []), ], }), ); } function expectHistoryRoundTrip(before: ToolcraftState, after: ToolcraftState) { expect(after.history.undo).toHaveLength(before.history.undo.length + 1); const undone = toolcraftReducer(after, { type: "history.undo" }); expect(undone.values).toEqual(before.values); expect(undone.timeline).toEqual(before.timeline); expect(undone.layers).toEqual(before.layers); expect(undone.mediaAssets).toEqual(before.mediaAssets); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(redone.values).toEqual(after.values); expect(redone.timeline).toEqual(after.timeline); expect(redone.layers).toEqual(after.layers); expect(redone.mediaAssets).toEqual(after.mediaAssets); } describe("collection history state-domain separation", () => { it.each(["controls.reset", "controls.resetTargets"] as const)( "%s composes nested-track cleanup with media playback readiness", (type) => { const added = toolcraftReducer(createState("notes", "selection", true), { target: "notes", type: "controls.addCollectionItem", }); const imported = importImageMedia(added, { sourceTarget: "source" }); const seeded = toolcraftReducer(imported, { controlId: getToolcraftCollectionItemControlAddress( "notes", 0, "opacity", ), controlLabel: "Opacity", timeSeconds: 3, type: "timeline.toggleControlKeyframes", value: 60, valueLabel: "60", }); const playing = { ...seeded, timeline: { ...seeded.timeline, currentTimeSeconds: 3, isPlaying: true, }, }; const reset = toolcraftReducer( playing, type === "controls.reset" ? { type } : { type, targets: ["notes", "source"] }, ); expect(reset.mediaAssets).toEqual([]); expect(reset.timeline.keyframeGroups).toEqual([]); expect(reset.timeline.isPlaying).toBe(false); expect(reset.timeline.currentTimeSeconds).toBe(0); expectHistoryRoundTrip(playing, reset); }, ); it("keeps sparse timeline changes in a grouped collection replacement", () => { const added = toolcraftReducer(createState("notes", "selection"), { target: "notes", type: "controls.addCollectionItem", }); const seeded = toolcraftReducer(added, { controlId: getToolcraftCollectionItemControlAddress( "notes", 0, "opacity", ), controlLabel: "Opacity", timeSeconds: 3, type: "timeline.toggleControlKeyframes", value: 60, valueLabel: "60", }); let edited = seeded; for (const opacity of [70, 80]) { edited = toolcraftReducer(edited, { history: "merge", historyGroup: "drag", target: "notes", type: "controls.setValue", value: [{ label: "A", opacity }], }); } expect(edited.timeline.keyframeGroups).toEqual([]); expectHistoryRoundTrip(seeded, edited); }); it.each([ { collectionTarget: "timeline", selectionTarget: "noteSelection" }, { collectionTarget: "notes", selectionTarget: "timeline" }, { collectionTarget: "layers", selectionTarget: "mediaAssets" }, ])( "keeps $collectionTarget values and $selectionTarget selection separate from structural history", ({ collectionTarget, selectionTarget }) => { const initial = createState(collectionTarget, selectionTarget); const first = toolcraftReducer(initial, { target: collectionTarget, type: "controls.addCollectionItem", }); expect(first.values[collectionTarget]).toEqual([ { label: "Note", opacity: 60 }, ]); expect(first.values[selectionTarget]).toBe(0); expect(first.timeline).toEqual(initial.timeline); expectHistoryRoundTrip(initial, first); const second = toolcraftReducer(first, { target: collectionTarget, type: "controls.addCollectionItem", }); const addresses = [0, 1].map((index) => getToolcraftCollectionItemControlAddress( collectionTarget, index, "opacity", ), ); let seeded = second; for (const controlId of [...addresses].reverse()) { const beforeKeyframe = seeded; seeded = toolcraftReducer(seeded, { controlId, controlLabel: "Opacity", timeSeconds: 3, type: "timeline.toggleControlKeyframes", value: 60, valueLabel: "60", }); expect(seeded.values).toEqual(beforeKeyframe.values); expectHistoryRoundTrip(beforeKeyframe, seeded); } const edited = toolcraftReducer(seeded, { fieldId: "opacity", itemIndex: 0, target: collectionTarget, type: "controls.setCollectionItemField", value: 25, }); expect(edited.values[collectionTarget]).toEqual([ { label: "Note", opacity: 25 }, { label: "Note", opacity: 60 }, ]); expect( edited.timeline.keyframeGroups .find(({ controlId }) => controlId === addresses[0]) ?.keyframes.find(({ timeSeconds }) => timeSeconds === 3)?.value, ).toBe(25); expectHistoryRoundTrip(seeded, edited); const removed = toolcraftReducer(edited, { target: collectionTarget, type: "controls.removeCollectionItem", }); expect(removed.values[collectionTarget]).toEqual([ { label: "Note", opacity: 25 }, ]); expect( removed.timeline.keyframeGroups.map(({ controlId }) => controlId), ).toEqual([addresses[0]]); expectHistoryRoundTrip(edited, removed); const replaced = toolcraftReducer(removed, { target: collectionTarget, type: "controls.setValue", value: [{ label: "Replacement", opacity: 80 }], }); expect(replaced.values[collectionTarget]).toEqual([ { label: "Replacement", opacity: 80 }, ]); expect(replaced.timeline.keyframeGroups).toEqual([]); expectHistoryRoundTrip(removed, replaced); const applied = toolcraftReducer(edited, { type: "controls.apply", values: { [collectionTarget]: [{ label: "Applied", opacity: 40 }], [selectionTarget]: 0, }, }); expect(applied.values[collectionTarget]).toEqual([ { label: "Applied", opacity: 40 }, ]); expect(applied.values[selectionTarget]).toBe(0); expect(applied.timeline.keyframeGroups).toEqual([]); expectHistoryRoundTrip(edited, applied); }, ); });