464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
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<string, (() => 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<void>((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<void>((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<void>((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);
|
|
});
|
|
});
|
|
});
|