feat(routes): add routes for M3U favorites, recent pages, and global recent

feat(tests): implement tests for app routes and settings component
feat(settings): enhance remove all playlists functionality with loading state
fix(imports): update import paths for EpgProgressPanelComponent and ExternalPlayerInfoDialogComponent
This commit is contained in:
4gray committed 2026-03-28 15:46:37 +01:00
1 parent 78fb8a3858
commit a884ef256c
9 files changed
+408 -194

No files matched your search

+153 -143
View File
@@ -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<AppComponent>;
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();
});
});
+1 -1
View File
@@ -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';
+39
View File
@@ -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');
});
});
+33
View File
@@ -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: () =>
+2 -2
View File
@@ -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 },
@@ -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 {
@@ -673,6 +673,7 @@
<div class="setting-item__control inline-actions">
<button
mat-button
[disabled]="isRemovingAllPlaylists()"
(click)="
$event.preventDefault();
$event.stopPropagation();
@@ -711,7 +712,11 @@
"
color="warn"
>
{{ 'SETTINGS.REMOVE_ALL_BUTTON' | translate }}
@if (isRemovingAllPlaylists()) {
<mat-spinner diameter="18" strokeWidth="3" />
} @else {
{{ 'SETTINGS.REMOVE_ALL_BUTTON' | translate }}
}
</button>
</div>
</div>
@@ -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<SettingsComponent>;
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<void>();
const dispatchSpy = jest.spyOn(store, 'dispatch');
(dialogService.openConfirmDialog as jest.Mock).mockImplementation(
({ onConfirm }: { onConfirm: () => Promise<void> }) => {
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
);
});
@@ -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<string | null>(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<void> => {
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);
}
},
});
}