diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-31 15:47:30 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-31 15:47:30 +0200 |
| commit | 70a6d58b8432e455ab1443d45bc23dd70b363bea (patch) | |
| tree | b4bcdb88b87cbd2cd295c54cde013f38481c7f2a /apps/web/src/lib/timeline/placement/__tests__ | |
| parent | 5cc6abc3a44c9d046a1cb71a8c916f1d35827e8f (diff) | |
| download | opencut-70a6d58b8432e455ab1443d45bc23dd70b363bea.tar.gz | |
refactor: centralize track placement into its own module
Diffstat (limited to 'apps/web/src/lib/timeline/placement/__tests__')
| -rw-r--r-- | apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts | 462 |
1 files changed, 462 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts new file mode 100644 index 00000000..db9c38f3 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, test } from "bun:test"; +import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline"; +import { resolveTrackPlacement } from "@/lib/track-placement"; + +function buildElement({ + id, + type, + startTime, + duration, +}: { + id: string; + type: ElementType; + startTime: number; + duration: number; +}): TimelineElement { + return { + id, + type, + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + } as TimelineElement; +} + +function buildTrack({ + id, + type, + elements = [], + isMain = false, +}: { + id: string; + type: TrackType; + elements?: TimelineElement[]; + isMain?: boolean; +}): TimelineTrack { + if (type === "video") { + return { + id, + type, + name: id, + elements: elements as TimelineTrack["elements"], + isMain, + muted: false, + hidden: false, + }; + } + + if (type === "audio") { + return { + id, + type, + name: id, + elements: elements as TimelineTrack["elements"], + muted: false, + }; + } + + return { + id, + type, + name: id, + elements: elements as TimelineTrack["elements"], + hidden: false, + }; +} + +function buildTimeSpan({ + startTime, + duration, + excludeElementId, +}: { + startTime: number; + duration: number; + excludeElementId?: string; +}) { + return { startTime, duration, excludeElementId }; +} + +describe("resolveTrackPlacement", () => { + test("explicit returns the requested compatible track", () => { + const tracks = [buildTrack({ id: "text-1", type: "text" })]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })], + strategy: { type: "explicit", trackId: "text-1" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-1", + trackIndex: 0, + trackType: "text", + }); + }); + + test("explicit rejects missing and incompatible tracks", () => { + const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { type: "explicit", trackId: "missing" }, + }), + ).toBeNull(); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { type: "explicit", trackId: "video-1" }, + }), + ).toBeNull(); + }); + + test("firstAvailable picks the first compatible track without overlap", () => { + const tracks = [ + buildTrack({ + id: "text-1", + type: "text", + elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + }), + buildTrack({ id: "text-2", type: "text" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-2", + trackIndex: 1, + trackType: "text", + }); + }); + + test("firstAvailable creates a new track when all compatible tracks are full", () => { + const tracks = [ + buildTrack({ + id: "graphic-1", + type: "graphic", + elements: [ + buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ + id: "video-main", + type: "video", + isMain: true, + }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "graphic", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "graphic", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("preferIndex uses the preferred track when it fits", () => { + const tracks = [buildTrack({ id: "audio-1", type: "audio" })]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "audio-1", + trackIndex: 0, + trackType: "audio", + }); + }); + + test("preferIndex creates a new overlay track above the main track", () => { + const tracks = [ + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "graphic", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })], + strategy: { + type: "preferIndex", + trackIndex: 1, + hoverDirection: "below", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "graphic", + insertIndex: 0, + insertPosition: "above", + }); + }); + + test("preferIndex keeps audio tracks below the main track", () => { + const tracks = [ + buildTrack({ id: "text-1", type: "text" }), + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "above", + createNewTrackOnly: true, + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: "below", + }); + }); + + test("aboveSource tries the track above source, then any compatible track", () => { + const tracks = [ + buildTrack({ id: "text-top", type: "text" }), + buildTrack({ + id: "text-middle", + type: "text", + elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + }), + buildTrack({ id: "text-source", type: "text" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "aboveSource", sourceTrackIndex: 2 }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-top", + trackIndex: 0, + trackType: "text", + }); + }); + + test("aboveSource creates a new track near the source when none fit", () => { + const tracks = [ + buildTrack({ + id: "text-top", + type: "text", + elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + }), + buildTrack({ + id: "text-source", + type: "text", + elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })], + }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "aboveSource", sourceTrackIndex: 1 }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "text", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("alwaysNew honors highest and default insertion rules", () => { + const tracks = [ + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "default" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: null, + }); + }); + + test("batch time spans reject tracks when any span overlaps", () => { + const tracks = [ + buildTrack({ + id: "audio-1", + type: "audio", + elements: [ + buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), + buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), + ], + }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [ + buildTimeSpan({ startTime: 2.5, duration: 1 }), + buildTimeSpan({ startTime: 5.5, duration: 1 }), + ], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("handles empty timelines, single tracks, and track-type derivation", () => { + expect( + resolveTrackPlacement({ + tracks: [], + elementType: "video", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + createNewTrackOnly: true, + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "video", + insertIndex: 0, + insertPosition: null, + }); + + expect( + resolveTrackPlacement({ + tracks: [buildTrack({ id: "audio-1", type: "audio" })], + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "default" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("existingTrack on main video includes adjustedStartTime when start snaps", () => { + const tracks = [ + buildTrack({ + id: "video-main", + type: "video", + isMain: true, + elements: [ + buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), + ], + }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "video", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })], + strategy: { type: "explicit", trackId: "video-main" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "video-main", + trackIndex: 0, + trackType: "video", + adjustedStartTime: 0, + }); + }); + + test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { + const tracks = [ + buildTrack({ id: "text-1", type: "text" }), + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "above", + verticalDragDirection: "down", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: "below", + }); + }); +}); |
