Files
jellytau/src/lib/components/player/VideoPlayer.logic.test.ts
T
2026-01-26 22:21:54 +01:00

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);
});
});
});