Files
hyperframes/packages/studio/src/player/components/useTimelineRangeSelectionScrub.test.tsx
T
Miguel Angel Simon Sierra 69020699df fix(studio): seek ruler clicks to the pressed position and round retime percentages
A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
2026-07-28 16:44:42 +02:00

128 lines
4.2 KiB
TypeScript

// @vitest-environment happy-dom
// Regression guard for the ruler-click seek. `handlePointerUp` replays
// `pendingClientXRef`, which only `updateScrubDrag` (pointermove) used to write,
// so a press with no move settled on the ref's initial 0 and clamped the
// playhead to t=0 — silently discarding the correct pointerdown seek.
import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/** Scroll container top; a press within RULER_H of this counts as a ruler press. */
const SCROLL_TOP = 100;
/** Comfortably inside the ruler band (RULER_H is 28 at time of writing). */
const RULER_Y = SCROLL_TOP + 4;
type Handlers = ReturnType<typeof useTimelineRangeSelection>;
function makeScrollEl(): HTMLDivElement {
const el = document.createElement("div");
el.getBoundingClientRect = () =>
({ top: SCROLL_TOP, left: 0, width: 1000, height: 400 }) as DOMRect;
return el;
}
function makePointerEvent(clientX: number, clientY: number): React.PointerEvent {
const currentTarget = document.createElement("div");
currentTarget.setPointerCapture = vi.fn();
return {
button: 0,
shiftKey: false,
metaKey: false,
ctrlKey: false,
pointerId: 1,
clientX,
clientY,
target: document.createElement("div"),
currentTarget,
} as unknown as React.PointerEvent;
}
const roots: Array<{ unmount: () => void }> = [];
afterEach(() => {
for (const root of roots.splice(0)) act(() => root.unmount());
});
function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn> } {
const seekFromX = vi.fn();
const scrollEl = makeScrollEl();
let latest: Handlers | null = null;
// Hoisted so they survive re-renders. The hook mutates `isDragging.current`
// across the press, and a fresh object per render would reset it to false.
const scrollRef = { current: scrollEl };
const ppsRef = { current: 25 };
const dragScrollRaf = { current: 0 };
const isDragging = { current: false };
const elementsRef = { current: [] };
const trackOrderRef = { current: [] };
const rowHeightsRef = { current: [] };
function Probe(): null {
latest = useTimelineRangeSelection({
scrollRef,
ppsRef,
effectiveDuration: 30,
pps: 25,
seekFromX,
autoScrollDuringDrag: vi.fn(),
dragScrollRaf,
isDragging,
setShowPopover: vi.fn(),
elementsRef,
trackOrderRef,
rowHeightsRef,
contentOrigin: 0,
});
return null;
}
roots.push(mountReactHarness(<Probe />));
return {
handlers: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
seekFromX,
};
}
describe("useTimelineRangeSelection — ruler press seek", () => {
it("replays the pressed x on pointerup when the pointer never moved", () => {
const { handlers, seekFromX } = setup();
act(() => handlers().handlePointerDown(makePointerEvent(1000, RULER_Y)));
act(() => handlers().handlePointerUp());
// Both the press and the settle must land on the SAME x. The bug settled on
// 0, so the LAST call is the one that decides where the playhead ends up.
expect(seekFromX).toHaveBeenCalledWith(1000);
expect(seekFromX).not.toHaveBeenCalledWith(0);
expect(seekFromX.mock.calls.at(-1)).toEqual([1000]);
});
it("does not fall back to x=0 for a press nearer the left edge either", () => {
const { handlers, seekFromX } = setup();
act(() => handlers().handlePointerDown(makePointerEvent(400, RULER_Y)));
act(() => handlers().handlePointerUp());
expect(seekFromX.mock.calls.at(-1)).toEqual([400]);
});
it("settles on the final pointermove x when the pointer did move", () => {
const { handlers, seekFromX } = setup();
act(() => handlers().handlePointerDown(makePointerEvent(700, RULER_Y)));
act(() => handlers().handlePointerMove(makePointerEvent(760, RULER_Y)));
act(() => handlers().handlePointerUp());
// The move must win over the seeded press coordinate.
expect(seekFromX.mock.calls.at(-1)).toEqual([760]);
});
});