test(settings): split the settings spec along the facade seams (#1277)

settings.component.spec.ts was 1516 lines and the last settings file in the
max-lines baseline. The behaviour that moved into facades now has its own
specs, driven directly instead of through the rendered page.

- settings-app-update.facade.spec.ts: status polling/retry, bridge actions,
  release notes dialog, version messaging, dispose
- settings-epg.facade.spec.ts: refresh, clear flow, post-save re-fetch
- settings-playlist-reset.facade.spec.ts: summary, dialog, Electron progress,
  browser fallback, failure snackbar
- settings-backup.facade.spec.ts: desktop export, browser download fallback
- settings.component.spec.ts keeps the page shell, the facade lifecycle seam
  and runtime capabilities; settings.component.form.spec.ts takes hydration,
  section outputs, dashboard controls and submit
- settings-section-scroll.directive.spec.ts gives the directive its first spec
- shared TestBed fixtures live in settings/test-stubs/, kept out of both the
  app build (.stub.ts) and the coverage ratchet (test-stubs/)

105 settings tests, up from 94; every file is under the 400-line limit, so
settings.component.spec.ts leaves the baseline.
This commit is contained in:
4gray authored and GitHub committed 2026-07-27 08:31:14 +02:00
1 parent e55d55b47f
commit e2300bea11
9 files changed
+1758 -1290

No files matched your search

@@ -0,0 +1,270 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { DataService } from '@iptvnator/services';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockProvider } from 'ng-mocks';
import { of } from 'rxjs';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { SettingsService } from '../services/settings.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
import {
createElectronStub,
DEFAULT_APP_UPDATE_STATUS,
MockSettingsService,
} from './test-stubs/settings-test-harness.stub';
/** The retry loop is deliberately private; tests drive it directly. */
interface SettingsAppUpdateFacadePrivateTestApi {
loadStatus(): Promise<void>;
waitForRetry(): Promise<void>;
}
describe('SettingsAppUpdateFacade', () => {
let facade: SettingsAppUpdateFacade;
let matDialog: MatDialog;
let dataService: DataService;
let translate: TranslateService;
const originalElectron = window.electron;
/** Lets the facade's async status load settle */
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const privateApi = (): SettingsAppUpdateFacadePrivateTestApi =>
facade as unknown as SettingsAppUpdateFacadePrivateTestApi;
beforeEach(() => {
window.electron = createElectronStub();
TestBed.configureTestingModule({
providers: [
SettingsAppUpdateFacade,
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(MatDialog, { open: jest.fn() }),
{ provide: SettingsService, useClass: MockSettingsService },
],
imports: [TranslateModule.forRoot()],
});
facade = TestBed.inject(SettingsAppUpdateFacade);
matDialog = TestBed.inject(MatDialog);
dataService = TestBed.inject(DataService);
translate = TestBed.inject(TranslateService);
});
afterEach(() => {
window.electron = originalElectron;
});
it('loads the desktop app update status and subscribes to status pushes', async () => {
const pushedStatus: ElectronBridgeAppUpdateStatus = {
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
};
facade.init();
await flush();
const statusHandler = (
window.electron.onAppUpdateStatusChange as jest.Mock
).mock.calls[0][0] as (status: ElectronBridgeAppUpdateStatus) => void;
statusHandler(pushedStatus);
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(
1
);
expect(facade.status()).toEqual(pushedStatus);
});
it('stops listening for status pushes once disposed', () => {
const unsubscribe = jest.fn();
(window.electron.onAppUpdateStatusChange as jest.Mock).mockReturnValue(
unsubscribe
);
facade.init();
facade.dispose();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it('retries the initial app update status load when IPC handlers are still starting', async () => {
const retriedStatus: ElectronBridgeAppUpdateStatus = {
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
};
(window.electron.getAppUpdateStatus as jest.Mock)
.mockReset()
.mockRejectedValueOnce(new Error('No handler registered'))
.mockResolvedValueOnce(retriedStatus);
jest.spyOn(privateApi(), 'waitForRetry').mockResolvedValue(undefined);
await privateApi().loadStatus();
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(2);
expect(facade.status()).toEqual(retriedStatus);
});
it('waits for the desktop app update bridge method before loading status', async () => {
const retriedStatus: ElectronBridgeAppUpdateStatus = {
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
};
delete (window.electron as Partial<typeof window.electron>)
.getAppUpdateStatus;
let retryCount = 0;
const getStatus = jest.fn().mockResolvedValue(retriedStatus);
jest.spyOn(privateApi(), 'waitForRetry').mockImplementation(
async () => {
retryCount += 1;
if (retryCount === 1) {
window.electron.getAppUpdateStatus = getStatus;
}
}
);
await privateApi().loadStatus();
expect(retryCount).toBe(1);
expect(getStatus).toHaveBeenCalledTimes(1);
expect(facade.status()).toEqual(retriedStatus);
});
it('forwards app update actions to the desktop bridge', async () => {
await facade.checkForAppUpdate();
await facade.downloadAppUpdate();
await facade.installAppUpdate();
expect(window.electron.checkForAppUpdate).toHaveBeenCalledTimes(1);
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
expect(window.electron.installAppUpdate).toHaveBeenCalledTimes(1);
});
it('opens the manual release URL from unsupported update status', () => {
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
facade.status.set({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
facade.openManualAppUpdate();
expect(openSpy).toHaveBeenCalledWith(
DEFAULT_APP_UPDATE_STATUS.manualDownloadUrl,
'_blank',
'noreferrer'
);
});
it('opens release notes dialog for the latest update version', () => {
const openSpy = jest.spyOn(matDialog, 'open').mockReturnValue({
afterClosed: () => of(false),
} as unknown as ReturnType<MatDialog['open']>);
facade.status.set({
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
facade.openReleaseNotes();
expect(openSpy).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
expect.objectContaining({
data: {
initialVersion: '0.23.0',
},
})
);
});
it('opens release notes dialog for the current version when no update is available', () => {
const openSpy = jest.spyOn(matDialog, 'open').mockReturnValue({
afterClosed: () => of(false),
} as unknown as ReturnType<MatDialog['open']>);
facade.status.set({
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.21.0',
release: {
version: '0.21.0',
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
facade.openReleaseNotes();
expect(openSpy).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
expect.objectContaining({
data: {
initialVersion: DEFAULT_APP_UPDATE_STATUS.currentVersion,
fallbackToLatest: true,
},
})
);
});
describe('Version check', () => {
const latestVersion = '1.0.0';
const currentVersion = '0.1.0';
beforeEach(() => {
const settingsService = TestBed.inject(SettingsService);
(settingsService.getAppVersion as jest.Mock).mockReturnValue(
of(latestVersion)
);
jest.spyOn(translate, 'instant').mockImplementation((key) => {
if (key === 'SETTINGS.NEW_VERSION_AVAILABLE') {
return 'New version available';
}
if (key === 'SETTINGS.LATEST_VERSION') {
return 'Latest version installed';
}
return key;
});
});
it('should return true if version is outdated', () => {
jest.spyOn(dataService, 'getAppVersion').mockReturnValue(
currentVersion
);
expect(facade.isCurrentVersionOutdated(latestVersion)).toBe(true);
});
it('should update notification message if version is outdated', () => {
jest.spyOn(dataService, 'getAppVersion').mockReturnValue(
currentVersion
);
facade.showVersionInformation(latestVersion);
expect(translate.instant).toHaveBeenCalledWith(
'SETTINGS.NEW_VERSION_AVAILABLE'
);
expect(facade.updateMessage()).toBe('New version available: 1.0.0');
});
it('reports the installed version as current when it matches', () => {
jest.spyOn(dataService, 'getAppVersion').mockReturnValue(
latestVersion
);
facade.showVersionInformation(latestVersion);
expect(facade.updateMessage()).toBe('Latest version installed');
expect(facade.version()).toBe(latestVersion);
});
});
});
@@ -0,0 +1,157 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
selectAllPlaylistsMeta,
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { PlaylistBackupService } from '@iptvnator/services';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockProvider } from 'ng-mocks';
import { SettingsBackupFacade } from './settings-backup.facade';
import { SettingsSnackbarService } from './settings-snackbar.service';
import {
BACKUP_EXPORT_RESULT,
createElectronStub,
MatSnackBarStub,
} from './test-stubs/settings-test-harness.stub';
describe('SettingsBackupFacade', () => {
let facade: SettingsBackupFacade;
let playlistBackupService: PlaylistBackupService;
const originalElectron = window.electron;
/** The component normally supplies a paint frame; tests skip the wait */
const noWait = () => Promise.resolve();
function configure(): void {
TestBed.configureTestingModule({
providers: [
SettingsBackupFacade,
SettingsSnackbarService,
{ provide: MatSnackBar, useClass: MatSnackBarStub },
MockProvider(PlaylistBackupService, {
exportBackup: jest
.fn()
.mockResolvedValue(BACKUP_EXPORT_RESULT),
importBackup: jest.fn(),
}),
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: [] },
{ selector: selectIsEpgAvailable, value: false },
],
}),
],
imports: [TranslateModule.forRoot()],
});
facade = TestBed.inject(SettingsBackupFacade);
playlistBackupService = TestBed.inject(PlaylistBackupService);
}
beforeEach(() => {
window.electron = createElectronStub();
});
afterEach(() => {
window.electron = originalElectron;
});
it('shows an export busy state until the backup file has been written', async () => {
configure();
let resolveExport: (value: typeof BACKUP_EXPORT_RESULT) => void = () =>
undefined;
(playlistBackupService.exportBackup as jest.Mock).mockReturnValueOnce(
new Promise((resolve) => {
resolveExport = resolve;
})
);
const exportPromise = facade.exportData(noWait);
expect(facade.isExportingData()).toBe(true);
resolveExport(BACKUP_EXPORT_RESULT);
await exportPromise;
expect(window.electron.saveFileDialog).toHaveBeenCalledWith(
BACKUP_EXPORT_RESULT.defaultFileName,
[
{
extensions: ['json'],
name: 'JSON',
},
]
);
expect(window.electron.writeFile).toHaveBeenCalledWith(
'/tmp/backup.json',
'{}'
);
expect(facade.isExportingData()).toBe(false);
});
it('falls back to browser backup download when desktop file-save preload is incomplete', async () => {
const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json');
window.electron = {
platform: 'linux',
saveFileDialog,
} as unknown as typeof window.electron;
configure();
const createObjectURL = jest.fn().mockReturnValue('blob:backup');
const revokeObjectURL = jest.fn();
const originalCreateObjectURL = window.URL.createObjectURL;
const originalRevokeObjectURL = window.URL.revokeObjectURL;
Object.defineProperty(window.URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation();
try {
await facade.exportData(noWait);
expect(saveFileDialog).not.toHaveBeenCalled();
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup');
} finally {
clickSpy.mockRestore();
Object.defineProperty(window.URL, 'createObjectURL', {
configurable: true,
value: originalCreateObjectURL,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
configurable: true,
value: originalRevokeObjectURL,
});
}
});
it('reports a failed export and clears the busy state', async () => {
configure();
(playlistBackupService.exportBackup as jest.Mock).mockRejectedValueOnce(
new Error('disk full')
);
jest.spyOn(console, 'error').mockImplementation();
const snackBar = TestBed.inject(
MatSnackBar
) as unknown as MatSnackBarStub;
await facade.exportData(noWait);
expect(facade.isExportingData()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'Playlist backup export failed.',
undefined,
expect.objectContaining({ panelClass: ['settings-snackbar'] })
);
});
});
@@ -0,0 +1,177 @@
import { TestBed } from '@angular/core/testing';
import { UntypedFormBuilder } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import { DialogService } from '@iptvnator/ui/components';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockProvider } from 'ng-mocks';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
import { SettingsEpgFacade } from './settings-epg.facade';
import { SettingsFormFacade } from './settings-form.facade';
import { SettingsSnackbarService } from './settings-snackbar.service';
import {
createElectronStub,
createEpgBridgeStub,
MatSnackBarStub,
MockSettingsService,
MockSettingsStore,
} from './test-stubs/settings-test-harness.stub';
describe('SettingsEpgFacade', () => {
let facade: SettingsEpgFacade;
let formFacade: SettingsFormFacade;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgService: EpgService;
let dialogService: DialogService;
let snackBar: MatSnackBarStub;
let translate: TranslateService;
const originalElectron = window.electron;
/** The confirm dialog is a mock, so tests trigger its callback directly */
const confirmImmediately = () => {
(dialogService.openConfirmDialog as jest.Mock).mockImplementation(
({ onConfirm }: { onConfirm: () => Promise<void> }) => {
void onConfirm();
}
);
};
beforeEach(() => {
window.electron = createElectronStub();
epgBridge = createEpgBridgeStub();
TestBed.configureTestingModule({
providers: [
UntypedFormBuilder,
SettingsEpgFacade,
SettingsFormFacade,
SettingsSnackbarService,
{ provide: EpgRuntimeBridgeService, useValue: epgBridge },
MockProvider(EpgService, { fetchEpg: jest.fn() }),
MockProvider(DialogService, { openConfirmDialog: jest.fn() }),
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: SettingsService, useClass: MockSettingsService },
{ provide: SettingsStore, useClass: MockSettingsStore },
],
imports: [TranslateModule.forRoot()],
});
facade = TestBed.inject(SettingsEpgFacade);
formFacade = TestBed.inject(SettingsFormFacade);
epgService = TestBed.inject(EpgService);
dialogService = TestBed.inject(DialogService);
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
translate = TestBed.inject(TranslateService);
jest.spyOn(translate, 'instant').mockImplementation((key) => key);
});
afterEach(() => {
window.electron = originalElectron;
});
it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => {
const url = 'http://epg-url-here/data.xml';
facade.refresh(url);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, {
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
});
it('skips the refresh when the URL is empty or EPG is unmanaged', () => {
facade.refresh('');
epgBridge.supportsDataManagement = false;
facade.refresh('http://epg-url-here/data.xml');
expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled();
});
it('force-fetches every configured source, skipping blank entries', () => {
formFacade.setEpgUrls([
'http://first.example/guide.xml',
'http://second.example/guide.xml',
]);
formFacade.addEpgSource();
facade.refreshAll();
expect(epgBridge.forceFetchEpg).toHaveBeenCalledTimes(2);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
'http://first.example/guide.xml',
expect.anything()
);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
'http://second.example/guide.xml',
expect.anything()
);
});
it('clears EPG data with a busy state and refreshes all sources on success', async () => {
let resolveClear: () => void = () => undefined;
const clearPromise = new Promise<{ success: boolean }>((resolve) => {
resolveClear = () => resolve({ success: true });
});
(epgBridge.clearEpgData as jest.Mock).mockReturnValue(clearPromise);
confirmImmediately();
const refreshSpy = jest.spyOn(facade, 'refreshAll');
facade.clear();
expect(facade.isClearing()).toBe(true);
expect(refreshSpy).not.toHaveBeenCalled();
resolveClear();
await clearPromise;
await Promise.resolve();
expect(facade.isClearing()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEARED',
undefined,
expect.objectContaining({ panelClass: ['settings-snackbar'] })
);
expect(refreshSpy).toHaveBeenCalled();
});
it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => {
(epgBridge.clearEpgData as jest.Mock).mockRejectedValueOnce(
new Error('boom')
);
confirmImmediately();
const refreshSpy = jest.spyOn(facade, 'refreshAll');
jest.spyOn(console, 'error').mockImplementation();
facade.clear();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(facade.isClearing()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEAR_FAILED',
undefined,
expect.objectContaining({ panelClass: ['settings-snackbar'] })
);
expect(refreshSpy).not.toHaveBeenCalled();
});
it('re-fetches the saved sources after the settings are stored', () => {
formFacade.setEpgUrls(['http://saved.example/guide.xml']);
facade.fetchConfiguredEpg();
expect(epgService.fetchEpg).toHaveBeenCalledWith([
'http://saved.example/guide.xml',
]);
});
it('does not re-fetch when no EPG source is configured', () => {
facade.fetchConfiguredEpg();
expect(epgService.fetchEpg).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,244 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
PlaylistActions,
selectAllPlaylistsMeta,
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { Store } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockProvider } from 'ng-mocks';
import { of } from 'rxjs';
import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
import { SettingsSnackbarService } from './settings-snackbar.service';
import {
createDialogRef,
createElectronStub,
createPlaylistMeta,
MatSnackBarStub,
} from './test-stubs/settings-test-harness.stub';
describe('SettingsPlaylistResetFacade', () => {
let facade: SettingsPlaylistResetFacade;
let matDialog: MatDialog;
let databaseService: DatabaseService;
let playlistsService: PlaylistsService;
let store: Store;
let mockStore: MockStore;
let snackBar: MatSnackBarStub;
let translate: TranslateService;
const originalElectron = window.electron;
/** The component normally supplies a paint frame; tests skip the wait */
const noWait = () => Promise.resolve();
function setPlaylists(playlists: PlaylistMeta[]): void {
mockStore.overrideSelector(selectAllPlaylistsMeta, playlists);
mockStore.refreshState();
}
beforeEach(() => {
window.electron = createElectronStub();
TestBed.configureTestingModule({
providers: [
SettingsPlaylistResetFacade,
SettingsSnackbarService,
MockProvider(MatDialog, { open: jest.fn() }),
{ provide: MatSnackBar, useClass: MatSnackBarStub },
MockProvider(DatabaseService, {
createOperationId: jest
.fn()
.mockReturnValue('delete-all-op'),
deleteAllPlaylists: jest.fn().mockResolvedValue(true),
}),
MockProvider(PlaylistsService, {
removeAll: jest.fn().mockReturnValue(of(undefined)),
}),
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: [] },
{ selector: selectIsEpgAvailable, value: false },
],
}),
],
imports: [TranslateModule.forRoot()],
});
facade = TestBed.inject(SettingsPlaylistResetFacade);
matDialog = TestBed.inject(MatDialog);
databaseService = TestBed.inject(DatabaseService);
playlistsService = TestBed.inject(PlaylistsService);
store = TestBed.inject(Store);
mockStore = TestBed.inject(MockStore);
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
translate = TestBed.inject(TranslateService);
jest.spyOn(translate, 'instant').mockImplementation(
(key: string, params?: Record<string, number>) =>
key === 'SETTINGS.REMOVE_ALL_PROGRESS'
? `${params?.current}/${params?.total}`
: key
);
});
afterEach(() => {
window.electron = originalElectron;
});
it('refuses the global wipe when there are no playlists', () => {
setPlaylists([]);
expect(facade.deleteSummary().total).toBe(0);
expect(facade.canRemoveAll()).toBe(false);
facade.confirmAndRemoveAll(noWait);
expect(matDialog.open).not.toHaveBeenCalled();
});
it('opens the dedicated delete-all dialog with the current playlist type summary', () => {
setPlaylists([
createPlaylistMeta({ _id: 'm3u-1' }),
createPlaylistMeta({
_id: 'xtream-1',
serverUrl: 'http://xtream.example',
}),
createPlaylistMeta({
_id: 'stalker-1',
macAddress: '00:11:22:33:44:55',
}),
]);
const openSpy = jest
.spyOn(matDialog, 'open')
.mockReturnValue(createDialogRef(false));
facade.confirmAndRemoveAll(noWait);
expect(facade.deleteSummary()).toEqual({
total: 3,
m3u: 1,
xtream: 1,
stalker: 1,
});
expect(openSpy).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: {
summary: {
total: 3,
m3u: 1,
xtream: 1,
stalker: 1,
},
},
})
);
});
it('tracks Electron delete-all progress and dispatches store cleanup after success', async () => {
setPlaylists([
createPlaylistMeta({ _id: 'm3u-1' }),
createPlaylistMeta({
_id: 'xtream-1',
serverUrl: 'http://xtream.example',
}),
]);
const dispatchSpy = jest.spyOn(store, 'dispatch');
jest.spyOn(matDialog, 'open').mockReturnValue(createDialogRef(true));
let resolveDelete: (value: boolean) => void = () => undefined;
(databaseService.deleteAllPlaylists as jest.Mock).mockImplementation(
({
onEvent,
}: {
onEvent?: (event: {
operation: string;
status: string;
current?: number;
total?: number;
}) => void;
}) => {
onEvent?.({
operation: 'delete-all-playlists',
status: 'progress',
current: 3,
total: 7,
});
return new Promise<boolean>((resolve) => {
resolveDelete = resolve;
});
}
);
facade.confirmAndRemoveAll(noWait);
await Promise.resolve();
expect(facade.isRemovingAllPlaylists()).toBe(true);
expect(facade.removeAllProgressLabel()).toBe('3/7');
expect(databaseService.deleteAllPlaylists).toHaveBeenCalledWith(
expect.objectContaining({
operationId: 'delete-all-op',
onEvent: expect.any(Function),
})
);
resolveDelete(true);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(facade.isRemovingAllPlaylists()).toBe(false);
expect(facade.removeAllProgress()).toBeNull();
expect(dispatchSpy).toHaveBeenCalledWith(
PlaylistActions.removeAllPlaylists()
);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.PLAYLISTS_REMOVED',
undefined,
{
duration: 2000,
horizontalPosition: 'center',
panelClass: ['settings-snackbar'],
verticalPosition: 'bottom',
}
);
});
it('falls back to PlaylistsService.removeAll outside Electron', async () => {
window.electron = undefined as unknown as typeof window.electron;
setPlaylists([createPlaylistMeta({ _id: 'browser-m3u' })]);
const dispatchSpy = jest.spyOn(store, 'dispatch');
jest.spyOn(matDialog, 'open').mockReturnValue(createDialogRef(true));
facade.confirmAndRemoveAll(noWait);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(playlistsService.removeAll).toHaveBeenCalled();
expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith(
PlaylistActions.removeAllPlaylists()
);
});
it('surfaces a failure snackbar when the wipe reports no success', async () => {
setPlaylists([createPlaylistMeta({ _id: 'm3u-1' })]);
(databaseService.deleteAllPlaylists as jest.Mock).mockResolvedValue(
false
);
jest.spyOn(matDialog, 'open').mockReturnValue(createDialogRef(true));
jest.spyOn(console, 'error').mockImplementation();
facade.confirmAndRemoveAll(noWait);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(facade.isRemovingAllPlaylists()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.PLAYLISTS_REMOVE_FAILED',
undefined,
expect.objectContaining({ panelClass: ['settings-snackbar'] })
);
});
});
@@ -0,0 +1,107 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { SettingsComponent } from './settings.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
interface SettingsSectionScrollDirectiveTestApi {
getScrollRoot(): HTMLElement | null;
}
/**
* The directive only makes sense against a real settings page, so this is an
* integration spec: it renders the component and drives the section
* navigation the workspace shell would trigger.
*/
describe('SettingsSectionScrollDirective', () => {
let fixture: ComponentFixture<SettingsComponent>;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub();
configureSettingsComponentTestBed(epgBridge);
}));
beforeEach(() => {
window.electron = createElectronStub();
});
afterEach(() => {
window.electron = originalElectron;
});
it('should scroll the selected navigation target within the workspace viewport', async () => {
jest.useFakeTimers();
try {
fixture = TestBed.createComponent(SettingsComponent);
const component = fixture.componentInstance;
const settingsContext = TestBed.inject(SettingsContextService);
const originalGetElementById =
document.getElementById.bind(document);
const scrollTo = jest.fn();
const scrollRoot = {
scrollTop: 96,
clientHeight: 885,
scrollHeight: 2469,
getBoundingClientRect: () =>
({
top: 56,
}) as DOMRect,
scrollTo,
} as unknown as HTMLElement;
stubSettingsSideEffects(component);
fixture.detectChanges();
const scrollDirective = fixture.debugElement
.query(By.directive(SettingsSectionScrollDirective))
.injector.get(SettingsSectionScrollDirective);
jest.spyOn(
scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi,
'getScrollRoot'
).mockReturnValue(scrollRoot);
const getElementByIdSpy = jest
.spyOn(document, 'getElementById')
.mockImplementation((id: string) => {
if (id === 'about') {
return {
getBoundingClientRect: () =>
({
top: 2050,
height: 159,
}) as DOMRect,
} as HTMLElement;
}
return originalGetElementById(id);
});
await fixture.whenStable();
fixture.detectChanges();
settingsContext.navigateToSection('about');
fixture.detectChanges();
expect(getElementByIdSpy).toHaveBeenCalledWith('about');
expect(scrollTo).toHaveBeenCalledWith({
behavior: 'smooth',
top: 1488,
});
expect(settingsContext.pendingScrollTarget()).toBe('about');
jest.advanceTimersByTime(600);
expect(settingsContext.pendingScrollTarget()).toBeNull();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,350 @@
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<SettingsComponent>;
let settingsStore: MockSettingsStore;
let translate: TranslateService;
let snackBar: MatSnackBarStub;
let epgBridge: Partial<EpgRuntimeBridgeService>;
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();
});
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,340 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
UntypedFormBuilder,
} from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import {
selectAllPlaylistsMeta,
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import {
DatabaseService,
DataService,
PlaylistBackupService,
PlaylistsService,
} from '@iptvnator/services';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
Language,
PlaylistMeta,
StartupBehavior,
StreamFormat,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { DialogService } from '@iptvnator/ui/components';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule, MockProvider } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { from, of } from 'rxjs';
import { ElectronServiceStub } from '../../services/electron.service.stub';
import { SettingsStore } from '../../services/settings-store.service';
import { SettingsService } from '../../services/settings.service';
import { SettingsComponent } from '../settings.component';
/**
* Shared fixtures for the settings specs.
*
* Both halves of the name matter: the `.stub.ts` suffix keeps the file out of
* the Angular app build (`tsconfig.app.json` excludes it), and the
* `test-stubs/` directory keeps it out of coverage — `collectCoverageFrom`,
* `tools/coverage/run-tier-a-coverage.mjs` and the integrity checker all
* exclude that path, so this harness never lands in the coverage ratchet as
* production source.
*/
export const DEFAULT_DASHBOARD_RAILS = {
hero: true,
continueWatching: true,
liveFavorites: true,
recentlyWatchedLive: true,
favoriteMoviesAndSeries: true,
recentSources: true,
xtreamRecentlyAdded: true,
tmdbTrending: true,
};
export const DEFAULT_SETTINGS = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
recordingFolder: '',
embeddedMpvFrameCopy: false,
coverSize: 'medium',
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline',
tmdb: { enabled: false, apiKey: '' },
};
export const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
currentVersion: '0.22.0',
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
};
export class MatSnackBarStub {
open = jest.fn();
}
export class MockRouter {
navigateByUrl(url: string): string {
return url;
}
}
export class MockSettingsStore {
private _settings = signal(DEFAULT_SETTINGS);
getSettings = () => this._settings();
getTrustOptions = () => ({
trustedPrivateNetworkEpgUrls:
(
this._settings() as typeof DEFAULT_SETTINGS & {
trustedPrivateNetworkEpgUrls?: string[];
}
).trustedPrivateNetworkEpgUrls ?? [],
trustedInsecureTlsHosts:
(
this._settings() as typeof DEFAULT_SETTINGS & {
trustedInsecureTlsHosts?: string[];
}
).trustedInsecureTlsHosts ?? [],
});
loadSettings = jest.fn().mockResolvedValue(undefined);
updateSettings = jest.fn().mockResolvedValue(undefined);
// Helper method for tests to modify settings
_setSettings(newSettings: Partial<typeof DEFAULT_SETTINGS>) {
this._settings.set({
...this._settings(),
...newSettings,
});
}
}
export class MockSettingsService {
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
);
}
export function createEpgBridgeStub(): Partial<EpgRuntimeBridgeService> {
return {
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
supportsDataManagement: true,
supportsImport: true,
};
}
/** The full desktop bridge the settings page expects to be present */
export function createElectronStub(): typeof window.electron {
return {
checkEpgFreshness: jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: [],
}),
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest.fn().mockResolvedValue({ success: true }),
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
getAppUpdateStatus: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
getChannelPrograms: jest.fn().mockResolvedValue([]),
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
checkForAppUpdate: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
downloadAppUpdate: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
installAppUpdate: jest
.fn()
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
onAppUpdateStatusChange: jest.fn(() => jest.fn()),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
searchEpgPrograms: jest.fn().mockResolvedValue([]),
saveFileDialog: jest.fn().mockResolvedValue('/tmp/backup.json'),
setMpvPlayerPath: jest.fn().mockResolvedValue(undefined),
setVlcPlayerPath: jest.fn().mockResolvedValue(undefined),
updateSettings: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue({ success: true }),
} as unknown as typeof window.electron;
}
export const PLAYLIST_IMPORT_DATE = '2026-04-21T00:00:00.000Z';
export function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: overrides._id ?? 'playlist-id',
title: overrides.title ?? 'Playlist',
count: overrides.count ?? 10,
importDate: overrides.importDate ?? PLAYLIST_IMPORT_DATE,
autoRefresh: overrides.autoRefresh ?? false,
...overrides,
};
}
export function createDialogRef(
result: boolean
): ReturnType<MatDialog['open']> {
return {
afterClosed: () => of(result),
} as unknown as ReturnType<MatDialog['open']>;
}
export const BACKUP_EXPORT_RESULT = {
defaultFileName: 'iptvnator-playlist-backup-2026-04-21.json',
json: '{}',
manifest: {
kind: 'iptvnator-playlist-backup',
version: 1,
exportedAt: PLAYLIST_IMPORT_DATE,
includeSecrets: true,
playlists: [],
},
};
/** Providers every settings spec needs, whether or not it renders the page */
export function settingsTestProviders(
epgBridge: Partial<EpgRuntimeBridgeService>
) {
return [
UntypedFormBuilder,
{ provide: SettingsStore, useClass: MockSettingsStore },
MockProvider(EpgService, { fetchEpg: jest.fn() }),
{ provide: EpgRuntimeBridgeService, useValue: epgBridge },
MockProvider(DialogService, { openConfirmDialog: jest.fn() }),
MockProvider(MatDialog, { open: jest.fn() }),
{ provide: SettingsService, useClass: MockSettingsService },
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
{ provide: Router, useClass: MockRouter },
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: [] },
{ selector: selectIsEpgAvailable, value: false },
],
}),
{ provide: NgxIndexedDBService, useValue: {} },
MockProvider(PlaylistsService, {
getAllData: jest.fn().mockReturnValue(of([])),
removeAll: jest.fn(),
}),
MockProvider(DatabaseService, {
createOperationId: jest.fn().mockReturnValue('delete-all-op'),
deleteAllPlaylists: jest.fn().mockResolvedValue(true),
}),
MockProvider(PlaylistBackupService, {
exportBackup: jest.fn().mockResolvedValue(BACKUP_EXPORT_RESULT),
importBackup: jest.fn().mockResolvedValue({
imported: 0,
merged: 0,
skipped: 0,
failed: 0,
errors: [],
}),
}),
];
}
/** TestBed setup for specs that render the whole settings page */
export function configureSettingsComponentTestBed(
epgBridge: Partial<EpgRuntimeBridgeService>
): void {
TestBed.configureTestingModule({
providers: settingsTestProviders(epgBridge),
imports: [
SettingsComponent,
HttpClientTestingModule,
FormsModule,
MockModule(MatSelectModule),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
ReactiveFormsModule,
MockModule(RouterTestingModule),
MockModule(MatCardModule),
MockModule(MatListModule),
MockModule(MatFormFieldModule),
MockModule(MatCheckboxModule),
MockModule(MatDividerModule),
TranslateModule.forRoot(),
],
}).compileComponents();
}
/**
* `ngOnInit` kicks off a version check and a LAN address lookup; both are
* stubbed on the owning facades so tests stay deterministic.
*/
export function stubSettingsSideEffects(
settingsComponent: SettingsComponent
): void {
jest.spyOn(
settingsComponent.appUpdate,
'checkAppVersion'
).mockImplementation();
jest.spyOn(
settingsComponent.remoteControl,
'fetchLocalIpAddresses'
).mockResolvedValue(undefined);
}
-1
View File
@@ -44,7 +44,6 @@ export const maxLinesBaseline = [
'apps/web-e2e/src/xtream.e2e.ts',
'apps/web/src/app/services/electron.service.ts',
'apps/web/src/app/services/pwa.service.ts',
'apps/web/src/app/settings/settings.component.spec.ts',
'apps/xtream-mock-server/src/app/generators/marketing.generator.ts',
'libs/epg/data-access/src/lib/epg.service.spec.ts',
'libs/epg/data-access/src/lib/epg.service.ts',