import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { Language, StartupBehavior, Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsComponent } from './settings.component'; import { configureSettingsComponentTestBed, createElectronStub, createEpgBridgeStub, DEFAULT_DASHBOARD_RAILS, DEFAULT_SETTINGS, MatSnackBarStub, MockSettingsStore, stubSettingsSideEffects, } from './test-stubs/settings-test-harness.stub'; /** * Everything the rendered settings form does: hydration from the store, the * section outputs that write straight through, and the submit path. */ describe('SettingsComponent form', () => { let component: SettingsComponent; let fixture: ComponentFixture; let settingsStore: MockSettingsStore; let translate: TranslateService; let snackBar: MatSnackBarStub; let epgBridge: Partial; const originalElectron = window.electron; beforeEach(waitForAsync(() => { epgBridge = createEpgBridgeStub(); configureSettingsComponentTestBed(epgBridge); })); beforeEach(() => { window.electron = createElectronStub(); fixture = TestBed.createComponent(SettingsComponent); settingsStore = TestBed.inject( SettingsStore ) as unknown as MockSettingsStore; translate = TestBed.inject(TranslateService); snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; component = fixture.componentInstance; stubSettingsSideEffects(component); fixture.detectChanges(); }); afterEach(() => { window.electron = originalElectron; }); describe('Get and set settings on component init', () => { const settings = { language: Language.GERMAN, player: VideoPlayer.Html5Player, theme: Theme.DarkTheme, }; it('should init default settings if previous config was not saved', async () => { await component.ngOnInit(); expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); }); it('should get and apply custom settings', () => { settingsStore._setSettings({ ...DEFAULT_SETTINGS, ...settings, }); component.form.hydrateFromStore(); expect(component.settingsForm.value).toEqual({ ...DEFAULT_SETTINGS, ...settings, }); }); it('hydrates the shared web controls setting from the settings store', () => { settingsStore._setSettings({ webPlayerSharedControls: true, }); component.form.hydrateFromStore(); expect( component.settingsForm.get('webPlayerSharedControls')?.value ).toBe(true); }); }); describe('Section outputs', () => { it('updates the selected theme through the general section and marks the form dirty', () => { const darkThemeButton = ( fixture.nativeElement as HTMLElement ).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement; darkThemeButton.click(); fixture.detectChanges(); expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme); expect(component.settingsForm.dirty).toBeTruthy(); }); it('updates cover size through the general section output', () => { const largeCoverButton = ( fixture.nativeElement as HTMLElement ).querySelector( '[data-test-id="cover-size-large"]' ) as HTMLButtonElement; largeCoverButton.click(); fixture.detectChanges(); expect(component.settingsForm.value.coverSize).toBe('large'); expect(settingsStore.updateSettings).toHaveBeenCalledWith({ coverSize: 'large', }); }); it('updates the EPG view mode through the epg section output', () => { const listButton = ( fixture.nativeElement as HTMLElement ).querySelector( '[data-test-id="epg-view-mode-list"]' ) as HTMLButtonElement; listButton.click(); fixture.detectChanges(); expect(component.settingsForm.value.epgViewMode).toBe('list'); expect(settingsStore.updateSettings).toHaveBeenCalledWith({ epgViewMode: 'list', }); }); }); describe('Dashboard controls', () => { it('renders dashboard controls with the expected defaults', () => { const nativeElement = fixture.nativeElement as HTMLElement; expect( nativeElement.querySelector( '[data-test-id="toggle-show-dashboard"]' ) ).not.toBeNull(); expect( nativeElement.querySelector( '[data-test-id="toggle-dashboard-hero"]' ) ).not.toBeNull(); expect( nativeElement.querySelector( '[data-test-id="toggle-dashboard-rail-live-favorites"]' ) ).not.toBeNull(); expect( nativeElement.querySelector( '[data-test-id="toggle-dashboard-rail-recently-watched-live"]' ) ).not.toBeNull(); expect( nativeElement.querySelector( '[data-test-id="toggle-dashboard-rail-favorite-movies-and-series"]' ) ).not.toBeNull(); expect(component.settingsForm.value.showDashboard).toBe(true); expect(component.settingsForm.value.dashboardRails).toEqual( DEFAULT_DASHBOARD_RAILS ); expect(component.settingsForm.value.startupBehavior).toBe( StartupBehavior.FirstView ); }); it('disables dashboard surface controls when the dashboard is off', async () => { const showDashboard = component.settingsForm.get('showDashboard'); const dashboardRails = component.settingsForm.get('dashboardRails'); showDashboard?.setValue(false); await fixture.whenStable(); expect(dashboardRails?.disabled).toBe(true); expect( component.settingsForm.get('dashboardRails.hero')?.disabled ).toBe(true); expect( component.settingsForm.get('dashboardRails.continueWatching') ?.disabled ).toBe(true); showDashboard?.setValue(true); await fixture.whenStable(); expect(dashboardRails?.enabled).toBe(true); expect( component.settingsForm.get('dashboardRails.hero')?.enabled ).toBe(true); }); }); describe('Saving', () => { it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => { jest.spyOn(translate, 'instant').mockReturnValue('Settings saved'); component.applyChangedSettings(); expect(snackBar.open).toHaveBeenCalledWith( 'Settings saved', undefined, { duration: 2000, horizontalPosition: 'center', panelClass: ['settings-snackbar'], verticalPosition: 'bottom', } ); }); it('should save settings on submit', async () => { settingsStore.updateSettings.mockResolvedValue(undefined); const updateSettings = jest.spyOn( window.electron, 'updateSettings' ); component.onSubmit(); await fixture.whenStable(); expect(settingsStore.updateSettings).toHaveBeenCalledWith({ ...component.settingsForm.value, trustedPrivateNetworkEpgUrls: [], trustedInsecureTlsHosts: [], }); expect(updateSettings).toHaveBeenCalledWith({ ...component.settingsForm.value, trustedPrivateNetworkEpgUrls: [], trustedInsecureTlsHosts: [], }); }); it('saves the shared web controls setting on submit', async () => { settingsStore.updateSettings.mockResolvedValue(undefined); component.settingsForm .get('webPlayerSharedControls') ?.setValue(true); component.onSubmit(); await fixture.whenStable(); expect(settingsStore.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ webPlayerSharedControls: true, }) ); }); it('clears external player paths in Electron when saved as empty', async () => { settingsStore.updateSettings.mockResolvedValue(undefined); const setMpvPlayerPath = jest.spyOn( window.electron, 'setMpvPlayerPath' ); const setVlcPlayerPath = jest.spyOn( window.electron, 'setVlcPlayerPath' ); component.settingsForm.patchValue({ mpvPlayerPath: '', vlcPlayerPath: '', }); component.onSubmit(); await fixture.whenStable(); expect(setMpvPlayerPath).toHaveBeenCalledWith(''); expect(setVlcPlayerPath).toHaveBeenCalledWith(''); }); it('saves external player command-line arguments with the settings payload', async () => { settingsStore.updateSettings.mockResolvedValue(undefined); const updateSettings = jest.spyOn( window.electron, 'updateSettings' ); component.settingsForm.patchValue({ mpvPlayerArguments: '--screen=1\n--geometry=1280x720', vlcPlayerArguments: '--qt-fullscreen-screennumber=1', }); component.onSubmit(); await fixture.whenStable(); expect(settingsStore.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ mpvPlayerArguments: '--screen=1\n--geometry=1280x720', vlcPlayerArguments: '--qt-fullscreen-screennumber=1', }) ); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ mpvPlayerArguments: '--screen=1\n--geometry=1280x720', vlcPlayerArguments: '--qt-fullscreen-screennumber=1', }) ); }); it('preserves saved EPG settings when saving from the web settings form', async () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; settingsStore._setSettings({ epgUrl: ['https://example.com/guide.xml'], preferUploadedEpgOverXtream: true, }); settingsStore.updateSettings.mockResolvedValue(undefined); const webFixture = TestBed.createComponent(SettingsComponent); const webComponent = webFixture.componentInstance; stubSettingsSideEffects(webComponent); webFixture.detectChanges(); await webFixture.whenStable(); webComponent.settingsForm.patchValue({ theme: Theme.DarkTheme }); webComponent.onSubmit(); await webFixture.whenStable(); expect(settingsStore.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ epgUrl: ['https://example.com/guide.xml'], preferUploadedEpgOverXtream: true, theme: Theme.DarkTheme, }) ); webFixture.destroy(); }); }); });