// @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; }>, ): 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( , ); }); }; render(options); return { host, root, rerender: render }; } function click(host: HTMLElement, label: string) { const button = host.querySelector(`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( '[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( 'button[aria-label="Toggle Position keyframe"]', ); expect(positionToggle?.textContent).toBe("◇"); view.rerender({ currentTime: 1 }); expect( view.host.querySelector('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( 'button[aria-label="Previous Position keyframe"]', ); expect(prevAt0).not.toBeNull(); expect(prevAt0?.disabled).toBe(true); view.rerender({ currentTime: 1 }); const prevAt1 = view.host.querySelector( '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( , ); }); expect( Array.from(view.host.querySelectorAll("[data-timeline-lane-top]")).map( (row) => row.style.top, ), ).toEqual( Array.from(lanesHost.querySelectorAll("[data-timeline-lane-top]")).map( (row) => row.style.top, ), ); expect( Array.from(lanesHost.querySelectorAll("[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()); }); });