summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts')
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts462
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..95424160
--- /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/timeline/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",
+ });
+ });
+});