Files
hyperframes/packages/studio/src/components/editor/AnimationCard.test.tsx
T

385 lines
13 KiB
TypeScript

// @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(
<AnimationCard
animation={animation}
defaultExpanded={defaultExpanded}
focusedSegment={nextFocusedSegment}
onFocusSegmentConsumed={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
onUpdateKeyframeEase={onEaseCommit}
onUpdateSegmentEase={onUpdateSegmentEase}
/>,
);
});
};
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<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull();
act(() => dropdown?.click());
const preset = host.querySelector<HTMLButtonElement>(`[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<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
onDeleteAnimation?: (id: string) => void;
} = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded={defaultExpanded}
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
/>,
);
});
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> = {}): 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<HTMLButtonElement>(`[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());
});
});