diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 4ab97362d..b5a6ccc92 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -1,20 +1,23 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { - ComponentFixture, - inject, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { provideMockStore } from '@ngrx/store/testing'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { MockComponent, MockModule, MockPipe, MockProviders } from 'ng-mocks'; -import { NgxIndexedDBService } from 'ngx-indexed-db'; -import { of } from 'rxjs'; -import { DataService, PlaylistsService } from 'services'; -import { Language, STORE_KEY, Theme } from 'shared-interfaces'; +import { Actions } from '@ngrx/effects'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; +import { MockProvider } from 'ng-mocks'; +import { EMPTY, of } from 'rxjs'; +import { DataService } from 'services'; +import { + Language, + Settings, + STORE_KEY, + StreamFormat, + Theme, + VideoPlayer, +} from 'shared-interfaces'; +import { PlaylistActions } from 'm3u-state'; import { AppComponent } from './app.component'; import { ElectronServiceStub } from './services/electron.service.stub'; import { SettingsService } from './services/settings.service'; @@ -23,175 +26,182 @@ jest.spyOn(global.console, 'error').mockImplementation(() => { // suppress console.error output during tests }); +class MockSettingsService { + getValueFromLocalStorage = jest.fn().mockReturnValue(of(undefined)); + changeTheme = jest.fn(); +} + +const DEFAULT_SETTINGS: Settings = { + player: VideoPlayer.VideoJs, + epgUrl: [], + streamFormat: StreamFormat.M3u8StreamFormat, + language: Language.ENGLISH, + showCaptions: false, + showExternalPlaybackBar: true, + theme: Theme.SystemTheme, + mpvPlayerPath: '', + mpvReuseInstance: false, + vlcPlayerPath: '', + remoteControl: false, + remoteControlPort: 8765, + downloadFolder: '', +}; + describe('AppComponent', () => { let component: AppComponent; - let electronService: DataService; let fixture: ComponentFixture; - let settingsService: SettingsService; + let epgService: EpgService; + let router: Router; + let settingsService: MockSettingsService; + let snackBar: MatSnackBar; + let store: MockStore; let translateService: TranslateService; - const defaultLanguage = 'en'; + const originalElectron = window.electron; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [AppComponent, MockPipe(TranslatePipe)], + imports: [AppComponent], providers: [ - MockProviders( - TranslateService, - PlaylistsService, - NgxIndexedDBService, - MatSnackBar - ), - SettingsService, + provideMockStore(), + { + provide: Actions, + useValue: new Actions(EMPTY), + }, { provide: DataService, useClass: ElectronServiceStub, }, - provideMockStore(), + { + provide: SettingsService, + useClass: MockSettingsService, + }, + MockProvider(EpgService, { + fetchEpg: jest.fn(), + }), + MockProvider(Router, { + navigateByUrl: jest.fn(), + }), + MockProvider(MatSnackBar, { + open: jest.fn(), + }), + MockProvider(TranslateService, { + instant: jest.fn((key: string) => key), + setDefaultLang: jest.fn(), + use: jest.fn(), + }), + { + provide: WORKSPACE_SHELL_ACTIONS, + useValue: { + openAddPlaylistDialog: jest.fn(), + openGlobalRecent: jest.fn(), + openGlobalSearch: jest.fn(), + openAccountInfo: jest.fn(), + }, + }, ], - imports: [ - MockModule(MatSnackBarModule), - MockComponent(NgxWhatsNewComponent), - RouterTestingModule, - HttpClientTestingModule, - ], - }).compileComponents(); + }) + .overrideComponent(AppComponent, { + set: { + template: '', + }, + }) + .compileComponents(); })); beforeEach(() => { - electronService = TestBed.inject(DataService); + window.electron = { + checkEpgFreshness: jest.fn().mockResolvedValue({ + freshUrls: [], + staleUrls: [], + }), + } as unknown as typeof window.electron; + fixture = TestBed.createComponent(AppComponent); - settingsService = TestBed.inject(SettingsService); + epgService = TestBed.inject(EpgService); + router = TestBed.inject(Router); + settingsService = TestBed.inject( + SettingsService + ) as unknown as MockSettingsService; + snackBar = TestBed.inject(MatSnackBar); + store = TestBed.inject(MockStore); translateService = TestBed.inject(TranslateService); component = fixture.componentInstance; - - // TODO: investigate in detail - component.triggerAutoUpdateMechanism = jest.fn(); - component.modals = []; - component.checkForUpdates = jest.fn(); - fixture.detectChanges(); }); - it('should create the component and set default language', () => { - jest.spyOn(translateService, 'setDefaultLang'); - jest.spyOn(component, 'setRendererListeners'); - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - expect(component.DEFAULT_LANG).toEqual(Language.ENGLISH); + afterEach(() => { + window.electron = originalElectron; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); }); it('should init component', () => { + const storeDispatchSpy = jest.spyOn(store, 'dispatch'); jest.spyOn(translateService, 'setDefaultLang'); - jest.spyOn(component, 'setRendererListeners'); jest.spyOn(component, 'initSettings'); - jest.spyOn(component, 'handleWhatsNewDialog'); + component.ngOnInit(); - expect(translateService.setDefaultLang).toHaveBeenCalledWith( - defaultLanguage + expect(storeDispatchSpy).toHaveBeenCalledWith( + PlaylistActions.loadPlaylists() + ); + expect(translateService.setDefaultLang).toHaveBeenCalledWith( + Language.ENGLISH ); - expect(component.setRendererListeners).toHaveBeenCalledTimes(1); expect(component.initSettings).toHaveBeenCalledTimes(1); - expect(component.handleWhatsNewDialog).toHaveBeenCalledTimes(1); }); - describe('Test ipc listeners and commands', () => { - it('should set IPC listeners', () => { - jest.spyOn(electronService, 'listenOn'); - component.setRendererListeners(); - expect(electronService.listenOn).toHaveBeenCalledTimes( - component.commandsList.length - ); - }); + it('should navigate to the provided route', () => { + const route = '/add-playlists'; + jest.spyOn(router, 'navigateByUrl'); - it('should remove all ipc listeners on destroy', () => { - jest.spyOn(electronService, 'removeAllListeners'); - component.ngOnDestroy(); - expect(electronService.removeAllListeners).toHaveBeenCalledTimes( - component.commandsList.length - ); - }); + component.navigateToRoute(route); - it('should navigate to the provided route', inject( - [Router], - (router: Router) => { - const route = '/add-playlists'; - jest.spyOn(router, 'navigateByUrl'); - component.navigateToRoute(route); - expect(router.navigateByUrl).toHaveBeenCalledTimes(1); - expect(router.navigateByUrl).toHaveBeenCalledWith(route); - } - )); + expect(router.navigateByUrl).toHaveBeenCalledWith(route); }); - describe('Test version handling', () => { - it('should get actual app version which is outdated and show updates dialog', () => { - const currentAppVersion = '0.0.1'; - const spyOnSettingsGet = jest - .spyOn(settingsService, 'getValueFromLocalStorage') - .mockReturnValue(of(currentAppVersion)); + it('should apply system theme when no settings are stored', () => { + jest.spyOn(settingsService, 'changeTheme'); - expect(spyOnSettingsGet).toHaveBeenCalled(); - }); + component.initSettings(); - it('should get actual app version which is not outdated and do not shop updates dialog', () => { - const currentAppVersion = '1.0.0'; - const spyOnSettingsGet = jest - .spyOn(settingsService, 'getValueFromLocalStorage') - .mockReturnValue(of(currentAppVersion)); - - expect(spyOnSettingsGet).toHaveBeenCalled(); - }); + expect(settingsService.getValueFromLocalStorage).toHaveBeenCalledWith( + STORE_KEY.Settings + ); + expect(settingsService.changeTheme).toHaveBeenCalledWith( + Theme.SystemTheme + ); }); - describe('Set initial settings', () => { - const theme = Theme.DarkTheme; - const language = 'es'; - const epgUrl = ['http://localhost/epg.xml']; - - beforeEach(() => { - jest.spyOn(electronService, 'sendIpcEvent'); - jest.spyOn(settingsService, 'changeTheme'); - jest.spyOn(component, 'handleWhatsNewDialog'); - jest.spyOn(translateService, 'use'); + it('should apply saved settings and fetch stale epg data only', async () => { + const settings: Settings = { + ...DEFAULT_SETTINGS, + epgUrl: ['https://example.com/epg.xml'], + language: Language.SPANISH, + theme: Theme.DarkTheme, + }; + const checkEpgFreshness = jest.fn().mockResolvedValue({ + freshUrls: [], + staleUrls: settings.epgUrl, }); - it('should get and init settings (all settings are defined)', () => { - const spyOnSettingsGet = jest - .spyOn(settingsService, 'getValueFromLocalStorage') - .mockReturnValue(of({ theme, epgUrl, language })); + window.electron = { + ...window.electron, + checkEpgFreshness, + } as unknown as typeof window.electron; + settingsService.getValueFromLocalStorage.mockReturnValue(of(settings)); + jest.spyOn(settingsService, 'changeTheme'); + jest.spyOn(translateService, 'use'); - component.initSettings(); + component.initSettings(); + await fixture.whenStable(); - expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings); - expect(settingsService.changeTheme).toHaveBeenCalledWith(theme); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1); - expect(translateService.use).toHaveBeenCalledWith(language); - }); - - it('should get and init settings (nothing is defined)', () => { - const spyOnSettingsGet = jest - .spyOn(settingsService, 'getValueFromLocalStorage') - .mockReturnValue(of()); - - component.initSettings(); - - expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings); - expect(settingsService.changeTheme).toHaveBeenCalledTimes(0); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(0); - expect(translateService.use).toHaveBeenCalledTimes(0); - }); - - it('should get and init settings (only theme is defined)', () => { - const spyOnSettingsGet = jest - .spyOn(settingsService, 'getValueFromLocalStorage') - .mockReturnValue(of({ theme })); - - component.initSettings(); - - expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings); - expect(settingsService.changeTheme).toHaveBeenCalledWith(theme); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1); - expect(translateService.use).toHaveBeenCalledWith(defaultLanguage); - }); + expect(translateService.use).toHaveBeenCalledWith(Language.SPANISH); + expect(settingsService.changeTheme).toHaveBeenCalledWith( + Theme.DarkTheme + ); + expect(checkEpgFreshness).toHaveBeenCalledWith(settings.epgUrl, 12); + expect(epgService.fetchEpg).toHaveBeenCalledWith(settings.epgUrl); + expect(snackBar.open).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 425abcefa..7ffaba87f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -6,7 +6,7 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { EpgService } from '@iptvnator/epg/data-access'; import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; -import { EpgProgressPanelComponent } from '@iptvnator/ui/epg'; +import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state'; import { filter, take } from 'rxjs'; import { DataService } from 'services'; diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts new file mode 100644 index 000000000..83fb14619 --- /dev/null +++ b/apps/web/src/app/app.routes.spec.ts @@ -0,0 +1,39 @@ +import { routes } from './app.routes'; + +describe('app routes', () => { + const workspaceRoute = routes.find((route) => route.path === 'workspace'); + const workspaceChildren = workspaceRoute?.children ?? []; + + it('routes M3U favorites and recent pages through the shared collection wrapper', async () => { + const favoritesRoute = workspaceChildren.find( + (route) => route.path === 'playlists/:id/favorites' + ); + const recentRoute = workspaceChildren.find( + (route) => route.path === 'playlists/:id/recent' + ); + + expect(favoritesRoute?.data).toEqual({ + mode: 'favorites', + portalType: 'm3u', + }); + expect(recentRoute?.data).toEqual({ + mode: 'recent', + portalType: 'm3u', + }); + + expect(typeof favoritesRoute?.loadComponent).toBe('function'); + expect(typeof recentRoute?.loadComponent).toBe('function'); + }); + + it('adds a shared /workspace/global-recent route', async () => { + const globalRecentRoute = workspaceChildren.find( + (route) => route.path === 'global-recent' + ); + + expect(globalRecentRoute?.data).toEqual({ + mode: 'recent', + defaultScope: 'all', + }); + expect(typeof globalRecentRoute?.loadComponent).toBe('function'); + }); +}); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 917aa98aa..a40ad68af 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -40,6 +40,28 @@ export const routes: Routes = [ pathMatch: 'full', redirectTo: 'playlists/:id/all', }, + { + path: 'playlists/:id/favorites', + loadComponent: () => + import('@iptvnator/playlist/m3u/feature-player').then( + (c) => c.M3uCollectionRouteComponent + ), + data: { + mode: 'favorites', + portalType: 'm3u', + }, + }, + { + path: 'playlists/:id/recent', + loadComponent: () => + import('@iptvnator/playlist/m3u/feature-player').then( + (c) => c.M3uCollectionRouteComponent + ), + data: { + mode: 'recent', + portalType: 'm3u', + }, + }, { path: 'playlists/:id/:view', loadComponent: () => @@ -58,6 +80,17 @@ export const routes: Routes = [ (c) => c.UnifiedCollectionPageComponent ), }, + { + path: 'global-recent', + data: { + mode: 'recent', + defaultScope: 'all', + }, + loadComponent: () => + import('@iptvnator/portal/shared/ui').then( + (c) => c.UnifiedCollectionPageComponent + ), + }, { path: 'downloads', loadComponent: () => diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 11d7f6151..17aea2d98 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback'; +import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog'; import { DataService } from 'services'; import { ExternalPlayerSession, @@ -115,7 +115,7 @@ export class PlayerService { ); } - void import('@iptvnator/portal/xtream/feature').then( + return import('@iptvnator/portal/xtream/feature').then( ({ PlayerDialogComponent }) => { this.dialog.open(PlayerDialogComponent, { data: { streamUrl, title, contentInfo, startTime }, diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index b800bcdbd..d1317fa95 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -1,5 +1,6 @@ import { inject, Injectable, Provider } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; import { PlaylistType } from '@iptvnator/playlist/shared/ui'; import { WORKSPACE_SHELL_ACTIONS, @@ -10,6 +11,7 @@ import { @Injectable({ providedIn: 'root' }) export class AppWorkspaceShellActionsService implements WorkspaceShellActions { private readonly dialog = inject(MatDialog); + private readonly router = inject(Router); openAddPlaylistDialog(type: PlaylistType): void { void import('@iptvnator/playlist/import/feature').then( @@ -40,19 +42,7 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions { } openGlobalRecent(): void { - void import('@iptvnator/portal/xtream/feature').then( - ({ GlobalRecentlyViewedComponent }) => { - this.dialog.open(GlobalRecentlyViewedComponent, { - width: '100%', - height: '100%', - maxWidth: '100%', - panelClass: 'global-search-overlay', - data: { isGlobal: true }, - hasBackdrop: true, - disableClose: false, - }); - } - ); + void this.router.navigate(['/workspace/global-recent']); } openAccountInfo(data: WorkspaceAccountInfoData): void { diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 00dce0482..9f1fc5c8e 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -673,6 +673,7 @@
diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 1bc322147..9c5f793c2 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -16,32 +16,35 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import { Store } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { EpgService } from '@iptvnator/epg/data-access'; import { MockModule, MockProvider, - MockProviders, } from 'ng-mocks'; import { DialogService } from 'components'; import { DataService, PlaylistsService } from 'services'; -import { Language, StreamFormat, Theme, VideoPlayer } from 'shared-interfaces'; +import { + Language, + StreamFormat, + Theme, + VideoPlayer, +} from 'shared-interfaces'; import { SettingsComponent } from './settings.component'; import { signal } from '@angular/core'; import { NgxIndexedDBService } from 'ngx-indexed-db'; -import { of } from 'rxjs'; -import { SETTINGS_UPDATE } from 'shared-interfaces'; +import { from, of, Subject } from 'rxjs'; import { ElectronServiceStub } from '../services/electron.service.stub'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from '../services/settings.service'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { PlaylistActions } from 'm3u-state'; class MatSnackBarStub { - open() { - return undefined; - } + open = jest.fn(); } export class MockRouter { @@ -58,15 +61,17 @@ const DEFAULT_SETTINGS = { showExternalPlaybackBar: true, theme: Theme.SystemTheme, mpvPlayerPath: '', + mpvReuseInstance: false, vlcPlayerPath: '', remoteControl: false, - remoteControlPort: 3000, + remoteControlPort: 8765, + epgUrl: [], }; class MockSettingsStore { private _settings = signal(DEFAULT_SETTINGS); - getSettings = () => this._settings; + getSettings = () => this._settings(); loadSettings = jest.fn().mockResolvedValue(undefined); @@ -82,8 +87,18 @@ class MockSettingsStore { } class MockSettingsService { - getAppVersion = jest.fn().mockReturnValue(of('1.0.0')); + getAppVersion = jest + .fn() + .mockReturnValue(from(Promise.resolve('1.0.0'))); changeTheme = jest.fn(); + isVersionOutdated = jest + .fn() + .mockImplementation((currentVersion: string, latestVersion: string) => + currentVersion.localeCompare(latestVersion, undefined, { + numeric: true, + sensitivity: 'base', + }) < 0 + ); } describe('SettingsComponent', () => { @@ -91,17 +106,26 @@ describe('SettingsComponent', () => { let fixture: ComponentFixture; let electronService: DataService; let router: Router; - let settingsStore: MockSettingsStore; + let settingsStore: unknown; let translate: TranslateService; let epgService: EpgService; + let dialogService: DialogService; + let playlistsService: PlaylistsService; + let store: Store; + let snackBar: MatSnackBarStub; + const originalElectron = window.electron; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: [ UntypedFormBuilder, { provide: SettingsStore, useClass: MockSettingsStore }, - MockProvider(EpgService), - MockProvider(DialogService), + MockProvider(EpgService, { + fetchEpg: jest.fn(), + }), + MockProvider(DialogService, { + openConfirmDialog: jest.fn(), + }), { provide: SettingsService, useClass: MockSettingsService }, { provide: MatSnackBar, useClass: MatSnackBarStub }, { provide: DataService, useClass: ElectronServiceStub }, @@ -110,7 +134,14 @@ describe('SettingsComponent', () => { useClass: MockRouter, }, provideMockStore(), - MockProviders(NgxIndexedDBService, PlaylistsService), + { + provide: NgxIndexedDBService, + useValue: {}, + }, + MockProvider(PlaylistsService, { + getAllData: jest.fn().mockReturnValue(of([])), + removeAll: jest.fn(), + }), ], imports: [ SettingsComponent, @@ -132,18 +163,39 @@ describe('SettingsComponent', () => { })); beforeEach(() => { + window.electron = { + checkEpgFreshness: jest.fn().mockResolvedValue({ + freshUrls: [], + staleUrls: [], + }), + clearEpgData: jest.fn().mockResolvedValue({ success: true }), + getAppVersion: jest.fn().mockResolvedValue('1.0.0'), + getLocalIpAddresses: jest.fn().mockResolvedValue([]), + platform: 'linux', + setMpvPlayerPath: jest.fn().mockResolvedValue(undefined), + setVlcPlayerPath: jest.fn().mockResolvedValue(undefined), + updateSettings: jest.fn().mockResolvedValue(undefined), + } as unknown as typeof window.electron; + fixture = TestBed.createComponent(SettingsComponent); electronService = TestBed.inject(DataService); settingsStore = TestBed.inject(SettingsStore); router = TestBed.inject(Router); translate = TestBed.inject(TranslateService); epgService = TestBed.inject(EpgService); + dialogService = TestBed.inject(DialogService); + playlistsService = TestBed.inject(PlaylistsService); + store = TestBed.inject(Store); + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; component = fixture.componentInstance; - component.setSettings = jest.fn(); fixture.detectChanges(); }); + afterEach(() => { + window.electron = originalElectron; + }); + it('should create and init component', () => { expect(component).toBeTruthy(); }); @@ -158,10 +210,19 @@ describe('SettingsComponent', () => { }); it('should not render the page header in dialog mode', () => { - component.isDialog = true; - fixture.detectChanges(); + fixture.destroy(); - const nativeElement = fixture.nativeElement as HTMLElement; + const dialogFixture = TestBed.createComponent(SettingsComponent); + const dialogComponent = dialogFixture.componentInstance; + + dialogComponent.checkAppVersion = jest.fn(); + dialogComponent.fetchLocalIpAddresses = jest + .fn() + .mockResolvedValue(undefined); + dialogComponent.isDialog = true; + dialogFixture.detectChanges(); + + const nativeElement = dialogFixture.nativeElement as HTMLElement; expect( nativeElement.querySelector('[data-test-id="settings-page-header"]') ).toBeNull(); @@ -171,10 +232,20 @@ describe('SettingsComponent', () => { }); it('should scroll the general navigation target to the general section', async () => { + fixture.destroy(); + + const scrollFixture = TestBed.createComponent(SettingsComponent); + const scrollComponent = scrollFixture.componentInstance; const settingsContext = TestBed.inject(SettingsContextService); const scrollIntoView = jest.fn(); const originalGetElementById = document.getElementById.bind(document); + scrollComponent.checkAppVersion = jest.fn(); + scrollComponent.fetchLocalIpAddresses = jest + .fn() + .mockResolvedValue(undefined); + scrollFixture.detectChanges(); + const getElementByIdSpy = jest .spyOn(document, 'getElementById') .mockImplementation((id: string) => { @@ -187,9 +258,11 @@ describe('SettingsComponent', () => { return originalGetElementById(id); }); + await scrollFixture.whenStable(); + scrollFixture.detectChanges(); settingsContext.navigateToSection('general'); - fixture.detectChanges(); - await fixture.whenStable(); + scrollFixture.detectChanges(); + await scrollFixture.whenStable(); expect(getElementByIdSpy).toHaveBeenCalledWith('general'); expect(getElementByIdSpy).not.toHaveBeenCalledWith('settings-intro'); @@ -202,7 +275,9 @@ describe('SettingsComponent', () => { describe('Get and set settings on component init', () => { const settings = { - player: VideoPlayer.VideoJs, + language: Language.GERMAN, + player: VideoPlayer.Html5Player, + theme: Theme.DarkTheme, }; it('should init default settings if previous config was not saved', async () => { @@ -218,11 +293,7 @@ describe('SettingsComponent', () => { ...settings, }); - component.ngOnInit(); - - // Force change detection - fixture.detectChanges(); - await fixture.whenStable(); + component.setSettings(); //expect(settingsStore.loadSettings).toHaveBeenCalled(); expect(component.settingsForm.value).toEqual({ @@ -269,7 +340,6 @@ describe('SettingsComponent', () => { currentVersion ); component.showVersionInformation(latestVersion); - fixture.detectChanges(); expect(translate.instant).toHaveBeenCalledWith( 'SETTINGS.NEW_VERSION_AVAILABLE' ); @@ -279,6 +349,42 @@ describe('SettingsComponent', () => { }); }); + it('removes all playlists with a busy state and dispatches store cleanup after success', async () => { + const removal$ = new Subject(); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + (dialogService.openConfirmDialog as jest.Mock).mockImplementation( + ({ onConfirm }: { onConfirm: () => Promise }) => { + void onConfirm(); + } + ); + (playlistsService.removeAll as jest.Mock).mockReturnValue( + removal$.asObservable() + ); + jest.spyOn(translate, 'instant').mockImplementation((key) => key); + + component.removeAll(); + + expect(component.isRemovingAllPlaylists()).toBe(true); + expect(playlistsService.removeAll).toHaveBeenCalled(); + + removal$.next(); + removal$.complete(); + await fixture.whenStable(); + + expect(component.isRemovingAllPlaylists()).toBe(false); + expect(dispatchSpy).toHaveBeenCalledWith( + PlaylistActions.removeAllPlaylists() + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.PLAYLISTS_REMOVED', + undefined, + { + duration: 2000, + } + ); + }); + it('should send epg refresh command', () => { jest.spyOn(epgService, 'fetchEpg'); const url = 'http://epg-url-here/data.xml'; @@ -302,15 +408,15 @@ describe('SettingsComponent', () => { it('should save settings on submit', async () => { const mockStore = settingsStore as unknown as MockSettingsStore; mockStore.updateSettings.mockResolvedValue(undefined); + const updateSettings = jest.spyOn(window.electron, 'updateSettings'); - jest.spyOn(electronService, 'sendIpcEvent'); - await component.onSubmit(); + component.onSubmit(); + await fixture.whenStable(); expect(mockStore.updateSettings).toHaveBeenCalledWith( component.settingsForm.value ); - expect(electronService.sendIpcEvent).toHaveBeenCalledWith( - SETTINGS_UPDATE, + expect(updateSettings).toHaveBeenCalledWith( component.settingsForm.value ); }); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index e1a80b3ca..b324a59ae 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -28,6 +28,7 @@ import { import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -38,7 +39,7 @@ import { EpgService } from '@iptvnator/epg/data-access'; import { QRCodeComponent } from 'angularx-qrcode'; import { DialogService } from 'components'; import { PlaylistActions, selectIsEpgAvailable } from 'm3u-state'; -import { take } from 'rxjs'; +import { firstValueFrom, take } from 'rxjs'; import { DataService, PlaylistsService } from 'services'; import { Language, @@ -80,6 +81,7 @@ interface ThemeOption { MatDividerModule, MatIconModule, MatInputModule, + MatProgressSpinnerModule, MatSelectModule, MatTooltipModule, ReactiveFormsModule, @@ -209,6 +211,7 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Currently visible QR code IP (null = none visible) */ visibleQrCodeIp = signal(null); + readonly isRemovingAllPlaylists = signal(false); private settingsStore = inject(SettingsStore); private sectionObserver?: IntersectionObserver; @@ -642,8 +645,36 @@ export class SettingsComponent implements OnInit, OnDestroy { this.dialogService.openConfirmDialog({ title: this.translate.instant('SETTINGS.REMOVE_DIALOG.TITLE'), message: this.translate.instant('SETTINGS.REMOVE_DIALOG.MESSAGE'), - onConfirm: (): void => - this.store.dispatch(PlaylistActions.removeAllPlaylists()), + onConfirm: async (): Promise => { + if (this.isRemovingAllPlaylists()) { + return; + } + + this.isRemovingAllPlaylists.set(true); + + try { + await firstValueFrom(this.playlistsService.removeAll()); + this.store.dispatch(PlaylistActions.removeAllPlaylists()); + this.snackBar.open( + this.translate.instant('SETTINGS.PLAYLISTS_REMOVED'), + undefined, + { + duration: 2000, + } + ); + } catch (error) { + console.error('Error removing playlists:', error); + this.snackBar.open( + this.translate.instant('SETTINGS.IMPORT_ERROR'), + undefined, + { + duration: 2000, + } + ); + } finally { + this.isRemovingAllPlaylists.set(false); + } + }, }); }