Files
hyperframes/packages/studio/src/player/components/timelineMultiDragPreview.test.ts
T
Miguel Ángelandukimsanov e464b33bb3 fix(studio): stacking sync (#2270)
* feat(studio): timeline collision and placement model

What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.

Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.

How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.

Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).

* feat(studio): timeline magnetic snapping

What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.

Why: the magnet math for clip drags/trims, reviewable standalone.

How: new files only; type-only playerStore imports; consumers land with the
drag engine.

Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): multi-clip drag preview math

What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.

Why: the group-drag math, standalone and DOM-free.

How: new files only; consumed later by TimelineLanes.

Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline z-stacking sync model

What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.

Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.

How: new files only; consumed later by timelineZones and the stacking-sync
hook.

Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
2026-07-11 22:56:17 -04:00

128 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import {
clampGroupMoveDelta,
isMultiDragActive,
isMultiDragPassenger,
multiDragDeltaSeconds,
multiDragPassengerOffsetPx,
type MultiDragPreviewInput,
} from "./timelineMultiDragPreview";
const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
dragStarted: true,
draggedKey: "a",
draggedOriginStart: 2,
draggedPreviewStart: 5,
selectedKeys: new Set(["a", "b", "c"]),
...over,
});
describe("isMultiDragActive", () => {
it("is active when a started drag's clip is part of a 2+ selection", () => {
expect(isMultiDragActive(base())).toBe(true);
});
it("is inactive before the drag starts", () => {
expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
});
it("is inactive for a single-clip selection (single-drag behavior)", () => {
expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
});
it("is inactive when the dragged clip is not itself selected", () => {
expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
});
});
describe("multiDragDeltaSeconds (the one formation delta)", () => {
it("is the grabbed clip's preview origin start when active", () => {
// The preview start is already group-clamped upstream, so this delta is the
// clamped delta every member (ghost + passengers) moves by.
expect(multiDragDeltaSeconds(base())).toBe(3);
});
it("supports a leftward (negative) delta", () => {
expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
});
it("is zero when no multi-drag is active", () => {
expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
});
});
describe("isMultiDragPassenger", () => {
it("marks a selected non-dragged clip as a passenger", () => {
expect(isMultiDragPassenger("b", base())).toBe(true);
expect(isMultiDragPassenger("c", base())).toBe(true);
});
it("never marks the dragged clip itself (it is the free ghost)", () => {
expect(isMultiDragPassenger("a", base())).toBe(false);
});
it("never marks an unselected clip", () => {
expect(isMultiDragPassenger("d", base())).toBe(false);
});
it("marks nothing when the drag is a single-drag", () => {
const single = base({ selectedKeys: new Set(["a"]) });
expect(isMultiDragPassenger("b", single)).toBe(false);
});
});
describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
it("converts the one formation delta to pixels for every passenger", () => {
// Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
});
it("is zero for the dragged clip and for non-passengers", () => {
expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
});
it("is zero for a non-finite pps", () => {
expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
});
it("follows a leftward delta", () => {
expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
});
});
describe("clampGroupMoveDelta (rigid group move)", () => {
it("passes a rightward delta through unchanged (no right wall)", () => {
expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
});
it("passes a leftward delta through when no member would cross 0", () => {
// Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
});
it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
// Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
// formation instead of out-running it.
expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
});
it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
// Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
});
it("already-at-0 member forbids any leftward move", () => {
expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
// rightward still allowed
expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
});
it("returns the raw delta for an empty formation", () => {
expect(clampGroupMoveDelta(-9, [])).toBe(-9);
});
});