// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AnimationCard } from "./AnimationCard"; import { EASE_PRESETS } from "./easePresetLibrary"; import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn()); vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit })); (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const ANIMATION: GsapAnimation = { id: "position-tween", targetSelector: "#clip-1", method: "to", position: 0, duration: 2, ease: "power1.out", properties: { x: 200 }, keyframes: { format: "percentage", keyframes: [ { percentage: 0, properties: { x: 0 } }, { percentage: 50, properties: { x: 100 } }, { percentage: 100, properties: { x: 200 } }, ], }, }; const FLAT_ANIMATION: GsapAnimation = { ...ANIMATION, id: "flat-position-tween", keyframes: undefined, }; afterEach(() => { document.body.innerHTML = ""; trackStudioSegmentEaseEdit.mockClear(); }); function renderFocusCard( focusedSegment: { tweenPercentage: number; collidingAnimationTargets?: AnimationKeyframeTarget[]; } | null, onEaseCommit = vi.fn(), defaultExpanded = false, animation = ANIMATION, onUpdateMeta = vi.fn(), onUpdateSegmentEase = vi.fn(), ) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); const render = (nextFocusedSegment: { tweenPercentage: number } | null) => { act(() => { root.render( , ); }); }; render(focusedSegment); return { host, root, render }; } function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined { return Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes(text), ); } function openSegment(host: HTMLElement, label: string): void { const segment = findButton(host, label); expect(segment).toBeDefined(); act(() => segment?.click()); } function selectPreset(host: HTMLElement, presetId: string): string { const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId); if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`); const dropdown = host.querySelector("[data-ease-type-dropdown]"); expect(dropdown).not.toBeNull(); act(() => dropdown?.click()); const preset = host.querySelector(`[data-ease-preset-id="${presetId}"]`); expect(preset).not.toBeNull(); act(() => preset?.click()); return presetConfig.ease; } const noop = () => {}; /** Every test mounts the same card; only expansion, flat mode, and the spies differ. */ function renderCard({ animation = baseAnimation(), defaultExpanded = true, flat, onUpdateMeta = vi.fn(), onUpdateKeyframeEase = vi.fn(), onDeleteAnimation = noop, }: { animation?: GsapAnimation; defaultExpanded?: boolean; flat?: boolean; onUpdateMeta?: ReturnType; onUpdateKeyframeEase?: ReturnType; onDeleteAnimation?: (id: string) => void; } = {}) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); return { host, root }; } function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void { if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor); else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); } describe("AnimationCard", () => { it("scrolls a focused segment into view but not a manually toggled segment", () => { const originalScrollIntoView = Object.getOwnPropertyDescriptor( HTMLElement.prototype, "scrollIntoView", ); const scrollIntoView = vi.fn(); Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView, }); const view = renderFocusCard({ tweenPercentage: 50 }); try { expect(scrollIntoView).toHaveBeenCalledOnce(); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); view.render(null); const manualToggle = findButton(view.host, "50% → 100%"); expect(manualToggle).toBeDefined(); act(() => manualToggle?.click()); expect(scrollIntoView).toHaveBeenCalledOnce(); } finally { act(() => view.root.unmount()); restoreScrollIntoView(originalScrollIntoView); } }); it("tracks a committed segment ease alongside the existing update", () => { const onEaseCommit = vi.fn(); const view = renderFocusCard(null, onEaseCommit, true); openSegment(view.host, "0% → 50%"); const ease = selectPreset(view.host, "quad-out"); expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease); expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease }); act(() => view.root.unmount()); }); it("commits a focused multi-id segment ease through the bulk callback", () => { const onUpdateKeyframeEase = vi.fn(); const onUpdateSegmentEase = vi.fn(); const collidingAnimationTargets = [ { animationId: ANIMATION.id, tweenPercentage: 50 }, { animationId: "scale-tween", tweenPercentage: 75 }, { animationId: "opacity-tween", tweenPercentage: 25 }, ]; const view = renderFocusCard( { tweenPercentage: 50, collidingAnimationTargets }, onUpdateKeyframeEase, false, ANIMATION, vi.fn(), onUpdateSegmentEase, ); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, ease); expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); act(() => view.root.unmount()); }); it("keeps a focused single-id segment ease on the single callback", () => { const onUpdateKeyframeEase = vi.fn(); const onUpdateSegmentEase = vi.fn(); const view = renderFocusCard( { tweenPercentage: 50, collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }], }, onUpdateKeyframeEase, false, ANIMATION, vi.fn(), onUpdateSegmentEase, ); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease); expect(onUpdateSegmentEase).not.toHaveBeenCalled(); act(() => view.root.unmount()); }); it("commits a focused flat tween segment ease through tween metadata", () => { const onUpdateMeta = vi.fn(); const onUpdateKeyframeEase = vi.fn(); const view = renderFocusCard( { tweenPercentage: 100 }, onUpdateKeyframeEase, false, FLAT_ANIMATION, onUpdateMeta, ); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease }); expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); act(() => view.root.unmount()); }); }); function baseAnimation(overrides: Partial = {}): GsapAnimation { return { id: "anim-1", method: "to", position: 0.8, duration: 1.2, ease: "power2.out", properties: { opacity: 1 }, ...overrides, } as GsapAnimation; } describe("AnimationCard ease editing", () => { it.each([ ["spring", "power2.out", "spring(0.42)", "Spring bounce"], ["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"], ["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"], ] as const)( "commits and immediately displays the %s default when a keyframe segment switches mode", (mode, currentEase, ease, fieldLabel) => { const onUpdateKeyframeEase = vi.fn(); const animation = baseAnimation({ keyframes: { format: "percentage", keyframes: [ { percentage: 0, properties: { opacity: 0 } }, { percentage: 50, properties: { opacity: 0.5 }, ease: currentEase }, { percentage: 100, properties: { opacity: 1 } }, ], }, }); const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation); openSegment(view.host, "0% → 50%"); const modeButton = view.host.querySelector(`[data-ease-mode="${mode}"]`); expect(modeButton).not.toBeNull(); act(() => modeButton?.click()); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); expect(modeButton?.getAttribute("aria-checked")).toBe("true"); expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull(); act(() => view.root.unmount()); }, ); it("commits one preset change to the selected keyframe segment", () => { const onUpdateKeyframeEase = vi.fn(); const animation = baseAnimation({ keyframes: { format: "percentage", keyframes: [ { percentage: 0, properties: { opacity: 0 } }, { percentage: 50, properties: { opacity: 0.5 } }, { percentage: 100, properties: { opacity: 1 } }, ], }, }); const view = renderCard({ animation, onUpdateKeyframeEase }); openSegment(view.host, "0% → 50%"); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({ action: "commit", ease, }); act(() => view.root.unmount()); }); it("commits one preset change through flat tween metadata", () => { const onUpdateMeta = vi.fn(); const onUpdateKeyframeEase = vi.fn(); const animation = baseAnimation({ id: "flat-tween" }); const view = renderCard({ animation, flat: true, onUpdateMeta, onUpdateKeyframeEase, }); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(animation.id, { ease }); expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); expect(trackStudioSegmentEaseEdit).not.toHaveBeenCalled(); act(() => view.root.unmount()); }); }); describe("AnimationCard flat branch", () => { it("renders a mint border-left and panel-token colors when flat", () => { const { host, root } = renderCard({ defaultExpanded: false, flat: true }); const card = host.querySelector('[data-flat-effect-card="true"]'); expect(card).not.toBeNull(); expect(card?.className).toContain("border-panel-accent"); act(() => root.unmount()); }); it("still renders the legacy (non-flat) appearance when flat is omitted", () => { const { host, root } = renderCard({ defaultExpanded: false }); expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull(); expect(host.textContent).toContain("power2.out"); act(() => root.unmount()); }); it("toggles expanded state when the collapsed header button is clicked, in both modes", () => { for (const flat of [false, true]) { const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined }); expect(host.textContent).not.toContain("Remove"); const button = host.querySelector("button"); expect(button).not.toBeNull(); act(() => { button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(host.textContent).toContain("Remove"); act(() => root.unmount()); } }); it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => { const onDeleteAnimation = vi.fn(); const { host, root } = renderCard({ flat: true, onDeleteAnimation }); const buttons = Array.from(host.querySelectorAll("button")); const removeButton = buttons.find((b) => b.textContent === "Remove"); expect(removeButton).not.toBeUndefined(); act(() => { removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onDeleteAnimation).toHaveBeenCalledWith("anim-1"); act(() => root.unmount()); }); });