mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* 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. * feat(studio): timeline lane-zone model What: new pure module timelineZones — visual/audio track-zone classification (classifyZone) and normalizeToZones, which re-packs lanes into zone-consistent rows; tests cover the stacking/zones interaction. Why: completes the z-model started in the stacking-sync PR. How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and computeStackingPatches (stacking-sync PR); type-only playerStore imports. Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): asset click policy and canvas nudge gate What: two small pure modules with tests — assetClickBehavior (click vs double-click policy for sidebar assets) and canvasNudgeGate (debounce gate for arrow-key canvas nudges). Why: policy dependencies of the upcoming asset card and nudge hook, reviewable as plain decision tables. How: new files only. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
|
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
|
|
// Minimal TimelineElement factory — only the fields the function inspects.
|
|
function makeEl(overrides: Partial<TimelineElement> & { id: string }): TimelineElement {
|
|
return {
|
|
tag: "div",
|
|
start: 0,
|
|
duration: 5,
|
|
track: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("findClipForAsset", () => {
|
|
it("returns null when elements array is empty", () => {
|
|
expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
|
|
});
|
|
|
|
it("returns null when no element matches", () => {
|
|
const el = makeEl({ id: "el1", src: "assets/other.mp4" });
|
|
expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
|
|
});
|
|
|
|
it("matches a bare relative src against the project-relative asset path", () => {
|
|
const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
|
|
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
|
});
|
|
|
|
it("matches a src with a ./ prefix", () => {
|
|
const el = makeEl({ id: "el1", src: "./assets/logo.png" });
|
|
expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
|
|
});
|
|
|
|
it("matches a server-relative /api/projects/…/preview/ src", () => {
|
|
const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
|
|
expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
|
|
});
|
|
|
|
it("matches a fully-absolute URL (as produced by the core runtime)", () => {
|
|
const el = makeEl({
|
|
id: "el1",
|
|
src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
|
|
});
|
|
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
|
});
|
|
|
|
it("decodes percent-encoded filenames when matching", () => {
|
|
const el = makeEl({
|
|
id: "el1",
|
|
src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
|
|
});
|
|
expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
|
|
});
|
|
|
|
it("strips query strings from src before matching", () => {
|
|
const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
|
|
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
|
});
|
|
|
|
it("returns the element with the earliest start when multiple clips match", () => {
|
|
const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
|
|
const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
|
|
const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
|
|
expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
|
|
});
|
|
|
|
it("prefers the key over id when the element has both", () => {
|
|
// findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
|
|
const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
|
|
const found = findClipForAsset([el], "assets/img.png");
|
|
expect(found?.key).toBe("clip-key");
|
|
});
|
|
|
|
it("skips elements with no src", () => {
|
|
const noSrc = makeEl({ id: "nosrc" });
|
|
const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
|
|
expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
|
|
});
|
|
});
|
|
|
|
describe("isPointerClick", () => {
|
|
it("returns true for zero movement", () => {
|
|
expect(isPointerClick(0, 0)).toBe(true);
|
|
});
|
|
|
|
it("returns true for movement within the threshold", () => {
|
|
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
|
|
expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
|
|
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
|
|
});
|
|
|
|
it("returns false at or beyond the threshold", () => {
|
|
expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
|
|
expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
|
|
expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
|
|
});
|
|
|
|
it("handles negative movement (pointer moved left/up)", () => {
|
|
expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
|
|
expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
|
|
});
|
|
});
|