// @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());
});
});