import { renderHook } from '@testing-library/react'; import { usePlayerKeyboardNavigation } from '../usePlayerKeyboardNavigation'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const makeContainerRef = () => { const el = document.createElement('div'); document.body.appendChild(el); return { current: el as HTMLElement | null }; }; const makeOptions = (overrides: Partial[0]> = {}) => ({ containerRef: makeContainerRef(), isVertical: false, currentPostType: 'image' as string, handleClose: jest.fn(), handleNextPost: jest.fn(), handlePreviousPost: jest.fn(), togglePlay: jest.fn(), toggleMute: jest.fn(), ...overrides, }); /** Fire a synthetic keydown on window. */ const fireKey = (key: string, target?: EventTarget) => { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); if (target) { Object.defineProperty(event, 'target', { value: target }); } window.dispatchEvent(event); return event; }; const fireKeyOn = (key: string, el: HTMLElement) => { el.focus(); return fireKey(key); }; afterEach(() => { document.body.innerHTML = ''; jest.restoreAllMocks(); }); // --------------------------------------------------------------------------- // Registration / cleanup // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – registration', () => { it('registers a keydown listener on window', () => { const spy = jest.spyOn(window, 'addEventListener'); const options = makeOptions(); renderHook(() => usePlayerKeyboardNavigation(options)); expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); it('removes the keydown listener on unmount', () => { const spy = jest.spyOn(window, 'removeEventListener'); const options = makeOptions(); const { unmount } = renderHook(() => usePlayerKeyboardNavigation(options)); unmount(); expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); }); // --------------------------------------------------------------------------- // Escape // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – Escape', () => { it('calls handleClose', () => { const options = makeOptions(); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('Escape'); expect(options.handleClose).toHaveBeenCalledTimes(1); }); it('does not call any navigation handler', () => { const options = makeOptions(); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('Escape'); expect(options.handleNextPost).not.toHaveBeenCalled(); expect(options.handlePreviousPost).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Horizontal mode (isVertical = false) // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – horizontal mode', () => { it('ArrowRight calls handleNextPost', () => { const options = makeOptions({ isVertical: false }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowRight'); expect(options.handleNextPost).toHaveBeenCalledTimes(1); expect(options.handlePreviousPost).not.toHaveBeenCalled(); }); it('ArrowLeft calls handlePreviousPost', () => { const options = makeOptions({ isVertical: false }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowLeft'); expect(options.handlePreviousPost).toHaveBeenCalledTimes(1); expect(options.handleNextPost).not.toHaveBeenCalled(); }); it('ArrowDown does nothing in horizontal mode', () => { const options = makeOptions({ isVertical: false }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowDown'); expect(options.handleNextPost).not.toHaveBeenCalled(); }); it('ArrowUp does nothing in horizontal mode', () => { const options = makeOptions({ isVertical: false }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowUp'); expect(options.handlePreviousPost).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Vertical mode (isVertical = true) // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – vertical mode', () => { it('ArrowDown calls handleNextPost', () => { const options = makeOptions({ isVertical: true }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowDown'); expect(options.handleNextPost).toHaveBeenCalledTimes(1); expect(options.handlePreviousPost).not.toHaveBeenCalled(); }); it('ArrowUp calls handlePreviousPost', () => { const options = makeOptions({ isVertical: true }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowUp'); expect(options.handlePreviousPost).toHaveBeenCalledTimes(1); expect(options.handleNextPost).not.toHaveBeenCalled(); }); it('ArrowRight does nothing in vertical mode', () => { const options = makeOptions({ isVertical: true }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowRight'); expect(options.handleNextPost).not.toHaveBeenCalled(); }); it('ArrowLeft does nothing in vertical mode', () => { const options = makeOptions({ isVertical: true }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('ArrowLeft'); expect(options.handlePreviousPost).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Video-only shortcuts (Space, k, m) // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – video shortcuts', () => { it('Space calls togglePlay for a video post', () => { const options = makeOptions({ currentPostType: 'video' }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey(' '); expect(options.togglePlay).toHaveBeenCalledTimes(1); }); it('k calls togglePlay for a video post', () => { const options = makeOptions({ currentPostType: 'video' }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('k'); expect(options.togglePlay).toHaveBeenCalledTimes(1); }); it('m calls toggleMute for a video post', () => { const options = makeOptions({ currentPostType: 'video' }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('m'); expect(options.toggleMute).toHaveBeenCalledTimes(1); }); it('Space does NOT call togglePlay for a non-video post', () => { const options = makeOptions({ currentPostType: 'image' }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey(' '); expect(options.togglePlay).not.toHaveBeenCalled(); }); it('k does NOT call togglePlay for a non-video post', () => { const options = makeOptions({ currentPostType: 'image' }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('k'); expect(options.togglePlay).not.toHaveBeenCalled(); }); it('m does NOT call toggleMute for a non-video post', () => { const options = makeOptions({ currentPostType: 'image' }); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('m'); expect(options.toggleMute).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Interactive-element guard (Space / Enter on buttons/links) // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – interactive element guard', () => { const interactiveTags = ['button', 'a'] as const; it.each(interactiveTags)( 'Space on a focused <%s> does not call togglePlay', (tag) => { const options = makeOptions({ currentPostType: 'video' }); renderHook(() => usePlayerKeyboardNavigation(options)); const el = document.createElement(tag); // without href is not focusable in JSDOM; href makes it a real link. if (tag === 'a') (el as HTMLAnchorElement).href = '#'; document.body.appendChild(el); fireKeyOn(' ', el); expect(options.togglePlay).not.toHaveBeenCalled(); }, ); it.each(interactiveTags)( 'Enter on a focused <%s> does not call handleClose', (tag) => { // Enter is not mapped to handleClose, but we verify the guard doesn't // accidentally suppress unrelated keys that happen to match the guard condition. const options = makeOptions({ currentPostType: 'video' }); renderHook(() => usePlayerKeyboardNavigation(options)); const el = document.createElement(tag); document.body.appendChild(el); fireKeyOn('Enter', el); // handleClose is only triggered by Escape, not Enter expect(options.handleClose).not.toHaveBeenCalled(); }, ); it('Space on the container itself (non-interactive) still calls togglePlay', () => { const options = makeOptions({ currentPostType: 'video' }); renderHook(() => usePlayerKeyboardNavigation(options)); // container is not matched by the interactive selector, so Space should fire fireKeyOn(' ', options.containerRef.current!); expect(options.togglePlay).toHaveBeenCalledTimes(1); }); }); // --------------------------------------------------------------------------- // Unrelated keys // --------------------------------------------------------------------------- describe('usePlayerKeyboardNavigation – unrelated keys', () => { it('does not call any handler for Tab', () => { const options = makeOptions(); renderHook(() => usePlayerKeyboardNavigation(options)); fireKey('Tab'); expect(options.handleClose).not.toHaveBeenCalled(); expect(options.handleNextPost).not.toHaveBeenCalled(); expect(options.handlePreviousPost).not.toHaveBeenCalled(); expect(options.togglePlay).not.toHaveBeenCalled(); expect(options.toggleMute).not.toHaveBeenCalled(); }); });