// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds"; import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; }); function pointerEvent(type: string, init: PointerEventInit): Event { if (typeof PointerEvent === "function") return new PointerEvent(type, init); return new MouseEvent(type, init); } function renderDiamonds(onClickKeyframe = vi.fn()) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); return { host, root, onClickKeyframe }; } describe("TimelineClipDiamonds", () => { // Dense rows narrow the DIAMOND so neighbours stay individually readable, but // the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable // (~7px) at the zoom floor. it("narrows dense keyframe visuals while flooring their hit regions", () => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( ({ percentage, propertyGroup: "position", properties: { x: percentage }, })), }} clipWidthPx={36} clipHeightPx={48} accentColor="#4ba3d2" isSelected currentPercentage={0} elementId="clip-1" selectedKeyframes={new Set()} groupAware />, ); }); const diamonds = Array.from(host.querySelectorAll("button[title]")); expect(diamonds).toHaveLength(3); for (const diamond of diamonds) { expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12); expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8); } act(() => root.unmount()); }); it("treats primary pointerup without drag as a keyframe click", () => { const { host, root, onClickKeyframe } = renderDiamonds(); const diamond = host.querySelector('button[title="50%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 })); }); expect(onClickKeyframe).toHaveBeenCalledWith( "clip-1", expect.objectContaining({ percentage: 50 }), ); act(() => root.unmount()); }); // The collapsed clip row and the expanded property lanes read the same cache, // so a keyframe that carries a property group has to hash to the same key in // both — otherwise collapsing a track silently drops the selection. it("keys a grouped keyframe the same way collapsed as expanded", () => { const groupedKeyframe = { percentage: 50, tweenPercentage: 25, propertyGroup: "position", animationId: "anim-1", properties: { x: 100 }, }; const sharedKey = timelineKeyframeSelectionKey("clip-1", groupedKeyframe); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); const onClickKeyframe = vi.fn(); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="50%"]'); // Highlighted from the shared key alone (the playhead is off-clip here). expect(diamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2"); act(() => { diamond?.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 })); }); expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", { percentage: 50, tweenPercentage: 25, propertyGroup: "position", animationId: "anim-1", }); act(() => root.unmount()); }); it("does not treat secondary pointerup as a keyframe click", () => { const { host, root, onClickKeyframe } = renderDiamonds(); const diamond = host.querySelector('button[title="50%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 })); }); expect(onClickKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); // Regression: once the clip is selected, canDrag arms on every diamond // press. A real click's few px of mouse/trackpad jitter then resolves (via // the neighbour clamp) back onto ~the same position — "noop", not "move" — // which fell through neither branch and silently did nothing: no // selection, no retime. It must still count as the click it was. it("treats a drag-armed press that resolves to a no-op move as a click", () => { const onClickKeyframe = vi.fn(); const onMoveKeyframe = vi.fn(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="50%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); // 4px of travel at a 5000px clip width is ~0.08 clip-% — above the drag // threshold (so resolveKeyframeDrag doesn't short-circuit to "click" // itself) but below the no-op epsilon once neighbour-clamped. diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 })); }); expect(onClickKeyframe).toHaveBeenCalledWith({ percentage: 50, tweenPercentage: 100, propertyGroup: "position", animationId: "anim-1", }); expect(onMoveKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); // Regression: a genuine retime (drag far enough to actually move the // keyframe) committed the move but never selected/parked on the result — // the diamond it was just dragged looked exactly like one nothing happened // to. Select it at its NEW position too. it("reselects a retimed keyframe with its post-move tween percentage", () => { const onClickKeyframe = vi.fn(); const onMoveKeyframe = vi.fn().mockResolvedValue(true); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="40%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 })); }); expect(onMoveKeyframe).toHaveBeenCalledWith( { percentage: 40, tweenPercentage: 50, propertyGroup: "position", animationId: "anim-1", }, 50, ); expect(onClickKeyframe).toHaveBeenCalledWith({ percentage: 50, tweenPercentage: 75, propertyGroup: "position", animationId: "anim-1", }); act(() => root.unmount()); }); it("composes a rapid second retime from the pending position", () => { const onMoveKeyframe = vi.fn().mockResolvedValue(true); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="50%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); // The cache still exposes 50%, but this second +10% drag starts at the // pending 75% destination and must therefore land at 85%, not 60%. diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 })); }); // The second move must identify the FROM keyframe by the pending (already- // moved) position 75%, not the stale rendered 50%; otherwise the serialized // mutation can't locate the keyframe the first move relocated. expect(onMoveKeyframe).toHaveBeenNthCalledWith( 2, { percentage: 75, tweenPercentage: 75, propertyGroup: "position", animationId: "anim-1", }, 85, ); act(() => root.unmount()); }); it.each([ ["returns false", () => Promise.resolve(false)], ["rejects", () => Promise.reject(new Error("retime failed"))], ])("clears a failed pending retime when the callback %s", async (_label, settle) => { const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="50%"]'); expect(diamond).not.toBeNull(); await act(async () => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); await Promise.resolve(); }); act(() => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 })); }); expect(onMoveKeyframe).toHaveBeenNthCalledWith( 2, { percentage: 50, tweenPercentage: 50, propertyGroup: "position", animationId: "anim-1", }, 60, ); act(() => root.unmount()); }); it("cancels an in-flight retime on Escape without committing or selecting", () => { const onClickKeyframe = vi.fn(); const onMoveKeyframe = vi.fn().mockResolvedValue(true); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="40%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }), ); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 })); }); // Escape ends the gesture: no retime is written, and the release is not // reinterpreted as a click that would park the playhead on the keyframe. expect(onMoveKeyframe).not.toHaveBeenCalled(); expect(onClickKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); // Regression: onClickKeyframe's state updates can re-render the diamond // button out from under the gesture before the browser auto-synthesizes the // "click" event that follows a button's pointerdown+pointerup. That orphaned // click then bubbles to the ancestor clip's onClick, which toggles selection // off whenever the clip is already selected — the state a diamond click // always happens in — so every keyframe click immediately deselected its // own clip. suppressClickRef lets that ancestor ignore the stray click. it("arms suppressClickRef synchronously on a keyframe click", () => { const suppressClickRef = { current: false }; const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const diamond = host.querySelector('button[title="50%"]'); expect(diamond).not.toBeNull(); act(() => { diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 })); }); expect(suppressClickRef.current).toBe(true); act(() => root.unmount()); }); const renderSegmentLane = (lastAmbiguous: boolean) => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); const kf = (percentage: number, extra: Record = {}) => ({ percentage, tweenPercentage: percentage, propertyGroup: "position", animationId: "anim-1", properties: { x: percentage }, ...extra, }); act(() => { root.render( , ); }); return { host, root }; }; it("hides the inline ease button on an ambiguous merged segment", () => { // Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous // keyframe, so its hover/ease-button area is not rendered. const { host, root } = renderSegmentLane(true); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); act(() => root.unmount()); }); it("keeps the inline ease button on unambiguous merged segments", () => { const { host, root } = renderSegmentLane(false); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2); act(() => root.unmount()); }); it("keeps the ease button above nearby diamonds without blocking the segment", () => { const { host, root } = renderSegmentLane(false); const segment = host.querySelector("[data-keyframe-ease-segment]"); const ease = segment?.querySelector("[data-keyframe-ease-button]"); const diamond = host.querySelector('button[title="50%"]'); expect(segment?.style.pointerEvents).toBe("none"); expect(ease?.style.pointerEvents).toBe("auto"); expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex)); act(() => root.unmount()); }); it("hides the inline ease button on a segment with no source animation id", () => { // A runtime-scanned keyframe has no animationId, so there is no tween to // target; the segment ending on it must not render a (dead) ease button. const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); const kf = (percentage: number, animationId?: string) => ({ percentage, tweenPercentage: percentage, propertyGroup: "position", ...(animationId ? { animationId } : {}), properties: { x: percentage }, }); act(() => { root.render( , ); }); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); act(() => root.unmount()); }); });