import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, renderHook } from '@testing-library/react'; import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player'; import type { PlayerAdapter, Version } from '@/components/video-page/types'; type Params = Parameters[0]; /** Measured from the video element's metadata, so every seek clamps to it. */ const DURATION = 60; const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2]; /** The timeline the tests drag over: 100px wide, starting at the viewport edge. */ const TIMELINE_LEFT = 0; const TIMELINE_WIDTH = 100; /** The hook builds the player inside a 100ms timeout. */ const PLAYER_INIT_DELAY_MS = 100; type FrameMetadata = { mediaTime: number; presentedFrames: number }; type FrameCallback = (now: number, metadata: FrameMetadata) => void; /** * A stand-in for the HTMLVideoElement the R2 branch of the hook drives. jsdom * has no media pipeline at all: it never fires 'play' or 'loadedmetadata', and * `duration` is a read-only NaN. This object exposes only the surface the hook * touches, and lets a test fire the media events itself so the timing is * explicit rather than accidental. */ function createVideoStub() { const listeners = new Map void>>(); let frameCallback: FrameCallback | null = null; let nextFrameCallbackId = 1; const video = { currentTime: 0, duration: DURATION, paused: true, muted: false, playbackRate: 1, seeking: false, videoWidth: 1920, videoHeight: 1080, readyState: 2, src: '', play: vi.fn(() => { video.paused = false; return Promise.resolve(); }), pause: vi.fn(() => { video.paused = true; }), load: vi.fn(), removeAttribute: vi.fn(), addEventListener: (type: string, handler: () => void) => { const forType = listeners.get(type) ?? new Set<() => void>(); forType.add(handler); listeners.set(type, forType); }, removeEventListener: (type: string, handler: () => void) => { listeners.get(type)?.delete(handler); }, requestVideoFrameCallback: vi.fn((callback: FrameCallback) => { frameCallback = callback; return nextFrameCallbackId++; }), cancelVideoFrameCallback: vi.fn(() => { frameCallback = null; }), /** Deliver a media event to whatever the hook has subscribed. */ fire: (type: string) => { for (const handler of [...(listeners.get(type) ?? [])]) handler(); }, /** Deliver one presented-frame sample to the frame-rate tracker. */ emitFrame: (metadata: FrameMetadata) => { const callback = frameCallback; frameCallback = null; callback?.(0, metadata); }, }; return video; } type VideoStub = ReturnType; function makeVersion(): Version { return { id: 'ver1', versionNumber: 1, versionLabel: null, providerId: 'r2', videoId: 'vid1', originalUrl: '/api/upload/video/abc.mp4', title: null, thumbnailUrl: null, // Left unset so the duration under test is the one measured from the // element, which is what a real page ends up using. duration: null, isActive: true, _count: { comments: 0 }, }; } function makeTimeline(): HTMLDivElement { const timeline = document.createElement('div'); // jsdom does no layout, so every rect is zero unless we supply one. timeline.getBoundingClientRect = () => ({ left: TIMELINE_LEFT, width: TIMELINE_WIDTH }) as DOMRect; document.body.appendChild(timeline); return timeline; } function renderPlayer() { const video = createVideoStub(); const timeline = makeTimeline(); const readout = document.createElement('div'); const playerRef: { current: PlayerAdapter | null } = { current: null }; const params: Params = { activeVersion: makeVersion(), activeVersionId: 'ver1', activeProviderId: 'r2', embedUrl: '/api/upload/video/abc.mp4', canInitializePlayer: true, iframeRef: { current: null }, videoRef: { current: video as unknown as HTMLVideoElement }, bunnyViewportRef: { current: null }, timelineRef: { current: timeline }, progressRef: { current: document.createElement('div') }, playheadRef: { current: document.createElement('div') }, scrubReadoutRef: { current: readout }, hlsRef: { current: null }, playerRef, formatTime: (seconds: number) => `${Math.floor(seconds)}s`, formatBunnyQualityLabel: () => 'auto', speedOptions: SPEED_OPTIONS, scheduleWatchProgressSaveRef: { current: vi.fn() }, setViewingAnnotation: vi.fn(), }; const rendered = renderHook(() => useVideoPlayer(params)); act(() => { vi.advanceTimersByTime(PLAYER_INIT_DELAY_MS); }); // Without metadata the hook has no duration, so nothing would clamp. act(() => { video.fire('loadedmetadata'); }); return { ...rendered, video, timeline, readout }; } /** Put the player into the playing state the way the media element would. */ function startPlayback(video: VideoStub) { act(() => { video.paused = false; video.fire('play'); }); } function stopPlayback(video: VideoStub) { act(() => { video.paused = true; video.fire('pause'); }); } /** * Two presented-frame samples one second apart is what the hook needs to derive * a rate; the first sample only establishes a baseline. */ function measureFrameRate(video: VideoStub, fps: number) { act(() => { video.emitFrame({ mediaTime: 0, presentedFrames: 0 }); }); act(() => { video.emitFrame({ mediaTime: 1, presentedFrames: fps }); }); } function pressKey( code: string, options: { shiftKey?: boolean; target?: EventTarget } = {} ): KeyboardEvent { const event = new KeyboardEvent('keydown', { code, shiftKey: options.shiftKey ?? false, bubbles: true, cancelable: true, }); act(() => { (options.target ?? window).dispatchEvent(event); }); return event; } function mouseEventAt(clientX: number) { return { clientX } as React.MouseEvent; } beforeEach(() => { vi.useFakeTimers(); // The hook injects the YouTube iframe API before the first