import { describe, expect, it } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { computeDragPreview, computeResizePreview, type DragPreviewContext, } from "./timelineClipDragPreview"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout"; // ───────────────────────────────────────────────────────────────────────────── // Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip // on its own top lane armed a phantom new-track insert (the old 0.32 insert band // reached deep into the clip body). That insert flipped the commit into the // lane-change branch, which nudged the clip's z-index and re-sorted it off its // lane. The invariant: a horizontal drag over a clip BODY → insertRow === null, // previewTrack unchanged (a pure time move — zero topology change, zero z sync). // // Elements mirror the user's index.html shapes: a high-z "v-moodboard" alone on // the top display lane, over several lower-lane video clips it overlaps in time, // plus a caption. Tracks here are already the normalized DISPLAY lanes (the store // runs normalizeToZones on discovery), matching what the drag hook passes in. // ───────────────────────────────────────────────────────────────────────────── const PPS = 40; function clip( id: string, track: number, start: number, duration: number, zIndex: number, tag = "video", ): TimelineElement { return { id, key: id, tag, start, duration, track, zIndex, domId: id }; } // v-moodboard: own top lane (0). Lower lane (1) carries overlapping video clips; // captions sit on lane 2. trackOrder = [0, 1, 2]. const moodboard = clip("v-moodboard", 0, 19, 5.5, 37); const fixtureElements: TimelineElement[] = [ moodboard, clip("v-dashboard", 1, 19, 4, 16), clip("v-globe", 1, 23, 1.5, 17), clip("cap", 2, 20.82, 1.78, 0, "text"), ]; // A scroll container whose content-space y equals clientY (rect top 0, no scroll). function fakeScroll(): HTMLDivElement { return { getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }), scrollLeft: 0, scrollTop: 0, scrollWidth: 100000, } as unknown as HTMLDivElement; } function ctx( rowHeights?: readonly number[], elements: TimelineElement[] = fixtureElements, ): DragPreviewContext { return { scroll: fakeScroll(), pps: PPS, duration: 44.5, trackOrder: [0, 1, 2], elements, rowHeights, selectedKeys: new Set(), buildSnapTargets: () => [], audioTracks: new Set(), }; } // content-space y for a fractional row index (inverse of getTimelineRowFromY). const yForRow = (rowFloat: number) => RULER_H + TRACKS_TOP_PAD + rowFloat * TRACK_H; // A drag grabbing `element` at vertical position `grabRowFloat` within its lane. function horizontalDrag( element: TimelineElement, grabRowFloat: number, deltaSeconds: number, ): { drag: DraggedClipState; clientX: number; clientY: number } { const originClientX = 800; const originClientY = yForRow(grabRowFloat); const drag: DraggedClipState = { element, originClientX, originClientY, originScrollLeft: 0, originScrollTop: 0, pointerClientX: originClientX, pointerClientY: originClientY, pointerOffsetX: 0, pointerOffsetY: 0, previewStart: element.start, previewTrack: element.track, insertRow: null, snapTime: null, snapType: null, started: true, }; // Horizontal: clientY stays at the grab point; only x advances by the delta. return { drag, clientX: originClientX + deltaSeconds * PPS, clientY: originClientY }; } describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => { it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => { const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2); const next = computeDragPreview(drag, clientX, clientY, ctx()); expect(next.insertRow).toBeNull(); // no phantom new-track insert expect(next.previewTrack).toBe(0); // stays on its own lane expect(next.desiredTrack).toBe(0); // pointer never left lane 0 → not a vertical aim expect(next.previewStart).toBeCloseTo(21, 5); // +2s moved }); it("grabbing ANYWHERE across the clip body (not just dead-center) stays a pure time move", () => { // Sweep the whole clip body of lane 0; a horizontal drag must never insert. for (let grab = 0.1; grab <= 0.9 + 1e-9; grab += 0.1) { const { drag, clientX, clientY } = horizontalDrag(moodboard, grab, 2); const next = computeDragPreview(drag, clientX, clientY, ctx()); expect(next.insertRow).toBeNull(); expect(next.previewTrack).toBe(0); } }); it("aiming the gutter ABOVE the top lane arms a top insert (UX rule 2)", () => { // Drag v-moodboard up into the top breathing pad → insert a new top track. const originClientX = 800; const originClientY = yForRow(0.5); const drag: DraggedClipState = { element: moodboard, originClientX, originClientY, originScrollLeft: 0, originScrollTop: 0, pointerClientX: originClientX, pointerClientY: originClientY, pointerOffsetX: 0, pointerOffsetY: 0, previewStart: moodboard.start, previewTrack: moodboard.track, insertRow: null, snapTime: null, snapType: null, started: true, }; // Pointer well above the first lane (into the top pad → rowFloat < 0). const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx()); expect(next.insertRow).toBe(0); // a new TOP track will be created on drop }); it("keeps a horizontal drag in the body of an expanded row out of insert mode", () => { const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H]; const clientY = RULER_H + TRACKS_TOP_PAD + rowHeights[0] - 8; const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2); const next = computeDragPreview( { ...drag, originClientY: clientY, pointerClientY: clientY }, clientX, clientY, ctx(rowHeights), ); expect(next.insertRow).toBeNull(); expect(next.previewTrack).toBe(0); }); it("uses the expanded row midpoint when choosing the side for an automatic insert", () => { const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H]; const dragged = clip("dragged", 0, 0, 1, 3); const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)]; const clientY = RULER_H + TRACKS_TOP_PAD + 30; const drag: DraggedClipState = { element: dragged, originClientX: 0, originClientY: clientY, originScrollLeft: 0, originScrollTop: 0, pointerClientX: 0, pointerClientY: clientY, pointerOffsetX: 0, pointerOffsetY: 0, previewStart: 0, previewTrack: 0, insertRow: null, snapTime: null, snapType: null, started: true, }; const next = computeDragPreview(drag, 0, clientY, { ...ctx(rowHeights, occupied), trackOrder: [0, 1], }); expect(next.insertRow).toBe(0); }); }); describe("computeResizePreview — composition source continuity", () => { it("seeds a legacy composition offset and advances it at playback rate", () => { const element = { ...clip("comp", 0, 2, 4, 0, "div"), kind: "composition" as const, playbackRate: 2, }; const result = computeResizePreview( { element, edge: "start", originClientX: 0, previewStart: 2, previewDuration: 4, started: true, }, 100, { scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] }, ); expect(result).toMatchObject({ previewStart: 3, previewDuration: 3, previewPlaybackStart: 2, }); }); });