mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user