import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js'; import { PlayerControlsComponent, WebVideoControlsAdapter, } from '../player-controls'; import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; import { HtmlVideoPlayerComponent } from './html-video-player.component'; describe('HtmlVideoPlayerComponent', () => { let component: HtmlVideoPlayerComponent; let fixture: ComponentFixture; let adapterAttach: jest.SpiedFunction; const electronApi = { setUserAgent: jest.fn().mockResolvedValue(true), }; // eslint-disable-next-line @typescript-eslint/no-unused-vars let dataService: DataService; const TEST_CHANNEL: Channel = { id: '1234', url: 'http://test.ts', name: 'Test channel', group: { title: 'News group', }, http: { origin: '', referrer: '', 'user-agent': 'localhost', }, radio: 'false', tvg: { id: '', logo: '', name: '', rec: '', url: '', }, }; beforeEach(waitForAsync(() => { const dataServiceMock = { sendIpcEvent: jest.fn().mockResolvedValue(undefined), }; TestBed.configureTestingModule({ imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()], providers: [{ provide: DataService, useValue: dataServiceMock }], }).compileComponents(); })); beforeEach(() => { adapterAttach = jest.spyOn(WebVideoControlsAdapter.prototype, 'attach'); Object.defineProperty(window, 'electron', { configurable: true, value: electronApi, }); electronApi.setUserAgent.mockClear(); fixture = TestBed.createComponent(HtmlVideoPlayerComponent); component = fixture.componentInstance; dataService = TestBed.inject(DataService); fixture.detectChanges(); }); afterEach(() => { delete (window as unknown as { electron?: unknown }).electron; jest.restoreAllMocks(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('keeps native and legacy controls when shared controls are disabled', () => { const video = component.videoPlayer.nativeElement; expect(video.controls).toBe(true); expect( fixture.debugElement.query(By.directive(PlayerControlsComponent)) ).toBeNull(); expect( fixture.debugElement.query( By.directive(SeriesPlaybackNavigationControlsComponent) ) ).not.toBeNull(); expect(adapterAttach).not.toHaveBeenCalled(); }); it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => { const el = component.videoPlayer.nativeElement; const removeSpy = jest.spyOn(el, 'removeEventListener'); fixture.destroy(); expect(removeSpy).toHaveBeenCalledWith( 'volumechange', expect.any(Function) ); expect(removeSpy).toHaveBeenCalledWith( 'loadedmetadata', expect.any(Function) ); expect(removeSpy).toHaveBeenCalledWith( 'timeupdate', expect.any(Function) ); }); it('should call play channel function after input changes', () => { jest.spyOn(component, 'playChannel'); jest.spyOn(global.console, 'error').mockImplementation(() => { /* empty */ }); component.ngOnChanges({ channel: new SimpleChange(null, TEST_CHANNEL, true), }); fixture.detectChanges(); expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL); }); it('passes channel headers and stream URL to Electron header overrides', () => { jest.spyOn( component.videoPlayer.nativeElement, 'load' ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' ).mockResolvedValue(undefined); component.playChannel({ ...TEST_CHANNEL, http: { 'user-agent': 'ChannelAgent/1.0', origin: '', referrer: 'https://portal.example/referrer', }, radio: 'false', url: 'https://stream.example/video.mp4', }); expect(electronApi.setUserAgent).toHaveBeenCalledWith( 'ChannelAgent/1.0', 'https://portal.example/referrer', 'https://stream.example/video.mp4' ); }); it('clears Electron header overrides for channels without custom headers', () => { jest.spyOn( component.videoPlayer.nativeElement, 'load' ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' ).mockResolvedValue(undefined); component.playChannel({ ...TEST_CHANNEL, http: { 'user-agent': '', origin: '', referrer: '', }, radio: 'false', url: 'https://stream.example/video.mp4', }); expect(electronApi.setUserAgent).toHaveBeenCalledWith( '', '', 'https://stream.example/video.mp4' ); }); it('replaces and reloads native video sources when switching episodes', () => { const video = component.videoPlayer.nativeElement; const loadSpy = jest .spyOn(video, 'load') .mockImplementation(() => undefined); const playSpy = jest.spyOn(video, 'play').mockResolvedValue(undefined); component.playChannel({ ...TEST_CHANNEL, url: 'https://stream.example/series/s01e01.mp4', }); component.playChannel({ ...TEST_CHANNEL, url: 'https://stream.example/series/s01e02.mp4', }); const sources = Array.from(video.querySelectorAll('source')); const [source] = sources; expect(sources).toHaveLength(1); expect(source?.src).toBe('https://stream.example/series/s01e02.mp4'); expect(source?.type).toBe('video/mp4'); expect(loadSpy).toHaveBeenCalledTimes(2); expect(playSpy).toHaveBeenCalledTimes(2); expect(loadSpy.mock.invocationCallOrder[1]).toBeLessThan( playSpy.mock.invocationCallOrder[1] ); }); it('emits a playback issue when the native video element reports an unsupported source', () => { const issues: unknown[] = []; component.channel = TEST_CHANNEL; component.playbackIssue.subscribe((issue) => issues.push(issue)); Object.defineProperty(component.videoPlayer.nativeElement, 'error', { configurable: true, value: { code: 4, message: 'No compatible source was found', }, }); component.videoPlayer.nativeElement.dispatchEvent(new Event('error')); expect(issues).toEqual([ expect.objectContaining({ code: 'unsupported-container', source: 'native', sourceUrl: 'http://test.ts', externalFallbackRecommended: true, }), ]); }); it('does not emit a playback issue when HLS.js reports a recoverable error', () => { const issues: unknown[] = []; component.playbackIssue.subscribe((issue) => { if (issue) issues.push(issue); }); ( component as unknown as { handleHlsError: (url: string, data: ErrorData) => void; } ).handleHlsError('https://example.com/live/playlist.m3u8', { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.FRAG_LOAD_ERROR, fatal: false, error: new Error('segment retry'), }); expect(issues).toEqual([]); }); it('emits only structured HLS evidence for a fatal manifest HTTP failure', () => { const issues: Array<{ readonly code?: string; readonly httpStatus?: number; readonly hls?: unknown; }> = []; component.playbackIssue.subscribe((issue) => { if (issue) issues.push(issue); }); const secret = 'html-hls-secret-sentinel'; ( component as unknown as { handleHlsError: (url: string, data: ErrorData) => void; } ).handleHlsError('https://example.com/live/playlist.m3u8', { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.MANIFEST_LOAD_ERROR, fatal: true, error: new Error(`provider message ${secret}`), reason: `provider reason ${secret}`, response: { code: 404, url: `https://provider.example/error?token=${secret}`, text: secret, data: { body: secret }, }, networkDetails: { responseText: secret }, }); expect(issues[0]).toEqual( expect.objectContaining({ code: 'network-error', httpStatus: 404, hls: { engineType: ErrorTypes.NETWORK_ERROR, engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, disposition: 'fatal', stage: 'manifest', failure: 'http', httpStatus: 404, }, }) ); expect(JSON.stringify(issues[0].hls)).not.toContain(secret); }); it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => { const events: string[] = []; ( component as unknown as { playbackEnded: { subscribe: (fn: () => void) => { unsubscribe: () => void }; }; } ).playbackEnded.subscribe(() => events.push('ended')); jest.spyOn( component.videoPlayer.nativeElement, 'load' ).mockImplementation(() => undefined); jest.spyOn( component.videoPlayer.nativeElement, 'play' ).mockResolvedValue(undefined); component.videoPlayer.nativeElement.dispatchEvent(new Event('ended')); component.playChannel({ ...TEST_CHANNEL, url: 'https://stream.example/series/s01e03.mp4', }); fixture.destroy(); expect(events).toEqual(['ended']); }); it('hides series navigation controls when series navigation is absent', () => { expect( fixture.debugElement.query( By.css('[data-test-id="series-playback-previous-episode"]') ) ).toBeNull(); expect( fixture.debugElement.query( By.css('[data-test-id="series-playback-next-episode"]') ) ).toBeNull(); }); it('renders series navigation controls with boundary disabled state', () => { const events: string[] = []; fixture.componentRef.setInput('seriesNavigation', { canPrevious: true, canNext: false, autoplayEnabled: true, }); ( component as unknown as { previousEpisodeRequested: { subscribe: (fn: () => void) => { unsubscribe: () => void }; }; nextEpisodeRequested: { subscribe: (fn: () => void) => { unsubscribe: () => void }; }; } ).previousEpisodeRequested.subscribe(() => events.push('previous')); ( component as unknown as { nextEpisodeRequested: { subscribe: (fn: () => void) => { unsubscribe: () => void }; }; } ).nextEpisodeRequested.subscribe(() => events.push('next')); fixture.detectChanges(); const previousButton = fixture.debugElement.query( By.css('[data-test-id="series-playback-previous-episode"]') ); const nextButton = fixture.debugElement.query( By.css('[data-test-id="series-playback-next-episode"]') ); expect(previousButton).not.toBeNull(); expect(previousButton.nativeElement.disabled).toBe(false); expect(nextButton).not.toBeNull(); expect(nextButton.nativeElement.disabled).toBe(true); previousButton.nativeElement.click(); nextButton.nativeElement.click(); expect(events).toEqual(['previous']); }); });