// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore"; import { TRACK_H } from "./timelineLayout"; import { buildStackingTimelineLayers } from "./timelineTrackOrder"; import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; function timelineElement(input: { id: string; track: number; zIndex: number; start?: number; duration?: number; sourceDuration?: number; }): TimelineElement { return { id: input.id, domId: input.id, tag: "div", start: input.start ?? 0, duration: input.duration ?? 2, sourceDuration: input.sourceDuration, track: input.track, zIndex: input.zIndex, stackingContextId: "root", parentCompositionId: null, compositionAncestors: ["root"], sourceFile: "index.html", timingSource: "authored", }; } afterEach(() => { document.body.innerHTML = ""; usePlayerStore.getState().reset(); }); function renderDragHarness(elements: TimelineElement[]) { const layers = buildStackingTimelineLayers(elements).rows; const scroll = document.createElement("div"); document.body.append(scroll); const onMoveElement = vi.fn(); const onResizeElement = vi.fn(); let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null; let setResizingClip: ((state: ResizingClipState | null) => void) | null = null; function Harness() { const hook = useTimelineClipDrag({ scrollRef: { current: scroll }, ppsRef: { current: 100 }, trackOrderRef: { current: layers.map((layer) => layer.id) }, timelineLayersRef: { current: layers }, timelineElementsRef: { current: elements }, onMoveElement, onResizeElement, onBlockedEditAttempt: vi.fn(), setShowPopover: vi.fn(), setRangeSelectionRef: { current: vi.fn() }, }); setDraggedClip = hook.setDraggedClip; setResizingClip = hook.setResizingClip; return null; } const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render(); }); if (!setDraggedClip) throw new Error("Expected drag setter"); if (!setResizingClip) throw new Error("Expected resize setter"); const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip; const applyResizingClip: (state: ResizingClipState | null) => void = setResizingClip; return { layers, onMoveElement, onResizeElement, startDrag(element: TimelineElement, layerIndex: number) { act(() => { applyDraggedClip({ element, originClientX: 0, originClientY: 0, originScrollLeft: 0, originScrollTop: 0, pointerClientX: 0, pointerClientY: 0, pointerOffsetX: 0, pointerOffsetY: 0, previewStart: element.start, previewTrack: element.track, previewLayerId: layers[layerIndex]!.id, previewLayerIndex: layerIndex, previewStackingReorder: null, snapBeatTime: null, snapGuideTime: null, snapGuideKind: null, started: false, }); }); }, startResize(element: TimelineElement, edge: "start" | "end") { act(() => { applyResizingClip({ element, edge, originClientX: 0, previewStart: element.start, previewDuration: element.duration, previewPlaybackStart: element.playbackStart, snapGuideTime: null, snapGuideKind: null, started: false, }); }); }, movePointer(clientX: number, clientY: number) { act(() => { window.dispatchEvent( new MouseEvent("pointermove", { bubbles: true, clientX, clientY, }), ); }); }, async dropPointer() { await act(async () => { window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); }); }, unmount() { act(() => root.unmount()); }, }; } describe("useTimelineClipDrag", () => { it("allows moving a clip past the current composition duration", async () => { const clip = timelineElement({ id: "clip", track: 0, zIndex: 1 }); const harness = renderDragHarness([clip]); harness.startDrag(clip, 0); harness.movePointer(1100, 0); await harness.dropPointer(); expect(harness.onMoveElement).toHaveBeenCalledWith( clip, expect.objectContaining({ start: 11 }), ); harness.unmount(); }); it("allows right-edge resize past the current composition duration", async () => { const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 }); const harness = renderDragHarness([clip]); harness.startResize(clip, "end"); harness.movePointer(400, 0); await harness.dropPointer(); expect(harness.onResizeElement).toHaveBeenCalledWith( clip, expect.objectContaining({ start: 6, duration: 6 }), ); harness.unmount(); }); it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => { const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 }); const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); const harness = renderDragHarness([front, middle, back]); harness.startDrag(back, 2); harness.movePointer(0, -2 * TRACK_H); await harness.dropPointer(); expect(harness.onMoveElement).toHaveBeenCalledTimes(1); expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({ start: 0, track: 2, stackingReorder: { contextKey: "root", placement: { type: "above", layerId: harness.layers[0]!.id }, zIndexChanges: [{ key: "back", zIndex: 4 }], }, }); harness.unmount(); }); it("uses the preview start when resolving whether a dragged clip can join a lane", async () => { const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); const back = timelineElement({ id: "back", track: 1, zIndex: 1 }); back.start = 0; front.start = 0; const harness = renderDragHarness([front, back]); harness.startDrag(back, 1); harness.movePointer(200, -TRACK_H); await harness.dropPointer(); expect(harness.onMoveElement).toHaveBeenCalledTimes(1); expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({ start: 2, track: 1, stackingReorder: { contextKey: "root", placement: { type: "onto", layerId: harness.layers[0]!.id }, zIndexChanges: [{ key: "back", zIndex: 3 }], }, }); harness.unmount(); }); });