Files
hyperframes/packages/studio/src/player/components/timelineMarquee.test.ts
T

311 lines
10 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
MARQUEE_DRAG_THRESHOLD_PX,
isMarqueeDrag,
isTimelineRulerPress,
getMarqueeRect,
getTimelineClipRect,
getMarqueeClipCandidates,
computeMarqueeSelection,
} from "./timelineMarquee";
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
import type { TimelineElement } from "../store/playerStore";
import {
GUTTER,
LANE_H,
TRACK_H,
RULER_H,
CLIP_Y,
TRACKS_LEFT_PAD,
createTimelineRowGeometry,
getTimelineRowTop,
} from "./timelineLayout";
// Canvas-space time origin used by the breathing-pad (default) test cases: right
// edge of the sticky gutter + the left pad. Other cases pass GUTTER or LABEL_COL_W
// directly as contentOrigin to test the plain/keyframe-label-column origins.
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
describe("isTimelineRulerPress", () => {
const rectTop = 500; // scroll container's viewport top
it("treats a press inside the ruler band as a ruler press (unscrolled)", () => {
expect(isTimelineRulerPress(rectTop, rectTop)).toBe(true);
expect(isTimelineRulerPress(rectTop + RULER_H - 1, rectTop)).toBe(true);
});
it("treats a press below the ruler band as a body press", () => {
expect(isTimelineRulerPress(rectTop + RULER_H, rectTop)).toBe(false);
expect(isTimelineRulerPress(rectTop + RULER_H + 100, rectTop)).toBe(false);
});
it("stays a ruler press when the body is scrolled down (sticky ruler)", () => {
// The ruler is position:sticky — scrolled down, its VISUAL band is still
// the top RULER_H px of the container. Content-space math
// (clientY - rectTop + scrollTop) would report y = 10 + 300 = 310 ≥ RULER_H
// and misclassify this as a body/marquee press; viewport-space math must
// still classify it as a ruler press regardless of scrollTop.
const scrollTop = 300;
const clientY = rectTop + 10; // visually on the stuck ruler
const contentSpaceY = clientY - rectTop + scrollTop;
expect(contentSpaceY).toBeGreaterThanOrEqual(RULER_H); // the old, broken signal
expect(isTimelineRulerPress(clientY, rectTop)).toBe(true);
});
it("honors a custom ruler height", () => {
expect(isTimelineRulerPress(rectTop + 30, rectTop, 32)).toBe(true);
expect(isTimelineRulerPress(rectTop + 33, rectTop, 32)).toBe(false);
});
});
describe("isMarqueeDrag", () => {
it("treats sub-threshold movement as a click, not a drag", () => {
expect(isMarqueeDrag(100, 50, 100, 50)).toBe(false);
expect(
isMarqueeDrag(
100,
50,
100 + MARQUEE_DRAG_THRESHOLD_PX - 1,
50 + MARQUEE_DRAG_THRESHOLD_PX - 1,
),
).toBe(false);
});
it("becomes a drag once either axis passes the threshold", () => {
expect(isMarqueeDrag(100, 50, 100 + MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
expect(isMarqueeDrag(100, 50, 100, 50 + MARQUEE_DRAG_THRESHOLD_PX)).toBe(true);
expect(isMarqueeDrag(100, 50, 100 - MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
});
});
describe("getMarqueeRect", () => {
it("builds a rect from origin to current for a down-right drag", () => {
expect(getMarqueeRect(10, 20, 110, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
});
it("normalizes negative drags (up-left) into a positive rect", () => {
expect(getMarqueeRect(110, 70, 10, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
});
it("normalizes mixed-direction drags (down-left / up-right)", () => {
expect(getMarqueeRect(110, 20, 10, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
expect(getMarqueeRect(10, 70, 110, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
});
it("yields a zero-size rect when the pointer has not moved", () => {
expect(getMarqueeRect(42, 42, 42, 42)).toEqual({ left: 42, top: 42, width: 0, height: 0 });
});
});
describe("getTimelineClipRect", () => {
const trackOrder = [0, 2, 5];
const geometry = createTimelineRowGeometry(
trackOrder,
trackOrder.map(() => TRACK_H),
);
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, geometry, 100, GUTTER);
expect(rect).toEqual({
left: GUTTER + 200,
top: getTimelineRowTop(1) + CLIP_Y,
width: 300,
height: TRACK_H - CLIP_Y * 2,
});
});
it("places the first visible track below the ruler + top breathing pad", () => {
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 50, GUTTER);
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
expect(rect?.left).toBe(GUTTER);
});
it("uses the row index in trackOrder, not the raw track number", () => {
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, geometry, 50, GUTTER);
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
});
it("uses cumulative tops and the resolved height for an expanded row", () => {
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
const expandedGeometry = createTimelineRowGeometry(trackOrder, rowHeights);
const rect = getTimelineClipRect(
{ start: 0, duration: 1, track: 0 },
expandedGeometry,
50,
GUTTER,
);
expect(rect).toMatchObject({
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
height: TRACK_H - CLIP_Y * 2,
});
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, expandedGeometry, 50, GUTTER)?.top,
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
});
it("enforces the 4px minimum rendered width", () => {
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, geometry, 10, GUTTER);
expect(rect?.width).toBe(4);
});
it("returns null for a track that is not displayed or an invalid pps", () => {
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, geometry, 100, GUTTER),
).toBeNull();
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 0, GUTTER),
).toBeNull();
expect(
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, NaN, GUTTER),
).toBeNull();
});
});
describe("computeMarqueeSelection", () => {
// Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
const trackOrder = [0, 1];
const rowGeometry = createTimelineRowGeometry(
trackOrder,
trackOrder.map(() => TRACK_H),
);
const pps = 100;
const clips = [
{ id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
{ id: "b", start: 2, duration: 1, track: 0 }, // x [232,332], row 0
{ id: "c", start: 0.5, duration: 1, track: 1 }, // x [82,182], row 1
];
const row0Top = getTimelineRowTop(0) + CLIP_Y;
const row1Top = getTimelineRowTop(1) + CLIP_Y;
it("selects only the clips the marquee rect intersects", () => {
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
rowGeometry,
pps,
contentOrigin: ORIGIN,
marquee,
});
expect(ids).toEqual(new Set(["a"]));
expect(primaryId).toBe("a");
});
it("selects across tracks when the rect spans multiple rows", () => {
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
const { ids } = computeMarqueeSelection({
clips,
rowGeometry,
pps,
contentOrigin: ORIGIN,
marquee,
});
expect(ids).toEqual(new Set(["a", "c"]));
});
it("excludes clips outside the rect horizontally", () => {
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
const { ids } = computeMarqueeSelection({
clips,
rowGeometry,
pps,
contentOrigin: ORIGIN,
marquee,
});
expect(ids).toEqual(new Set());
});
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
rowGeometry,
pps,
contentOrigin: GUTTER,
marquee,
baseSelection: ["b"],
});
expect(ids).toEqual(new Set(["b"]));
expect(primaryId).toBeNull();
});
it("unions additive base selection with new hits; primary comes from the marquee", () => {
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
rowGeometry,
pps,
contentOrigin: GUTTER,
marquee,
baseSelection: ["b"],
});
expect(ids).toEqual(new Set(["b", "c"]));
expect(primaryId).toBe("c");
});
it("shrinking the rect live drops clips it no longer covers", () => {
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
expect(
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide })
.ids,
).toEqual(new Set(["a", "b"]));
expect(
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: narrow })
.ids,
).toEqual(new Set(["a"]));
});
it("ignores clips on hidden/undisplayed tracks", () => {
const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
const { ids } = computeMarqueeSelection({
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
rowGeometry,
pps,
contentOrigin: GUTTER,
marquee,
});
expect(ids).toEqual(new Set());
});
});
describe("getMarqueeClipCandidates", () => {
it("queries only the intersecting rows and time span", () => {
const rowGeometry = createTimelineRowGeometry([0, 1, 2], [TRACK_H, TRACK_H, TRACK_H]);
const near: TimelineElement = { id: "near", tag: "div", start: 1, duration: 1, track: 1 };
const wrongTime: TimelineElement = {
id: "wrong-time",
tag: "div",
start: 20,
duration: 1,
track: 1,
};
const wrongRow: TimelineElement = {
id: "wrong-row",
tag: "div",
start: 1,
duration: 1,
track: 2,
};
const clipIndex = createTimelineClipIndex([
[0, []],
[1, [near, wrongTime]],
[2, [wrongRow]],
]);
expect(
getMarqueeClipCandidates({
clipIndex,
rowGeometry,
marquee: {
left: ORIGIN + 100,
top: getTimelineRowTop(1),
width: 100,
height: TRACK_H - 1,
},
pps: 100,
contentOrigin: ORIGIN,
}),
).toEqual([near]);
});
});