// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore"; import { TimelineClipDiamonds, TimelineDiamondLane, type TimelineDiamondKeyframe, } from "./TimelineClipDiamonds"; import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity"; import { configureTimelineTestViewport } from "./timelineTestViewport"; import { readPendingTimelineKeyframeRetimes } from "./useTimelineKeyframeHandlers"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; usePlayerStore.setState({ elements: [], timelineSessionEpoch: 0 }); }); const RETIME_ELEMENT: TimelineElement = { id: "clip-1", label: "Clip", tag: "div", start: 0, duration: 10, track: 0, }; function pointerEvent(type: string, init: PointerEventInit): Event { const event = typeof PointerEvent === "function" ? new PointerEvent(type, init) : new MouseEvent(type, init); if (!("pointerId" in event)) { Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 }); } return event; } function createTimelineHost() { const host = document.createElement("div"); host.setAttribute("data-timeline-scroll-viewport", ""); document.body.append(host); return host; } function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) { const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( , ); }); return { host, root, onClickKeyframe, onShiftClickKeyframe }; } it("pins authored keyframes outside the clip to an inspectable boundary marker", () => { const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( , ); }); const before = host.querySelectorAll('[data-keyframe-outside-clip="before"]'); const after = host.querySelector('[data-keyframe-outside-clip="after"]'); expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]); expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)"); expect(after?.style.left).toBe("201px"); expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)"); expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4); act(() => root.unmount()); }); function renderRetimeLane( onMoveKeyframe = vi.fn().mockResolvedValue(true), strict = false, options: { elementId?: string; host?: HTMLDivElement; keyframes?: TimelineDiamondKeyframe[]; } = {}, ) { const elementId = options.elementId ?? RETIME_ELEMENT.id; if (!options.host) { usePlayerStore.setState({ elements: [{ ...RETIME_ELEMENT, id: elementId, key: elementId }] }); } const host = options.host ?? createTimelineHost(); const keyframes = options.keyframes ?? [ { percentage: 0, tweenPercentage: 0, propertyGroup: "position", animationId: "anim-1", properties: { x: 0 }, }, { percentage: 50, tweenPercentage: 50, propertyGroup: "position", animationId: "anim-1", properties: { x: 100 }, }, { percentage: 100, tweenPercentage: 100, propertyGroup: "position", animationId: "anim-1", properties: { x: 200 }, }, ]; const onClickKeyframe = vi.fn(); const mountLane = () => { const laneHost = document.createElement("div"); host.append(laneHost); const root = createRoot(laneHost); const lane = ( ); act(() => { root.render(strict ? {lane} : lane); }); const diamond = laneHost.querySelector( `button[title="${keyframes[1]?.percentage}%"]`, ); expect(diamond).not.toBeNull(); return { diamond: diamond!, root }; }; return { ...mountLane(), host, mountLane, onClickKeyframe, onMoveKeyframe }; } describe("TimelineClipDiamonds", () => { it("marks only the nearest keyframe in a dense lane as under the playhead", () => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( ({ percentage, propertyGroup: "position", properties: { x: percentage }, })), }} clipWidthPx={4000} clipHeightPx={48} clipDuration={12} accentColor="#4ba3d2" isSelected currentPercentage={35.05} elementId="clip-1" selectedKeyframes={new Set()} groupAware />, ); }); expect(host.querySelectorAll('[data-keyframe-at-playhead="true"]')).toHaveLength(1); expect(host.querySelector('[data-keyframe-at-playhead="true"]')?.title).toBe( "35%", ); act(() => root.unmount()); }); it("distinguishes a playhead match from an explicitly selected keyframe", () => { const selectedKey = timelineKeyframeSelectionKey("clip-1", { percentage: 60, propertyGroup: "position", }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( , ); }); const playheadDiamond = host.querySelector('button[title="40%"]'); const selectedDiamond = host.querySelector('button[title="60%"]'); expect(playheadDiamond?.dataset.keyframeAtPlayhead).toBe("true"); expect(playheadDiamond?.dataset.keyframeSelected).toBe("false"); expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#a3a3a3"); expect(playheadDiamond?.querySelector('path[stroke="#4ba3d2"]')).not.toBeNull(); expect(selectedDiamond?.dataset.keyframeAtPlayhead).toBe("false"); expect(selectedDiamond?.dataset.keyframeSelected).toBe("true"); expect(selectedDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2"); act(() => root.unmount()); }); it("keeps dense keyframe visuals full-size while bounding 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"))).toBe(22); } act(() => root.unmount()); }); it("gives keyframes time-based names and native keyboard selection semantics", () => { const { host, root, onClickKeyframe } = renderDiamonds(); const diamond = host.querySelector('button[title="50%"]')!; expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s"); expect(diamond.getAttribute("aria-pressed")).toBe("false"); act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }))); expect(onClickKeyframe).toHaveBeenCalledWith( "clip-1", expect.objectContaining({ percentage: 50 }), ); act(() => root.unmount()); }); it("uses Shift+Space's native click for additive keyframe selection", () => { const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds(); const diamond = host.querySelector('button[title="50%"]')!; act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })), ); expect(onShiftClickKeyframe).toHaveBeenCalledWith( "clip-1", expect.objectContaining({ percentage: 50 }), ); expect(onClickKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); it("publishes retime previews after StrictMode effect replay", () => { const { diamond, host, root } = renderRetimeLane(undefined, true); const initialLeft = diamond.style.left; act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointermove", { bubbles: true, clientX: 120, pointerId: 7 }), ); }); expect(host.querySelector('button[title="50%"]')?.style.left).not.toBe( initialLeft, ); 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 = createTimelineHost(); 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 = createTimelineHost(); 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("leaves the selection alone when a stale retime fails after a newer drag", async () => { const onClickKeyframe = vi.fn(); let failFirstDrag: (() => void) | undefined; const onMoveKeyframe = vi .fn() .mockImplementationOnce( () => new Promise((resolve) => { failFirstDrag = () => resolve(false); }), ) .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%"]'); // Two drags back to back; the first one's commit is still in flight. act(() => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 })); }); onClickKeyframe.mockClear(); await act(async () => { failFirstDrag?.(); await Promise.resolve(); }); // The stale failure must not drag the selection back to the first drag's // source: the second retime, which the user can see, owns it now. expect(onClickKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); it("composes a rapid second retime from the pending position", () => { const onMoveKeyframe = vi.fn().mockResolvedValue(true); const host = createTimelineHost(); 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(() => { usePlayerStore.setState({ timelineSessionEpoch: 1 }); diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 })); }); expect(onMoveKeyframe).toHaveBeenNthCalledWith( 3, expect.objectContaining({ percentage: 50 }), 60, ); act(() => root.unmount()); }); it("preserves another element's pending retime when the active source is removed", () => { const host = createTimelineHost(); const elementA = { ...RETIME_ELEMENT, id: "clip-a", key: "clip-a" }; const elementB = { ...RETIME_ELEMENT, id: "clip-b", key: "clip-b" }; usePlayerStore.setState({ elements: [elementA, elementB] }); const laneA = renderRetimeLane(vi.fn().mockResolvedValue(true), false, { elementId: "clip-a", host, }); const onMoveB = vi.fn().mockResolvedValue(true); const laneB = renderRetimeLane(onMoveB, false, { elementId: "clip-b", host }); act(() => { laneB.diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }), ); laneA.diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }), ); usePlayerStore.setState({ elements: [elementB] }); laneB.diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 11 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170, pointerId: 11 }), ); }); expect(onMoveB).toHaveBeenNthCalledWith(2, expect.objectContaining({ percentage: 75 }), 85); act(() => { laneA.root.unmount(); laneB.root.unmount(); }); }); it("retires a pending retime once the cache exposes its destination identity", () => { const first = renderRetimeLane(); act(() => { first.diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }), ); }); expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(1); act(() => first.root.unmount()); const destination = renderRetimeLane(vi.fn().mockResolvedValue(true), false, { host: first.host, keyframes: [ { percentage: 0, tweenPercentage: 0, propertyGroup: "position", animationId: "anim-1", properties: { x: 0 }, }, { percentage: 75, tweenPercentage: 75, propertyGroup: "position", animationId: "anim-1", properties: { x: 100 }, }, { percentage: 100, tweenPercentage: 100, propertyGroup: "position", animationId: "anim-1", properties: { x: 200 }, }, ], }); act(() => { destination.diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 9 }), ); }); expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(0); act(() => { window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 9 })); destination.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 = createTimelineHost(); 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()); }); it("commits once from the stable viewport after the source lane unmounts", () => { const { diamond, onMoveKeyframe, root } = renderRetimeLane(); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); root.unmount(); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }), ); }); expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith( { percentage: 50, tweenPercentage: 50, propertyGroup: "position", animationId: "anim-1", }, 60, ); }); it("keeps the retime preview coherent when the source lane remounts", () => { const { diamond, mountLane, onMoveKeyframe, root } = renderRetimeLane(); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), ); }); const beforeUnmountLeft = Number.parseFloat(diamond.style.left); act(() => { root.unmount(); window.dispatchEvent( pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }), ); }); const { diamond: remountedDiamond, root: remountedRoot } = mountLane(); const remountedLeft = Number.parseFloat(remountedDiamond.style.left); expect(remountedLeft).toBeGreaterThan(beforeUnmountLeft); act(() => { window.dispatchEvent( pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }), ); }); expect(Number.parseFloat(remountedDiamond.style.left)).toBeGreaterThan(remountedLeft); act(() => { window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }), ); }); expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 80); act(() => remountedRoot.unmount()); }); it("includes horizontal viewport scrolling in the retime destination", () => { const { diamond, host, onMoveKeyframe, root } = renderRetimeLane(); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); host.scrollLeft = 20; window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); }); expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 60); act(() => root.unmount()); }); it("auto-scrolls horizontally without virtualizing the source row away", () => { let frame: FrameRequestCallback | null = null; vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => { frame = callback; return 1; }); vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined); const { diamond, host, root } = renderRetimeLane(); configureTimelineTestViewport(host, 10_000); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 790, clientY: 239, pointerId: 7, }), ); }); expect(frame).not.toBeNull(); act(() => frame?.(0)); expect(host.scrollLeft).toBeGreaterThan(0); expect(host.scrollTop).toBe(0); act(() => { window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 7 })); root.unmount(); }); }); it("ignores another pointer and lets the owning pointer finish", () => { const { diamond, onMoveKeyframe, root } = renderRetimeLane(); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 8 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), ); }); expect(onMoveKeyframe).toHaveBeenCalledOnce(); act(() => root.unmount()); }); it("cancels without mutation on pointer cancel or Escape and allows the next retime", () => { const { diamond, onMoveKeyframe, root } = renderRetimeLane(); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); window.dispatchEvent( pointerEvent("pointercancel", { bubbles: true, button: 0, clientX: 120, pointerId: 7, }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), ); diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }), ); window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" })); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }), ); diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }), ); }); expect(onMoveKeyframe).toHaveBeenCalledOnce(); act(() => root.unmount()); }); it("cancels on project switch or source removal without poisoning the next gesture", () => { const { diamond, onMoveKeyframe, root } = renderRetimeLane(); act(() => { diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), ); usePlayerStore.setState({ timelineSessionEpoch: 1 }); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), ); diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }), ); usePlayerStore.setState({ elements: [] }); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }), ); usePlayerStore.setState({ elements: [RETIME_ELEMENT] }); diamond.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }), ); window.dispatchEvent( pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }), ); }); expect(onMoveKeyframe).toHaveBeenCalledOnce(); 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. That stray click can // replace keyframe focus or collapse a marquee selection, so suppressClickRef // lets the ancestor ignore it. it("arms suppressClickRef synchronously on a keyframe click", () => { const suppressClickRef = { current: false }; const host = createTimelineHost(); 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, clipWidthPx = 200) => { const host = createTimelineHost(); const root = createRoot(host); const onSelectSegment = vi.fn(); const kf = (percentage: number, extra: Record = {}) => ({ percentage, tweenPercentage: percentage, propertyGroup: "position", animationId: "anim-1", properties: { x: percentage }, ...extra, }); act(() => { root.render( , ); }); return { host, onSelectSegment, root }; }; it("shows the inline ease button on a colliding merged segment (bulk edit)", () => { // Both segments (0->50, 50->100) render their ease button; the 50->100 // segment ends on a keyframe shared by two animations and the button now // bulk-edits both rather than being hidden. const { host, root } = renderSegmentLane(true); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2); act(() => root.unmount()); }); it("shows the inline ease button on single-animation merged segments", () => { const { host, onSelectSegment, root } = renderSegmentLane(false); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2); const ease = host.querySelector("[data-keyframe-ease-button]")!; expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s"); expect(ease.classList.contains("opacity-0")).toBe(true); act(() => ease.click()); expect(onSelectSegment).toHaveBeenCalledOnce(); expect(usePlayerStore.getState().requestedSeekTime).toBeNull(); act(() => root.unmount()); }); it("ends connectors at the diamond boundaries", () => { const { host, root } = renderSegmentLane(false); const connectors = host.querySelectorAll("[data-keyframe-connector]"); expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]); expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]); 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)); // Room to spare here, so the button carries the 24x24 WCAG 2.5.8 overlay. expect(ease?.className).toContain("before:h-6"); act(() => root.unmount()); }); it("drops the 24x24 ease overlay when the segment is too narrow to hold it", () => { // The segment wrapper outranks the diamonds, so an overlay wider than the // clear span between them would steal their clicks at fit zoom. 40px of clip // across three keyframes leaves well under 24px of clear span per segment. const { host, root } = renderSegmentLane(false, 40); const ease = host.querySelector("[data-keyframe-ease-button]"); expect(ease).not.toBeNull(); expect(ease?.className).not.toContain("before:h-6"); expect(ease?.style.width).toBe("16px"); 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 = createTimelineHost(); 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()); }); });