import { describe, it, expect, vi } from "vitest"; import { createRuntimePlayer } from "./player"; import type { RuntimeTimelineLike } from "./types"; function createMockTimeline(opts?: { time?: number; duration?: number }): RuntimeTimelineLike { const state = { time: opts?.time ?? 0, duration: opts?.duration ?? 10, paused: false }; return { play: vi.fn(() => { state.paused = false; }), pause: vi.fn(() => { state.paused = true; }), seek: vi.fn((t: number) => { state.time = t; }), totalTime: vi.fn((t: number) => { state.time = t; }), time: vi.fn(() => state.time), duration: vi.fn(() => state.duration), add: vi.fn(), paused: vi.fn((p?: boolean) => { if (p !== undefined) state.paused = p; }), timeScale: vi.fn(), set: vi.fn(), }; } function createMockDeps(timeline?: RuntimeTimelineLike | null) { let isPlaying = false; let playbackRate = 1; return { getTimeline: vi.fn(() => timeline ?? null), setTimeline: vi.fn(), getIsPlaying: vi.fn(() => isPlaying), setIsPlaying: vi.fn((v: boolean) => { isPlaying = v; }), getPlaybackRate: vi.fn(() => playbackRate), setPlaybackRate: vi.fn((v: number) => { playbackRate = v; }), getCanonicalFps: vi.fn(() => 30), onSyncMedia: vi.fn(), onStatePost: vi.fn(), onDeterministicSeek: vi.fn(), onDeterministicPause: vi.fn(), onDeterministicPlay: vi.fn(), onRenderFrameSeek: vi.fn(), onShowNativeVideos: vi.fn(), getSafeDuration: vi.fn(() => 10), }; } function createNestedTimelineHarness() { const createScene = (start: number, duration: number) => { const state = { time: 0, paused: false }; const timeline = { play: vi.fn(() => { state.paused = false; }), pause: vi.fn(() => { state.paused = true; }), seek: vi.fn((t: number) => { state.time = t; }), totalTime: vi.fn((t: number) => { state.time = t; }), time: vi.fn(() => state.time), duration: vi.fn(() => duration), add: vi.fn(), paused: vi.fn((p?: boolean) => { if (p !== undefined) state.paused = p; }), timeScale: vi.fn(), set: vi.fn(), } satisfies RuntimeTimelineLike; return { timeline, state, start, duration }; }; const scene1 = createScene(0, 1.5); const scene2 = createScene(1.5, 10); const scene5 = createScene(12, 3); const children = [scene1, scene2, scene5]; const masterState = { time: 0, paused: false }; const master = { play: vi.fn(() => { masterState.paused = false; }), pause: vi.fn(() => { masterState.paused = true; }), seek: vi.fn((t: number) => { masterState.time = t; for (const child of children) { if (child.state.paused) continue; child.state.time = Math.max(0, Math.min(t - child.start, child.duration)); } }), totalTime: vi.fn((t: number) => { masterState.time = t; for (const child of children) { if (child.state.paused) continue; child.state.time = Math.max(0, Math.min(t - child.start, child.duration)); } }), time: vi.fn(() => masterState.time), duration: vi.fn(() => 20), add: vi.fn(), paused: vi.fn((p?: boolean) => { if (p !== undefined) masterState.paused = p; }), timeScale: vi.fn(), set: vi.fn(), } satisfies RuntimeTimelineLike; return { master, scene1: scene1.timeline, scene2: scene2.timeline, scene5: scene5.timeline, }; } describe("createRuntimePlayer", () => { describe("play", () => { it("does nothing without a timeline", () => { const deps = createMockDeps(null); const player = createRuntimePlayer(deps); player.play(); expect(deps.setIsPlaying).not.toHaveBeenCalled(); }); it("does nothing if already playing", () => { const timeline = createMockTimeline(); const deps = createMockDeps(timeline); deps.getIsPlaying.mockReturnValue(true); const player = createRuntimePlayer(deps); player.play(); expect(timeline.play).not.toHaveBeenCalled(); }); it("plays the timeline and updates state", () => { const timeline = createMockTimeline({ time: 2, duration: 10 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); player.play(); expect(timeline.play).toHaveBeenCalled(); expect(deps.setIsPlaying).toHaveBeenCalledWith(true); expect(deps.onDeterministicPlay).toHaveBeenCalled(); expect(deps.onShowNativeVideos).toHaveBeenCalled(); expect(deps.onStatePost).toHaveBeenCalledWith(true); }); it("resets to 0 when at end of timeline", () => { const timeline = createMockTimeline({ time: 10, duration: 10 }); const deps = createMockDeps(timeline); deps.getSafeDuration.mockReturnValue(10); const player = createRuntimePlayer(deps); player.play(); expect(timeline.seek).toHaveBeenCalledWith(0, false); expect(deps.onDeterministicSeek).toHaveBeenCalledWith(0); }); it("sets timeScale to playbackRate", () => { const timeline = createMockTimeline({ time: 0, duration: 10 }); const deps = createMockDeps(timeline); deps.getPlaybackRate.mockReturnValue(2); const player = createRuntimePlayer(deps); player.play(); expect(timeline.timeScale).toHaveBeenCalledWith(2); }); }); describe("pause", () => { it("does nothing without a timeline", () => { const deps = createMockDeps(null); const player = createRuntimePlayer(deps); player.pause(); expect(deps.setIsPlaying).not.toHaveBeenCalled(); }); it("pauses the timeline and syncs media", () => { const timeline = createMockTimeline({ time: 5 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); player.pause(); expect(timeline.pause).toHaveBeenCalled(); expect(deps.setIsPlaying).toHaveBeenCalledWith(false); expect(deps.onDeterministicSeek).toHaveBeenCalledWith(5); expect(deps.onDeterministicPause).toHaveBeenCalled(); expect(deps.onSyncMedia).toHaveBeenCalledWith(5, false); expect(deps.onRenderFrameSeek).toHaveBeenCalledWith(5); expect(deps.onStatePost).toHaveBeenCalledWith(true); }); }); // Regression: nested compositions register sibling timelines alongside // the master (e.g. `scene1-logo-intro` + `scene2-4-canvas` next to the // master's own inline timeline). Before this, pausing the master would // leave siblings free-running, so scene animations kept advancing and the // composition would visibly drift past the paused time even though the // player UI was frozen. describe("timeline registry propagation", () => { it("pauses every sibling timeline, not just the master", () => { const master = createMockTimeline({ time: 5 }); const scene1 = createMockTimeline(); const scene2 = createMockTimeline(); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, scene1, scene2 }), }); player.pause(); expect(master.pause).toHaveBeenCalledTimes(1); expect(scene1.pause).toHaveBeenCalledTimes(1); expect(scene2.pause).toHaveBeenCalledTimes(1); }); it("plays every sibling timeline when the master plays", () => { const master = createMockTimeline({ time: 0, duration: 10 }); const scene1 = createMockTimeline(); const scene2 = createMockTimeline(); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, scene1, scene2 }), }); player.play(); expect(master.play).toHaveBeenCalledTimes(1); expect(scene1.play).toHaveBeenCalledTimes(1); expect(scene2.play).toHaveBeenCalledTimes(1); }); it("propagates playbackRate to siblings on play", () => { const master = createMockTimeline({ time: 0, duration: 10 }); const scene1 = createMockTimeline(); const deps = createMockDeps(master); deps.getPlaybackRate.mockReturnValue(2); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, scene1 }), }); player.play(); expect(scene1.timeScale).toHaveBeenCalledWith(2); }); it("does not call pause/play on the master twice through the registry", () => { const master = createMockTimeline({ time: 5 }); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, // The master is identity-equal to one of the registry entries. getTimelineRegistry: () => ({ main: master }), }); player.pause(); expect(master.pause).toHaveBeenCalledTimes(1); }); it("swallows errors from a broken sibling without breaking pause", () => { const master = createMockTimeline({ time: 5 }); const broken = createMockTimeline(); (broken.pause as ReturnType).mockImplementationOnce(() => { throw new Error("boom"); }); const ok = createMockTimeline(); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, broken, ok }), }); expect(() => player.pause()).not.toThrow(); expect(master.pause).toHaveBeenCalled(); expect(ok.pause).toHaveBeenCalled(); }); it("is a no-op when no registry is supplied (back-compat)", () => { const master = createMockTimeline({ time: 5 }); const deps = createMockDeps(master); const player = createRuntimePlayer(deps); expect(() => player.pause()).not.toThrow(); expect(master.pause).toHaveBeenCalled(); }); it("tolerates undefined entries in the registry", () => { const master = createMockTimeline({ time: 5 }); const scene = createMockTimeline(); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, gone: undefined, scene }), }); expect(() => player.pause()).not.toThrow(); expect(scene.pause).toHaveBeenCalled(); }); }); describe("seek", () => { it("does nothing without a timeline", () => { const deps = createMockDeps(null); const player = createRuntimePlayer(deps); player.seek(5); expect(deps.onDeterministicSeek).not.toHaveBeenCalled(); }); it("seeks to quantized time and pauses", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); player.seek(3); expect(timeline.pause).toHaveBeenCalled(); expect(timeline.totalTime).toHaveBeenCalled(); expect(deps.setIsPlaying).toHaveBeenCalledWith(false); expect(deps.onSyncMedia).toHaveBeenCalled(); expect(deps.onStatePost).toHaveBeenCalledWith(true); }); it("rearms paused sibling timelines so master seek updates their local offsets", () => { const { master, scene1, scene2, scene5 } = createNestedTimelineHarness(); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, scene1, scene2, scene5 }), }); player.pause(); player.seek(3); expect(scene1.play).toHaveBeenCalledTimes(1); expect(scene2.play).toHaveBeenCalledTimes(1); expect(scene5.play).toHaveBeenCalledTimes(1); expect(master.totalTime).toHaveBeenCalledWith(3, false); expect(scene1.time()).toBe(1.5); expect(scene2.time()).toBe(1.5); expect(scene5.time()).toBe(0); expect(scene1.pause).toHaveBeenCalledTimes(2); expect(scene2.pause).toHaveBeenCalledTimes(2); expect(scene5.pause).toHaveBeenCalledTimes(2); }); it("clamps negative time to 0", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); player.seek(-5); expect(deps.onDeterministicSeek).toHaveBeenCalledWith(0); }); it("handles NaN time", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); player.seek(NaN); expect(deps.onDeterministicSeek).toHaveBeenCalledWith(0); }); it("seeks to the exact safe duration without snapping back a frame", () => { const timeline = createMockTimeline({ duration: 8 }); const deps = createMockDeps(timeline); deps.getSafeDuration.mockReturnValue(8); const player = createRuntimePlayer(deps); player.seek(8); expect(timeline.pause).toHaveBeenCalled(); expect(timeline.totalTime).toHaveBeenCalledWith(8, false); expect(deps.onDeterministicSeek).toHaveBeenCalledWith(8); expect(deps.onSyncMedia).toHaveBeenCalledWith(8, false); }); // Regression: A/E Jump-to-in/out shortcuts (PR #842) send // `{ keepPlaying: true }` so playback survives the seek. Before this fix the // runtime always called setIsPlaying(false), so the shortcut paused playback // on every press in compositions backed by the `__player` runtime adapter. describe("keepPlaying option", () => { it("preserves play state when keepPlaying is true and playback was active", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); deps.getIsPlaying.mockReturnValue(true); const player = createRuntimePlayer(deps); player.seek(3, { keepPlaying: true }); expect(deps.setIsPlaying).not.toHaveBeenCalledWith(false); expect(deps.onDeterministicPlay).toHaveBeenCalled(); expect(deps.onShowNativeVideos).toHaveBeenCalled(); expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), true); }); it("resumes the master timeline after the deterministic seek pauses it", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); deps.getIsPlaying.mockReturnValue(true); const player = createRuntimePlayer(deps); player.seek(3, { keepPlaying: true }); // The helper pauses then seeks; the keep-playing branch must call // play() afterwards so the timeline is left running. const playMock = timeline.play as ReturnType; const pauseMock = timeline.pause as ReturnType; expect(playMock).toHaveBeenCalledTimes(1); expect(pauseMock).toHaveBeenCalled(); expect(playMock.mock.invocationCallOrder[0]).toBeGreaterThan( pauseMock.mock.invocationCallOrder[pauseMock.mock.invocationCallOrder.length - 1], ); }); it("applies playbackRate to master and siblings on resume", () => { const master = createMockTimeline({ duration: 10 }); const scene1 = createMockTimeline(); const deps = createMockDeps(master); deps.getIsPlaying.mockReturnValue(true); deps.getPlaybackRate.mockReturnValue(2); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, scene1 }), }); player.seek(3, { keepPlaying: true }); expect(master.timeScale).toHaveBeenCalledWith(2); expect(scene1.timeScale).toHaveBeenCalledWith(2); expect(scene1.play).toHaveBeenCalled(); }); it("stays paused when keepPlaying is true but playback was not active", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); deps.getIsPlaying.mockReturnValue(false); const player = createRuntimePlayer(deps); player.seek(3, { keepPlaying: true }); expect(deps.setIsPlaying).toHaveBeenCalledWith(false); expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), false); expect(deps.onDeterministicPlay).not.toHaveBeenCalled(); expect(deps.onShowNativeVideos).not.toHaveBeenCalled(); }); it("pauses on seek when keepPlaying is false (explicit)", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); deps.getIsPlaying.mockReturnValue(true); const player = createRuntimePlayer(deps); player.seek(3, { keepPlaying: false }); expect(deps.setIsPlaying).toHaveBeenCalledWith(false); expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), false); expect(deps.onDeterministicPlay).not.toHaveBeenCalled(); }); it("pauses on seek when no options are passed (default behavior unchanged)", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); deps.getIsPlaying.mockReturnValue(true); const player = createRuntimePlayer(deps); player.seek(3); expect(deps.setIsPlaying).toHaveBeenCalledWith(false); expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), false); expect(deps.onDeterministicPlay).not.toHaveBeenCalled(); }); }); }); describe("renderSeek", () => { it("seeks deterministically for render pipeline", () => { const timeline = createMockTimeline({ duration: 10 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); player.renderSeek(5); expect(timeline.pause).toHaveBeenCalled(); expect(deps.setIsPlaying).toHaveBeenCalledWith(false); expect(deps.onRenderFrameSeek).toHaveBeenCalled(); }); it("renderSeek rearms paused siblings and keeps them active for export frames", () => { const { master, scene1, scene2, scene5 } = createNestedTimelineHarness(); const deps = createMockDeps(master); const player = createRuntimePlayer({ ...deps, getTimelineRegistry: () => ({ main: master, scene1, scene2, scene5 }), }); player.pause(); player.renderSeek(5); expect(master.totalTime).toHaveBeenCalledWith(5, false); expect(scene1.time()).toBe(1.5); expect(scene2.time()).toBe(3.5); expect(scene5.time()).toBe(0); expect(scene1.play).toHaveBeenCalledTimes(1); expect(scene2.play).toHaveBeenCalledTimes(1); expect(scene5.play).toHaveBeenCalledTimes(1); expect(scene1.pause).toHaveBeenCalledTimes(1); expect(scene2.pause).toHaveBeenCalledTimes(1); expect(scene5.pause).toHaveBeenCalledTimes(1); }); }); describe("tolerates non-conformant timeline objects", () => { it("handles duration as a number property instead of a function", () => { const timeline = { play: vi.fn(), pause: vi.fn(), seek: vi.fn(), totalTime: vi.fn(), time: vi.fn(() => 2), duration: 10, add: vi.fn(), paused: vi.fn(), set: vi.fn(), } as unknown as RuntimeTimelineLike; const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); expect(player.getDuration()).toBe(10); expect(() => player.play()).not.toThrow(); }); it("handles missing pause method", () => { const timeline = { play: vi.fn(), seek: vi.fn(), time: vi.fn(() => 0), duration: vi.fn(() => 10), add: vi.fn(), paused: vi.fn(), set: vi.fn(), } as unknown as RuntimeTimelineLike; const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); expect(() => player.pause()).not.toThrow(); expect(() => player.seek(3)).not.toThrow(); }); it("handles missing play method", () => { const timeline = { pause: vi.fn(), seek: vi.fn(), time: vi.fn(() => 0), duration: vi.fn(() => 10), add: vi.fn(), paused: vi.fn(), set: vi.fn(), } as unknown as RuntimeTimelineLike; const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); expect(() => player.play()).not.toThrow(); }); it("handles time as a number property instead of a function", () => { const timeline = { play: vi.fn(), pause: vi.fn(), seek: vi.fn(), time: 5, duration: vi.fn(() => 10), add: vi.fn(), paused: vi.fn(), set: vi.fn(), } as unknown as RuntimeTimelineLike; const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); expect(player.getTime()).toBe(5); }); }); describe("getters", () => { it("getTime returns timeline time", () => { const timeline = createMockTimeline({ time: 7.5 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); expect(player.getTime()).toBe(7.5); }); it("getDuration returns timeline duration", () => { const timeline = createMockTimeline({ duration: 30 }); const deps = createMockDeps(timeline); const player = createRuntimePlayer(deps); expect(player.getDuration()).toBe(30); }); it("getTime returns 0 without timeline", () => { const deps = createMockDeps(null); const player = createRuntimePlayer(deps); expect(player.getTime()).toBe(0); }); it("getDuration returns 0 without timeline", () => { const deps = createMockDeps(null); const player = createRuntimePlayer(deps); expect(player.getDuration()).toBe(0); }); it("isPlaying delegates to deps", () => { const deps = createMockDeps(null); deps.getIsPlaying.mockReturnValue(true); const player = createRuntimePlayer(deps); expect(player.isPlaying()).toBe(true); }); it("setPlaybackRate delegates to deps", () => { const deps = createMockDeps(null); const player = createRuntimePlayer(deps); player.setPlaybackRate(1.5); expect(deps.setPlaybackRate).toHaveBeenCalledWith(1.5); }); it("getPlaybackRate delegates to deps", () => { const deps = createMockDeps(null); deps.getPlaybackRate.mockReturnValue(2); const player = createRuntimePlayer(deps); expect(player.getPlaybackRate()).toBe(2); }); }); });