// @vitest-environment happy-dom import React, { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; import { mountReactHarness } from "./domSelectionTestHarness"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 }); }); const selection = { id: "box", selector: "#box" } as DomEditSelection; const keyframedAnim = { id: "#box-to-position", targetSelector: "#box", propertyGroup: "position", method: "to", properties: {}, resolvedStart: 0, duration: 2, keyframes: { keyframes: [ { percentage: 0, properties: { x: 0, y: 0 } }, { percentage: 100, properties: { x: 100, y: 0 } }, ], }, } as unknown as GsapAnimation; type Commit = ( selection: DomEditSelection, props: Record, ) => Promise; /** Renders the hook and hands its commit function to the caller via a ref callback. */ function renderHookWith( animations: GsapAnimation[], onMutation: (mutation: Record, label: string) => unknown | Promise, onReady: (commit: Commit) => void, bumpGsapCache = vi.fn(), onBatch?: ( calls: Array<{ mutation: Record; options: { label: string } }>, label: string, ) => unknown | Promise, ) { function Harness() { const gsapCommitMutation = Object.assign( async ( _sel: DomEditSelection, mutation: Record, options: { label: string }, ) => { await onMutation(mutation, options.label); }, onBatch ? { batch: async ( calls: Array<{ selection: DomEditSelection; mutation: Record; options: { label: string }; }>, options: { label: string }, ) => { await onBatch(calls, options.label); }, } : {}, ); const { commitAnimatedProperties } = useAnimatedPropertyCommit({ selectedGsapAnimations: animations, gsapCommitMutation, addGsapAnimation: vi.fn(), convertToKeyframes: vi.fn(), previewIframeRef: { current: null }, bumpGsapCache, }); onReady(commitAnimatedProperties); return null; } return mountReactHarness(); } describe("useAnimatedPropertyCommit — ownership and rejection propagation", () => { it("rejects a helper-authored property before sending a mutation", async () => { const helperRotation = { id: "#box-to-rotation", targetSelector: "#box", propertyGroup: "rotation", method: "to", properties: { rotationX: 10 }, provenance: { kind: "helper", fn: "spin", callSite: 1 }, } as unknown as GsapAnimation; const mutations: Array> = []; let commit!: Commit; const root = renderHookWith( [helperRotation], (mutation) => mutations.push(mutation), (ready) => (commit = ready), ); await expect(commit(selection, { rotationX: 12 })).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "unroll-required", }); expect(mutations).toHaveLength(0); act(() => root.unmount()); }); it("rejects runtime-computed property ownership before sending a mutation", async () => { const runtimePosition = { ...keyframedAnim, hasUnresolvedKeyframes: true, } as GsapAnimation; const mutations: Array> = []; let commit!: Commit; const root = renderHookWith( [runtimePosition], (mutation) => mutations.push(mutation), (ready) => (commit = ready), ); await expect(commit(selection, { x: 50 })).rejects.toMatchObject({ reason: "source-uneditable", }); expect(mutations).toHaveLength(0); act(() => root.unmount()); }); it("rejects every property before a mixed-group commit can partially persist", async () => { const helperOpacity = { id: "#box-to-visual", targetSelector: "#box", propertyGroup: "visual", method: "to", properties: { opacity: 0.5 }, provenance: { kind: "helper", fn: "fade", callSite: 1 }, } as unknown as GsapAnimation; const mutations: Array> = []; let commit!: Commit; const root = renderHookWith( [helperOpacity], (mutation) => mutations.push(mutation), (ready) => (commit = ready), ); await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({ reason: "unroll-required", }); expect(mutations).toHaveLength(0); act(() => root.unmount()); }); it("rethrows a persistence failure to the telemetry wrapper", async () => { const failure = new Error("save failed"); const bumpGsapCache = vi.fn(); let commit!: Commit; const root = renderHookWith( [keyframedAnim], async () => { throw failure; }, (ready) => (commit = ready), bumpGsapCache, ); await expect(commit(selection, { x: 50 })).rejects.toBe(failure); expect(bumpGsapCache).toHaveBeenCalledTimes(1); act(() => root.unmount()); }); }); function renderCommitHook( mutations: Array>, onReady: (commit: Commit) => void, ) { return renderHookWith( [keyframedAnim], (mutation) => { mutations.push(mutation); }, onReady, ); } // Regression (#1808): a "3D transform" / design-panel property edit on an // element that already has a keyframed tween is the ACTUAL path a manual // canvas nudge exercises (not the raw drag intercept) — with auto-keyframe // off, it must shift the whole tween instead of adding/updating a keyframe // at the playhead. describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => { async function runCommitWithAutoKeyframe(enabled: boolean) { usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 }); const mutations: Array> = []; let commit: Commit | undefined; const root = renderCommitHook(mutations, (fn) => (commit = fn)); await act(async () => commit!(selection, { x: 50 })); act(() => root.unmount()); return mutations; } it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => { const mutations = await runCommitWithAutoKeyframe(false); expect(mutations).toHaveLength(1); expect(mutations[0]!.type).toBe("replace-with-keyframes"); }); it("still updates a keyframe at the playhead when the toggle is on (default)", async () => { const mutations = await runCommitWithAutoKeyframe(true); expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe( true, ); expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false); }); }); // Regression: commitStaticSet picked the FIRST `set` for the selector with no // group check — a panel W edit on a static element merged `width` into the // POSITION set (`tl.set("#el",{x,y,width})`), a mixed-group set the split // machinery exists to prevent, labeled "Set 3D transform" in undo history. describe("commitStaticSet group routing", () => { const positionSet = { id: "#box-set-0-position", targetSelector: "#box", propertyGroup: "position", method: "set", properties: { x: 10, y: 20 }, } as unknown as GsapAnimation; function renderStaticHook( committed: Array<{ mutation: Record; label: string }>, onReady: (commit: Commit) => void, ) { return renderHookWith( [positionSet], (mutation, label) => { committed.push({ mutation, label }); }, onReady, ); } it("width edit creates a size set instead of contaminating the position set", async () => { const committed: Array<{ mutation: Record; label: string }> = []; let commit!: Commit; renderStaticHook(committed, (c) => (commit = c)); await act(async () => { await commit(selection, { width: 500 }); }); const updates = committed.filter((c) => c.mutation.type === "update-properties"); expect(updates).toHaveLength(0); const adds = committed.filter((c) => c.mutation.type === "add"); expect(adds).toHaveLength(1); expect(adds[0]!.mutation.properties).toEqual({ width: 500 }); expect(adds[0]!.label).toBe("Resize layer"); }); it("x edit updates the position set with a Move label", async () => { const committed: Array<{ mutation: Record; label: string }> = []; let commit!: Commit; renderStaticHook(committed, (c) => (commit = c)); await act(async () => { await commit(selection, { x: 400 }); }); const update = committed.find((c) => c.mutation.type === "update-properties"); expect(update).toBeDefined(); expect(update!.mutation.animationId).toBe("#box-set-0-position"); expect(update!.label).toBe("Move layer"); }); it("width edit updates its duration-zero size hold instead of appending a set", async () => { const instantSizeHold = { id: "#box-to-0-size", targetSelector: "#box", propertyGroup: "size", method: "to", properties: { width: 150, height: 150 }, resolvedStart: 0, duration: 0, extras: { immediateRender: "__raw:true" }, } as unknown as GsapAnimation; const committed: Array<{ mutation: Record; label: string }> = []; let commit!: Commit; renderHookWith( [positionSet, instantSizeHold], (mutation, label) => { committed.push({ mutation, label }); }, (c) => (commit = c), ); await act(async () => { await commit(selection, { width: 344 }); }); expect(committed).toHaveLength(1); expect(committed[0]!.mutation).toEqual({ type: "update-properties", animationId: instantSizeHold.id, properties: { width: 344 }, }); expect(committed[0]!.label).toBe("Resize layer"); expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false); expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id); }); it("persists multiple property groups in one atomic batch", async () => { const committed: Array<{ mutation: Record; label: string }> = []; const batches: Array<{ calls: Array<{ mutation: Record; options: { label: string } }>; label: string; }> = []; let commit!: Commit; const root = renderHookWith( [positionSet], (mutation, label) => committed.push({ mutation, label }), (ready) => (commit = ready), vi.fn(), (calls, label) => batches.push({ calls, label }), ); await act(async () => { await commit(selection, { x: 400, width: 500 }); }); expect(committed).toHaveLength(0); expect(batches).toHaveLength(1); expect(batches[0]!.label).toBe("Set properties"); expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([ { type: "update-properties", animationId: positionSet.id, properties: { x: 400 }, }, { type: "add", targetSelector: "#box", method: "set", position: 0, properties: { width: 500 }, global: true, }, ]); act(() => root.unmount()); }); it("fails before sending anything when an atomic multi-group batch is unavailable", async () => { const committed: Array<{ mutation: Record; label: string }> = []; let commit!: Commit; const root = renderStaticHook(committed, (ready) => (commit = ready)); await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow( "Atomic GSAP property batch is unavailable", ); expect(committed).toHaveLength(0); act(() => root.unmount()); }); });