import { renderHook, act } from '@testing-library/react'; import React, { ReactNode, createElement } from 'react'; import usePlayerNavigation, { PLAYER_ANIMATION_DURATION_MS, SLIDE_DURATION_MS, } from '../usePlayerNavigation'; import { StoreContext, initial } from '../../services/store'; import { IMedia } from '../../types'; // ─── Fixtures ──────────────────────────────────────────────────────────────── const makeMedia = (index: number, type: IMedia['type'] = 'image'): IMedia => ({ id: `post-${index}`, type, caption: `Caption ${index}`, source: 'instagram', post: `https://post.com/${index}`, image: `https://image.com/${index}.jpg`, thumbnail: type === 'video' ? `https://thumb.com/${index}.jpg` : undefined, video: type === 'video' ? `https://video.com/${index}.mp4` : undefined, postIndex: index, productsLinked: {}, }); const medias = [makeMedia(0), makeMedia(1), makeMedia(2)]; // ─── Store mock helpers ─────────────────────────────────────────────────────── const mockTriggerEvent = jest.fn(); const makeStore = (overrides?: Partial) => ({ ...initial, data: { settings: {} as any, content: { medias }, id: 'widget-1', }, triggerEvent: mockTriggerEvent, setStoreState: jest.fn(), ...overrides, }); const makeWrapper = (store: ReturnType) => ({ children }: { children: ReactNode }) => createElement(StoreContext.Provider, { value: store as any }, children); // Default render — starts on the middle post so both prev and next exist const renderNav = ( postIndex = 1, { onClose = jest.fn(), onPostChange = jest.fn(), store = makeStore(), }: { onClose?: jest.Mock; onPostChange?: jest.Mock; store?: ReturnType; } = {}, ) => renderHook( () => usePlayerNavigation({ post: medias[postIndex], onClose, onPostChange, }), { wrapper: makeWrapper(store) }, ); // ─── Tests ──────────────────────────────────────────────────────────────────── describe('usePlayerNavigation', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); // ─── Initial state ────────────────────────────────────────────────────────── describe('initial state', () => { it('exposes the provided post as currentDisplayPost', () => { const { result } = renderNav(1); expect(result.current.currentDisplayPost).toBe(medias[1]); }); it('resolves the correct prev and next neighbours', () => { const { result } = renderNav(1); expect(result.current.prevDisplayPost).toBe(medias[0]); expect(result.current.nextDisplayPost).toBe(medias[2]); }); it('has null prevDisplayPost for the first post', () => { const { result } = renderNav(0); expect(result.current.prevDisplayPost).toBeNull(); }); it('has null nextDisplayPost for the last post', () => { const { result } = renderNav(2); expect(result.current.nextDisplayPost).toBeNull(); }); it('starts muted', () => { const { result } = renderNav(); expect(result.current.isMuted).toBe(true); }); it('starts playing', () => { const { result } = renderNav(); expect(result.current.isPlaying).toBe(true); }); it('starts not visible (for the enter animation)', () => { const { result } = renderNav(); expect(result.current.isVisible).toBe(false); }); it('starts not closing', () => { const { result } = renderNav(); expect(result.current.isClosing).toBe(false); }); it('starts with no slide in progress', () => { const { result } = renderNav(); expect(result.current.isSliding).toBe(false); expect(result.current.slideOffset).toBe(0); }); }); // ─── Visibility animation ─────────────────────────────────────────────────── describe('enter animation', () => { it('becomes visible after the 50 ms mount delay', () => { const { result } = renderNav(); expect(result.current.isVisible).toBe(false); act(() => { jest.advanceTimersByTime(50); }); expect(result.current.isVisible).toBe(true); }); }); // ─── triggerEvent ─────────────────────────────────────────────────────────── describe('analytics events', () => { it('fires the opened event on mount', () => { renderNav(1); expect(mockTriggerEvent).toHaveBeenCalledWith( 'mobilePlayerOpened', { post: medias[1] }, 'widget-1', ); }); it('fires the closed event when handleClose is called', () => { const { result } = renderNav(1); act(() => { result.current.handleClose(); }); expect(mockTriggerEvent).toHaveBeenCalledWith( 'mobilePlayerClosed', { post: medias[1] }, 'widget-1', ); }); it('fires the navigation event when moving to the next post', () => { const { result } = renderNav(1); act(() => { result.current.handleNextPost(); }); expect(mockTriggerEvent).toHaveBeenCalledWith( 'mobilePlayerNavigation', { direction: 'right', post: medias[2] }, 'widget-1', ); }); it('fires the navigation event when moving to the previous post', () => { const { result } = renderNav(1); act(() => { result.current.handlePreviousPost(); }); expect(mockTriggerEvent).toHaveBeenCalledWith( 'mobilePlayerNavigation', { direction: 'left', post: medias[0] }, 'widget-1', ); }); }); // ─── Body overflow ───────────────────────────────────────────────────────── describe('body overflow lock', () => { it('sets body overflow to hidden on mount', () => { renderNav(); expect(document.body.style.overflow).toBe('hidden'); }); it('restores the original body overflow on unmount', () => { document.body.style.overflow = 'auto'; const { unmount } = renderNav(); expect(document.body.style.overflow).toBe('hidden'); unmount(); expect(document.body.style.overflow).toBe('auto'); }); }); // ─── toggleMute & togglePlay ──────────────────────────────────────────────── describe('toggleMute', () => { it('flips isMuted to false', () => { const { result } = renderNav(); act(() => { result.current.toggleMute(); }); expect(result.current.isMuted).toBe(false); }); it('flips isMuted back to true on second call', () => { const { result } = renderNav(); act(() => { result.current.toggleMute(); }); act(() => { result.current.toggleMute(); }); expect(result.current.isMuted).toBe(true); }); }); describe('togglePlay', () => { it('flips isPlaying to false', () => { const { result } = renderNav(); act(() => { result.current.togglePlay(); }); expect(result.current.isPlaying).toBe(false); }); it('flips isPlaying back to true on second call', () => { const { result } = renderNav(); act(() => { result.current.togglePlay(); }); act(() => { result.current.togglePlay(); }); expect(result.current.isPlaying).toBe(true); }); }); // ─── handleClose ─────────────────────────────────────────────────────────── describe('handleClose', () => { it('sets isClosing to true immediately', () => { const { result } = renderNav(); act(() => { result.current.handleClose(); }); expect(result.current.isClosing).toBe(true); }); it('calls onClose after PLAYER_ANIMATION_DURATION_MS', () => { const onClose = jest.fn(); const { result } = renderNav(1, { onClose }); act(() => { result.current.handleClose(); }); expect(onClose).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(PLAYER_ANIMATION_DURATION_MS); }); expect(onClose).toHaveBeenCalledTimes(1); }); }); // ─── handleNextPost ───────────────────────────────────────────────────────── describe('handleNextPost', () => { it('starts a slide-right animation immediately', () => { const { result } = renderNav(1); act(() => { result.current.handleNextPost(); }); expect(result.current.isSliding).toBe(true); expect(result.current.slideOffset).toBe(-100); }); it('updates currentDisplayPost to next post after SLIDE_DURATION_MS', () => { const onPostChange = jest.fn(); const { result } = renderNav(1, { onPostChange }); act(() => { result.current.handleNextPost(); }); act(() => { jest.advanceTimersByTime(SLIDE_DURATION_MS); }); expect(result.current.currentDisplayPost).toBe(medias[2]); expect(result.current.isSliding).toBe(false); expect(result.current.slideOffset).toBe(0); }); it('calls onPostChange with the new post after the slide', () => { const onPostChange = jest.fn(); const { result } = renderNav(1, { onPostChange }); act(() => { result.current.handleNextPost(); }); act(() => { jest.advanceTimersByTime(SLIDE_DURATION_MS); }); expect(onPostChange).toHaveBeenCalledWith(medias[2]); }); it('resumes playing after navigating', () => { const { result } = renderNav(1); act(() => { result.current.togglePlay(); }); // pause first act(() => { result.current.handleNextPost(); }); act(() => { jest.advanceTimersByTime(SLIDE_DURATION_MS); }); expect(result.current.isPlaying).toBe(true); }); it('calls handleClose when there is no next post', () => { const onClose = jest.fn(); const { result } = renderNav(2, { onClose }); // last post act(() => { result.current.handleNextPost(); }); act(() => { jest.advanceTimersByTime(PLAYER_ANIMATION_DURATION_MS); }); expect(onClose).toHaveBeenCalledTimes(1); }); it('ignores a second call while a slide is in progress', () => { const onPostChange = jest.fn(); const { result } = renderNav(0, { onPostChange }); act(() => { result.current.handleNextPost(); result.current.handleNextPost(); // should be ignored }); act(() => { jest.advanceTimersByTime(SLIDE_DURATION_MS); }); // onPostChange should only be called once (for medias[1], not medias[2]) expect(onPostChange).toHaveBeenCalledTimes(1); expect(onPostChange).toHaveBeenCalledWith(medias[1]); }); }); // ─── handlePreviousPost ───────────────────────────────────────────────────── describe('handlePreviousPost', () => { it('starts a slide-left animation immediately', () => { const { result } = renderNav(1); act(() => { result.current.handlePreviousPost(); }); expect(result.current.isSliding).toBe(true); expect(result.current.slideOffset).toBe(100); }); it('updates currentDisplayPost to prev post after SLIDE_DURATION_MS', () => { const onPostChange = jest.fn(); const { result } = renderNav(1, { onPostChange }); act(() => { result.current.handlePreviousPost(); }); act(() => { jest.advanceTimersByTime(SLIDE_DURATION_MS); }); expect(result.current.currentDisplayPost).toBe(medias[0]); expect(onPostChange).toHaveBeenCalledWith(medias[0]); }); it('does nothing when there is no previous post', () => { const onPostChange = jest.fn(); const { result } = renderNav(0, { onPostChange }); // first post act(() => { result.current.handlePreviousPost(); }); expect(result.current.isSliding).toBe(false); expect(onPostChange).not.toHaveBeenCalled(); }); }); // ─── showNextIndicator ────────────────────────────────────────────────────── describe('showNextIndicator', () => { it('is true initially when a next post exists and user has not interacted', () => { const { result } = renderNav(0); // has next expect(result.current.showNextIndicator).toBe(true); }); it('is false when there is no next post', () => { const { result } = renderNav(2); // last post expect(result.current.showNextIndicator).toBe(false); }); it('becomes false after the user navigates', () => { const { result } = renderNav(0); act(() => { result.current.handleNextPost(); }); act(() => { jest.advanceTimersByTime(SLIDE_DURATION_MS); }); expect(result.current.showNextIndicator).toBe(false); }); }); // ─── thumbnailImage ───────────────────────────────────────────────────────── describe('thumbnailImage', () => { it('returns the thumbnail when the current post is a video with a thumbnail', () => { const mediaWithThumb = makeMedia(0, 'video'); const store = { ...makeStore(), data: { settings: {} as any, content: { medias: [mediaWithThumb] }, id: 'widget-1', }, }; const wrapper = makeWrapper(store); const { result } = renderHook( () => usePlayerNavigation({ post: mediaWithThumb, onClose: jest.fn(), onPostChange: jest.fn() }), { wrapper }, ); expect(result.current.thumbnailImage).toBe(mediaWithThumb.thumbnail); }); it('returns the image for an image post', () => { const { result } = renderNav(0); expect(result.current.thumbnailImage).toBe(medias[0].image); }); }); // ─── External post prop change ────────────────────────────────────────────── describe('post prop sync', () => { it('updates nav when the post prop changes to a different post', () => { const { result, rerender } = renderHook( ({ post }: { post: IMedia }) => usePlayerNavigation({ post, onClose: jest.fn(), onPostChange: jest.fn() }), { initialProps: { post: medias[0] }, wrapper: makeWrapper(makeStore()), }, ); expect(result.current.currentDisplayPost).toBe(medias[0]); act(() => { rerender({ post: medias[2] }); }); expect(result.current.currentDisplayPost).toBe(medias[2]); }); it('resumes playing when the post prop changes', () => { const { result, rerender } = renderHook( ({ post }: { post: IMedia }) => usePlayerNavigation({ post, onClose: jest.fn(), onPostChange: jest.fn() }), { initialProps: { post: medias[0] }, wrapper: makeWrapper(makeStore()), }, ); act(() => { result.current.togglePlay(); }); // pause expect(result.current.isPlaying).toBe(false); act(() => { rerender({ post: medias[2] }); }); expect(result.current.isPlaying).toBe(true); }); it('does not update nav when rerendered with the same post', () => { const onPostChange = jest.fn(); const { result, rerender } = renderHook( ({ post }: { post: IMedia }) => usePlayerNavigation({ post, onClose: jest.fn(), onPostChange }), { initialProps: { post: medias[1] }, wrapper: makeWrapper(makeStore()), }, ); act(() => { rerender({ post: medias[1] }); }); // Nav stays the same, onPostChange is not called expect(result.current.currentDisplayPost).toBe(medias[1]); expect(onPostChange).not.toHaveBeenCalled(); }); }); });