import Clip from '../../client/index' import { createMediaTracker } from '@financial-times/media-tracking-sdk' jest.mock('@financial-times/media-tracking-sdk', () => ({ createMediaTracker: jest.fn(), })) const createMediaTrackerMock = jest.mocked(createMediaTracker) const mediaTrackerMock = { mount: jest.fn(), unmount: jest.fn(), flushWatched: jest.fn(), getProgress: jest.fn(() => 0), } class IntersectionObserverMock implements IntersectionObserver { root: Element | Document | null = null rootMargin: string = '' thresholds: ReadonlyArray = [] callback: IntersectionObserverCallback options?: IntersectionObserverInit observe: jest.Mock unobserve: jest.Mock disconnect: jest.Mock takeRecords: jest.Mock constructor( callback: IntersectionObserverCallback, options?: IntersectionObserverInit ) { this.callback = callback this.options = options this.observe = jest.fn() this.unobserve = jest.fn() this.disconnect = jest.fn() this.takeRecords = jest.fn().mockReturnValue([]) } trigger( entries: IntersectionObserverEntry[], observer: IntersectionObserverMock ) { this.callback(entries, observer) } } global.IntersectionObserver = IntersectionObserverMock as unknown as typeof IntersectionObserver describe('Clip', () => { let clipComponent: HTMLElement let videoElement: HTMLVideoElement let videoContainer: HTMLElement let captionContainer: HTMLElement let captionElement: HTMLElement let clipInstance: Clip let dispatchEventSpy: jest.SpyInstance const mockReadySate = 4 let isPaused = true beforeEach(() => { createMediaTrackerMock.mockReturnValue(mediaTrackerMock) Object.values(mediaTrackerMock).forEach((mock) => mock.mockClear()) videoElement = document.createElement('video') videoElement.classList.add('cp-clip__video') isPaused = true const trackElement = document.createElement('track') trackElement.kind = 'captions' trackElement.srclang = 'en' trackElement.src = 'https://next-media-api.ft.com/clips/captions/35441370' videoElement.appendChild(trackElement) Object.defineProperty(videoElement, 'readyState', { get() { return mockReadySate }, }) Object.defineProperty(videoElement, 'paused', { get() { return isPaused }, }) Object.defineProperty(videoElement, 'canPlayType', { value: jest.fn().mockImplementation(() => 'probably'), writable: true, }) videoElement.play = jest.fn().mockImplementation(() => { isPaused = false return Promise.resolve() }) videoElement.pause = jest.fn().mockImplementation(() => { isPaused = true }) const mockTextTracks = [ { mode: 'disabled', kind: 'captions', language: 'en', label: 'English', addEventListener: jest.fn(), removeEventListener: jest.fn(), }, ] Object.defineProperty(videoElement, 'textTracks', { value: mockTextTracks, writable: true, }) clipComponent = document.createElement('div') videoContainer = document.createElement('div') videoContainer.classList.add('cp-clip__video-container') videoContainer.appendChild(videoElement) captionElement = document.createElement('div') captionElement.classList.add('cp-clip__caption') captionElement.setAttribute('data-cp-clip-caption', 'true') captionContainer = document.createElement('div') captionContainer.classList.add('cp-clip__video-meta-info') captionContainer.appendChild(captionElement) clipComponent.appendChild(videoContainer) clipComponent.appendChild(captionContainer) document.body.appendChild(clipComponent) clipInstance = new Clip(clipComponent, { closedCaption: true, autoShowClosedCaptions: true, }) dispatchEventSpy = jest.spyOn(videoElement, 'dispatchEvent') }) afterEach(() => { document.body.removeChild(clipComponent) jest.clearAllMocks() }) it('should initialise with default options', () => { expect(clipInstance.opts.autorender).toBe(true) expect(clipInstance.opts.fadeOutDelay).toBe(2000) }) it('should mount the media tracker when initialised', () => { expect(createMediaTrackerMock).toHaveBeenCalledWith( expect.objectContaining({ mediaType: 'video', customComponentEvents: ['cc-default', 'click:out', 'cta:click', 'view'], adapter: expect.any(Object), getContext: expect.any(Function), }) ) expect(mediaTrackerMock.mount).toHaveBeenCalledTimes(1) }) it('should provide page context to the media tracker', () => { createMediaTrackerMock.mockClear() const clipWithRootId = new Clip(clipComponent, { autorender: false, rootContentId: 'root-content-id', }) const trackerConfig = createMediaTrackerMock.mock.calls[0]![0] expect(trackerConfig.getContext()).toEqual({ url: 'http://localhost/', referrer: '', rootContentId: 'root-content-id', }) clipWithRootId.destroy() }) it('should toggle play/pause state', () => { clipInstance.togglePlay() expect(videoElement.paused).toBe(false) clipInstance.togglePlay() expect(videoElement.paused).toBe(true) }) it('should flush watched metrics and unmount the media tracker on unload', () => { clipInstance.unload() expect(mediaTrackerMock.flushWatched).toHaveBeenCalledTimes(1) expect(mediaTrackerMock.unmount).toHaveBeenCalledTimes(1) expect( mediaTrackerMock.flushWatched.mock.invocationCallOrder[0]! ).toBeLessThan(mediaTrackerMock.unmount.mock.invocationCallOrder[0]!) }) it('should create custom player controls', () => { clipInstance.createCustomPlayer() const playerCreatedEvent = dispatchEventSpy.mock.calls[0][0] as CustomEvent expect(playerCreatedEvent.type).toBe( 'cpContentPipeline.clipComponent.customPlayerCreated' ) expect(playerCreatedEvent.detail).toEqual({ clipId: clipInstance.opts.id, }) }) it('should handle visibility changes', () => { const visibilityListenerSpy = jest.spyOn(clipInstance, 'visibilityListener') clipInstance.visibilityListener([ { isIntersecting: true } as IntersectionObserverEntry, ]) expect(visibilityListenerSpy).toHaveBeenCalled() }) it('should play closed captions by default when available', () => { clipInstance.videoEl.dispatchEvent(new Event('playing')) expect(clipInstance.videoEl.textTracks[0]?.mode).toBe('showing') const closedCaptionIcon = document.querySelector('.cp-clip__closed-caption') expect( closedCaptionIcon?.getAttribute('data-display-closed-captions') ).toBeDefined() }) it('should toggle closed captions', () => { clipInstance.videoEl.dispatchEvent(new Event('playing')) const fireEventSpy = jest.spyOn(clipInstance, 'fireEvent') clipInstance.containerEl .querySelector('.cp-clip__closed-caption') ?.dispatchEvent(new Event('click')) expect(clipInstance.videoEl.textTracks[0]?.mode).toBe('hidden') const closedCaptionIcon = clipInstance.containerEl.querySelector( '.cp-clip__closed-caption' ) expect( closedCaptionIcon?.getAttribute('data-display-closed-captions') ).toBeNull() expect(fireEventSpy.mock.calls[0]).toEqual([ 'cta:click', { trigger_action: 'turn captions off' }, ]) clipInstance.containerEl .querySelector('.cp-clip__closed-caption') ?.dispatchEvent(new Event('click')) expect(clipInstance.videoEl.textTracks[0]?.mode).toBe('showing') expect( closedCaptionIcon?.getAttribute('data-display-closed-captions') ).toBeDefined() expect(fireEventSpy.mock.calls[1]).toEqual([ 'cta:click', { trigger_action: 'turn captions on' }, ]) }) })