Files
hyperframes/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
T
Miguel Angel Simon Sierra 4ac57a5845 refactor(studio): split the track header's lane math out of its JSX
The header file owned value sampling, readout formatting, lane-state
resolution and the JSX at once, so a formatting change and a layout change
edited the same file. Sampling and formatting now live in
trackHeaderLaneValues, lane-state resolution in trackHeaderLaneState, and
resolveLaneHeaderState returns only the four fields its caller reads.

Also shows the track's clip count next to the track identity, which the
header promised but never rendered.
2026-07-27 19:52:06 +02:00

307 lines
10 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { LABEL_COL_W } from "./timelineLayout";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
const ELEMENT: TimelineElement = {
id: "clip-1",
label: "Hero card",
tag: "div",
start: 0,
duration: 2,
track: 0,
};
function animation(
id: string,
propertyGroup: PropertyGroupName,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
}>,
): GsapAnimation {
return {
id,
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 2,
properties: {},
propertyGroup,
keyframes: { format: "percentage", keyframes },
};
}
const POSITION = animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 100, y: 50 } },
{ percentage: 100, properties: { x: 200, y: 100 } },
]);
const OPACITY = animation("opacity-tween", "visual", [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
interface RenderHeaderOptions {
animations?: GsapAnimation[];
clipCount?: number;
currentTime?: number;
expanded?: boolean;
onSeek?: (time: number) => void;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
}
function renderHeader(options: RenderHeaderOptions = {}): {
host: HTMLDivElement;
root: Root;
rerender: (next: RenderHeaderOptions) => void;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (next: RenderHeaderOptions) => {
act(() => {
root.render(
<TimelineTrackHeader
trackNumber={0}
trackLabel="Hero card"
contentOrigin={LABEL_COL_W}
keyframeClip={ELEMENT}
clipCount={next.clipCount ?? 1}
isExpanded={next.expanded !== false}
animations={next.animations ?? [POSITION, OPACITY]}
currentTime={next.currentTime ?? 0}
isTrackHidden={false}
isAudioTrack={false}
isActive
isHovered={false}
theme={defaultTimelineTheme}
onToggleClipExpanded={vi.fn()}
onToggleTrackHidden={vi.fn()}
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
onSeek={next.onSeek}
/>,
);
});
};
render(options);
return { host, root, rerender: render };
}
function click(host: HTMLElement, label: string) {
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button).not.toBeNull();
act(() => button?.click());
}
describe("TimelineTrackHeader", () => {
// The header shows one clip's lanes, so how many clips the track holds is
// otherwise invisible from the label column. A single-clip track stays silent.
it("shows the track's clip count only once the track holds more than one clip", () => {
const view = renderHeader({ clipCount: 1 });
expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
view.rerender({ clipCount: 3 });
expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
act(() => view.root.unmount());
});
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
animationId: "opacity-tween",
propertyGroup: "visual",
tweenPercentage: 25,
properties: { opacity: 0.25 },
remove: false,
}),
);
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
animationId: "opacity-tween",
propertyGroup: "visual",
tweenPercentage: 50,
properties: { opacity: 0.5 },
remove: true,
}),
);
expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
ELEMENT,
expect.objectContaining({ animationId: "position-tween" }),
);
act(() => view.root.unmount());
});
it("seeks only to the selected group's adjacent keyframes", () => {
const onSeek = vi.fn();
const view = renderHeader({
currentTime: 1,
animations: [
POSITION,
animation("opacity-tween", "visual", [
{ percentage: 25, properties: { opacity: 0.25 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 75, properties: { opacity: 0.75 } },
]),
],
onSeek,
});
click(view.host, "Next Position keyframe");
expect(onSeek).toHaveBeenLastCalledWith(2);
click(view.host, "Previous Position keyframe");
expect(onSeek).toHaveBeenLastCalledWith(0);
expect(onSeek).not.toHaveBeenCalledWith(1.5);
act(() => view.root.unmount());
});
// The lane header sits inside the track row, whose own click handler selects
// the track. Every control in the label column has to own its click, or
// seeking to a keyframe also reselects whatever is behind the header.
it("keeps lane-header control clicks off the ancestor track row", () => {
const onAncestorClick = vi.fn();
const view = renderHeader({
currentTime: 1,
onSeek: vi.fn(),
onTogglePropertyGroupKeyframe: vi.fn(),
});
// React 18 delegates from the root container, so an ancestor of it is where
// a leaked click actually shows up.
document.body.addEventListener("click", onAncestorClick);
// Every control in the lane's label column, found by row rather than by
// label, so a wording change to one button can't silently drop it here.
const controls = view.host.querySelectorAll<HTMLButtonElement>(
'[data-property-group="position"] button',
);
expect(controls.length).toBeGreaterThanOrEqual(3);
for (const button of controls) {
act(() => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
document.body.removeEventListener("click", onAncestorClick);
expect(onAncestorClick).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("fills the toggle diamond exactly at that group's keyframe", () => {
const view = renderHeader({ currentTime: 0.5 });
const positionToggle = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle Position keyframe"]',
);
expect(positionToggle?.textContent).toBe("◇");
view.rerender({ currentTime: 1 });
expect(
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
?.textContent,
).toBe("◆");
act(() => view.root.unmount());
});
it("updates formatted group values when the playhead moves", () => {
const view = renderHeader({ currentTime: 0.5 });
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"50, 25",
);
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
view.rerender({ currentTime: 1.5 });
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"150, 75",
);
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
act(() => view.root.unmount());
});
it("disables the previous chevron at or before the group's first keyframe", () => {
const view = renderHeader({ currentTime: 0 });
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Previous Position keyframe"]',
);
expect(prevAt0).not.toBeNull();
expect(prevAt0?.disabled).toBe(true);
view.rerender({ currentTime: 1 });
const prevAt1 = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Previous Position keyframe"]',
);
expect(prevAt1?.disabled).toBe(false);
act(() => view.root.unmount());
});
it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
const view = renderHeader({ expanded: false });
expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
const assertAligned = (animations: GsapAnimation[]) => {
view.rerender({ animations });
const lanesHost = document.createElement("div");
document.body.append(lanesHost);
const lanesRoot = createRoot(lanesHost);
act(() => {
lanesRoot.render(
<TimelinePropertyLanes
animations={animations}
clipStart={0}
clipDuration={2}
clipLeftPx={120}
clipWidthPx={200}
accentColor="#3CE6AC"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
/>,
);
});
expect(
Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
(row) => row.style.top,
),
).toEqual(
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
(row) => row.style.top,
),
);
expect(
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
(row) => row.style.left,
),
).toEqual(animations.map(() => "120px"));
act(() => lanesRoot.unmount());
};
assertAligned([POSITION]);
assertAligned([POSITION, OPACITY]);
act(() => view.root.unmount());
});
});