import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock video element for testing seek behavior function createMockVideoElement(options: { paused?: boolean; autoplay?: boolean; currentTime?: number; } = {}) { const listeners: Record void)[]> = {}; return { paused: options.paused ?? true, autoplay: options.autoplay ?? true, currentTime: options.currentTime ?? 0, pause: vi.fn(function(this: any) { this.paused = true; }), play: vi.fn(function(this: any) { this.paused = false; return Promise.resolve(); }), addEventListener: vi.fn((event: string, handler: () => void) => { if (!listeners[event]) listeners[event] = []; listeners[event].push(handler); }), removeEventListener: vi.fn((event: string, handler: () => void) => { if (listeners[event]) { listeners[event] = listeners[event].filter(h => h !== handler); } }), // Helper to trigger events in tests _triggerEvent: (event: string) => { listeners[event]?.forEach(h => h()); }, _getListeners: () => listeners, }; } describe("VideoPlayer Resume Logic", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("handleCanPlay seek behavior", () => { it("should pause video before seeking to prevent autoplay from starting at position 0", async () => { const videoElement = createMockVideoElement({ paused: false, autoplay: true }); // Simulate the handleCanPlay logic const initialPosition = 60; const hasPerformedInitialSeek = false; if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { const wasPlaying = !videoElement.paused; videoElement.pause(); expect(videoElement.pause).toHaveBeenCalled(); expect(wasPlaying).toBe(true); } }); it("should set currentTime to initial position", async () => { const videoElement = createMockVideoElement(); const initialPosition = 120; videoElement.currentTime = initialPosition; expect(videoElement.currentTime).toBe(120); }); it("should wait for seeked event before resuming playback", async () => { const videoElement = createMockVideoElement({ paused: false, autoplay: true }); const initialPosition = 60; // Simulate handleCanPlay logic videoElement.pause(); videoElement.currentTime = initialPosition; // Create the promise that waits for seeked const seekPromise = new Promise((resolve) => { const onSeeked = () => { videoElement.removeEventListener("seeked", onSeeked); resolve(); }; videoElement.addEventListener("seeked", onSeeked); }); // Verify listener was added expect(videoElement.addEventListener).toHaveBeenCalledWith("seeked", expect.any(Function)); // Simulate seek completion videoElement._triggerEvent("seeked"); await seekPromise; // Verify listener was removed after seek expect(videoElement.removeEventListener).toHaveBeenCalledWith("seeked", expect.any(Function)); }); it("should resume playback after seek completes when autoplay is enabled", async () => { const videoElement = createMockVideoElement({ paused: false, autoplay: true }); const initialPosition = 60; // Simulate handleCanPlay logic const wasPlaying = !videoElement.paused; videoElement.pause(); videoElement.currentTime = initialPosition; // Wait for seeked const seekPromise = new Promise((resolve) => { const onSeeked = () => { videoElement.removeEventListener("seeked", onSeeked); resolve(); }; videoElement.addEventListener("seeked", onSeeked); }); videoElement._triggerEvent("seeked"); await seekPromise; // Resume playback if (wasPlaying || videoElement.autoplay) { await videoElement.play(); } expect(videoElement.play).toHaveBeenCalled(); }); it("should not resume playback if video was paused and has no autoplay", async () => { const videoElement = createMockVideoElement({ paused: true, autoplay: false }); const initialPosition = 60; const wasPlaying = !videoElement.paused; videoElement.pause(); videoElement.currentTime = initialPosition; // Resume playback check if (wasPlaying || videoElement.autoplay) { await videoElement.play(); } expect(videoElement.play).not.toHaveBeenCalled(); }); it("should have fallback timeout in case seeked event doesn't fire", async () => { const videoElement = createMockVideoElement(); const initialPosition = 60; videoElement.currentTime = initialPosition; let resolved = false; const seekPromise = new Promise((resolve) => { const onSeeked = () => { videoElement.removeEventListener("seeked", onSeeked); resolve(); }; videoElement.addEventListener("seeked", onSeeked); // Fallback timeout setTimeout(() => { videoElement.removeEventListener("seeked", onSeeked); resolved = true; resolve(); }, 2000); }); // Don't trigger seeked event - rely on timeout vi.advanceTimersByTime(2000); await seekPromise; expect(resolved).toBe(true); }); it("should not seek if initialPosition is 0", () => { const videoElement = createMockVideoElement(); const initialPosition = 0; const hasPerformedInitialSeek = false; let seekPerformed = false; if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { seekPerformed = true; } expect(seekPerformed).toBe(false); }); it("should not seek if hasPerformedInitialSeek is true", () => { const videoElement = createMockVideoElement(); const initialPosition = 60; const hasPerformedInitialSeek = true; let seekPerformed = false; if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { seekPerformed = true; } expect(seekPerformed).toBe(false); }); it("should not seek if videoElement is null", () => { const videoElement = null; const initialPosition = 60; const hasPerformedInitialSeek = false; let seekPerformed = false; if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { seekPerformed = true; } expect(seekPerformed).toBe(false); }); }); describe("hasPerformedInitialSeek flag", () => { it("should be set to true after seek is initiated", () => { const videoElement = createMockVideoElement(); const initialPosition = 60; let hasPerformedInitialSeek = false; if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { hasPerformedInitialSeek = true; videoElement.currentTime = initialPosition; } expect(hasPerformedInitialSeek).toBe(true); }); it("should be reset to false when streamUrl changes", () => { let hasPerformedInitialSeek = true; let currentStreamUrl = "url1"; // Simulate $effect when streamUrl changes const newStreamUrl = "url2"; if (newStreamUrl !== currentStreamUrl) { currentStreamUrl = newStreamUrl; hasPerformedInitialSeek = false; } expect(hasPerformedInitialSeek).toBe(false); }); it("should prevent duplicate seeks on multiple canplay events", () => { const videoElement = createMockVideoElement(); const initialPosition = 60; let hasPerformedInitialSeek = false; let seekCount = 0; // First canplay if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { hasPerformedInitialSeek = true; videoElement.currentTime = initialPosition; seekCount++; } // Second canplay (shouldn't seek) if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) { hasPerformedInitialSeek = true; videoElement.currentTime = initialPosition; seekCount++; } expect(seekCount).toBe(1); }); }); describe("initialPosition change handling", () => { it("should seek when initialPosition changes after initial seek was done", () => { const videoElement = createMockVideoElement(); let hasPerformedInitialSeek = true; const isMediaReady = true; let currentTime = 60; // Simulate new position const newPosition = 120; if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { hasPerformedInitialSeek = false; videoElement.currentTime = newPosition; currentTime = newPosition; } expect(videoElement.currentTime).toBe(120); expect(currentTime).toBe(120); expect(hasPerformedInitialSeek).toBe(false); }); it("should not seek if media is not ready", () => { const videoElement = createMockVideoElement(); const hasPerformedInitialSeek = true; const isMediaReady = false; const newPosition = 120; let seekTriggered = false; if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { seekTriggered = true; } expect(seekTriggered).toBe(false); }); it("should not seek if initial seek hasn't been performed yet", () => { const videoElement = createMockVideoElement(); const hasPerformedInitialSeek = false; const isMediaReady = true; const newPosition = 120; let seekTriggered = false; if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { seekTriggered = true; } expect(seekTriggered).toBe(false); }); }); describe("seekOffset handling for transcoded streams", () => { it("should reset seekOffset to 0 when streamUrl changes", () => { let seekOffset = 120; let currentStreamUrl = "url1"; // Simulate $effect when streamUrl changes const newStreamUrl = "url2"; if (newStreamUrl !== currentStreamUrl) { currentStreamUrl = newStreamUrl; seekOffset = 0; } expect(seekOffset).toBe(0); }); it("should add seekOffset to currentTime for transcoded streams", () => { const seekOffset = 60; const videoElementTime = 30; // Video thinks it's at 30s const currentTime = seekOffset + videoElementTime; expect(currentTime).toBe(90); // Actual position is 90s }); }); describe("error handling", () => { it("should handle seek errors gracefully", async () => { const videoElement = createMockVideoElement(); const initialPosition = 60; let errorCaught = false; // Simulate a video element that throws on currentTime set Object.defineProperty(videoElement, 'currentTime', { set: () => { throw new Error('Seek not allowed'); }, get: () => 0, }); try { videoElement.currentTime = initialPosition; } catch (err) { errorCaught = true; } expect(errorCaught).toBe(true); }); it("should handle play() rejection gracefully", async () => { const videoElement = createMockVideoElement(); videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked')); let errorCaught = false; try { await videoElement.play(); } catch (err) { errorCaught = true; } expect(errorCaught).toBe(true); }); }); }); describe("Resume Dialog Logic", () => { describe("progress eligibility", () => { it("should show resume dialog when watched > 30 seconds and < 90% complete", () => { const positionSeconds = 60; const totalSeconds = 3600; // 1 hour video const progressPercent = (positionSeconds / totalSeconds) * 100; const shouldShow = positionSeconds > 30 && progressPercent < 90; expect(shouldShow).toBe(true); }); it("should not show resume dialog when watched <= 30 seconds", () => { const positionSeconds = 25; const totalSeconds = 3600; const progressPercent = (positionSeconds / totalSeconds) * 100; const shouldShow = positionSeconds > 30 && progressPercent < 90; expect(shouldShow).toBe(false); }); it("should not show resume dialog when >= 90% complete", () => { const positionSeconds = 3300; // 55 minutes of 1 hour video const totalSeconds = 3600; const progressPercent = (positionSeconds / totalSeconds) * 100; const shouldShow = positionSeconds > 30 && progressPercent < 90; expect(shouldShow).toBe(false); }); it("should handle edge case at exactly 30 seconds", () => { const positionSeconds = 30; const totalSeconds = 3600; const progressPercent = (positionSeconds / totalSeconds) * 100; const shouldShow = positionSeconds > 30 && progressPercent < 90; expect(shouldShow).toBe(false); // > 30, not >= 30 }); it("should handle edge case at exactly 90%", () => { const positionSeconds = 3240; // Exactly 90% of 3600 const totalSeconds = 3600; const progressPercent = (positionSeconds / totalSeconds) * 100; const shouldShow = positionSeconds > 30 && progressPercent < 90; expect(shouldShow).toBe(false); // < 90, not <= 90 }); }); describe("position tick conversion", () => { it("should convert ticks to seconds correctly", () => { const positionTicks = 600_000_000; // 60 seconds in ticks const positionSeconds = positionTicks / 10_000_000; expect(positionSeconds).toBe(60); }); it("should convert seconds to ticks correctly", () => { const positionSeconds = 120; const positionTicks = positionSeconds * 10_000_000; expect(positionTicks).toBe(1_200_000_000); }); it("should handle large tick values", () => { const positionTicks = 36_000_000_000; // 1 hour in ticks const positionSeconds = positionTicks / 10_000_000; expect(positionSeconds).toBe(3600); }); }); });