diff --git a/apps/web/src/app/settings/settings-app-update.facade.spec.ts b/apps/web/src/app/settings/settings-app-update.facade.spec.ts new file mode 100644 index 000000000..79fa59389 --- /dev/null +++ b/apps/web/src/app/settings/settings-app-update.facade.spec.ts @@ -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; + waitForRetry(): Promise; +} + +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((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) + .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); + 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); + 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); + }); + }); +}); diff --git a/apps/web/src/app/settings/settings-backup.facade.spec.ts b/apps/web/src/app/settings/settings-backup.facade.spec.ts new file mode 100644 index 000000000..e4e70ea9d --- /dev/null +++ b/apps/web/src/app/settings/settings-backup.facade.spec.ts @@ -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'] }) + ); + }); +}); diff --git a/apps/web/src/app/settings/settings-epg.facade.spec.ts b/apps/web/src/app/settings/settings-epg.facade.spec.ts new file mode 100644 index 000000000..dd8683299 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg.facade.spec.ts @@ -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; + 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 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((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(); + }); +}); diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts new file mode 100644 index 000000000..e9b3f8a5c --- /dev/null +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts @@ -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) => + 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((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((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((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((resolve) => setTimeout(resolve, 0)); + + expect(facade.isRemovingAllPlaylists()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.PLAYLISTS_REMOVE_FAILED', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + }); +}); diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts b/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts new file mode 100644 index 000000000..f2c402094 --- /dev/null +++ b/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts @@ -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; + let epgBridge: Partial; + 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(); + } + }); +}); diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts new file mode 100644 index 000000000..c11db5c82 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -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; + let settingsStore: MockSettingsStore; + let translate: TranslateService; + let snackBar: MatSnackBarStub; + let epgBridge: Partial; + const originalElectron = window.electron; + + beforeEach(waitForAsync(() => { + epgBridge = createEpgBridgeStub(); + configureSettingsComponentTestBed(epgBridge); + })); + + beforeEach(() => { + window.electron = createElectronStub(); + + fixture = TestBed.createComponent(SettingsComponent); + settingsStore = TestBed.inject( + SettingsStore + ) as unknown as MockSettingsStore; + translate = TestBed.inject(TranslateService); + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; + + component = fixture.componentInstance; + stubSettingsSideEffects(component); + fixture.detectChanges(); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + describe('Get and set settings on component init', () => { + const settings = { + language: Language.GERMAN, + player: VideoPlayer.Html5Player, + theme: Theme.DarkTheme, + }; + + it('should init default settings if previous config was not saved', async () => { + await component.ngOnInit(); + + expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); + }); + + it('should get and apply custom settings', () => { + settingsStore._setSettings({ + ...DEFAULT_SETTINGS, + ...settings, + }); + + component.form.hydrateFromStore(); + + expect(component.settingsForm.value).toEqual({ + ...DEFAULT_SETTINGS, + ...settings, + }); + }); + + it('hydrates the shared web controls setting from the settings store', () => { + settingsStore._setSettings({ + webPlayerSharedControls: true, + }); + + component.form.hydrateFromStore(); + + expect( + component.settingsForm.get('webPlayerSharedControls')?.value + ).toBe(true); + }); + }); + + describe('Section outputs', () => { + it('updates the selected theme through the general section and marks the form dirty', () => { + const darkThemeButton = ( + fixture.nativeElement as HTMLElement + ).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement; + + darkThemeButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme); + expect(component.settingsForm.dirty).toBeTruthy(); + }); + + it('updates cover size through the general section output', () => { + const largeCoverButton = ( + fixture.nativeElement as HTMLElement + ).querySelector( + '[data-test-id="cover-size-large"]' + ) as HTMLButtonElement; + + largeCoverButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.coverSize).toBe('large'); + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + coverSize: 'large', + }); + }); + + it('updates the EPG view mode through the epg section output', () => { + const listButton = ( + fixture.nativeElement as HTMLElement + ).querySelector( + '[data-test-id="epg-view-mode-list"]' + ) as HTMLButtonElement; + + listButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.epgViewMode).toBe('list'); + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + epgViewMode: 'list', + }); + }); + }); + + describe('Dashboard controls', () => { + it('renders dashboard controls with the expected defaults', () => { + const nativeElement = fixture.nativeElement as HTMLElement; + + expect( + nativeElement.querySelector( + '[data-test-id="toggle-show-dashboard"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-hero"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-rail-live-favorites"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-rail-recently-watched-live"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-rail-favorite-movies-and-series"]' + ) + ).not.toBeNull(); + expect(component.settingsForm.value.showDashboard).toBe(true); + expect(component.settingsForm.value.dashboardRails).toEqual( + DEFAULT_DASHBOARD_RAILS + ); + expect(component.settingsForm.value.startupBehavior).toBe( + StartupBehavior.FirstView + ); + }); + + it('disables dashboard surface controls when the dashboard is off', async () => { + const showDashboard = component.settingsForm.get('showDashboard'); + const dashboardRails = component.settingsForm.get('dashboardRails'); + + showDashboard?.setValue(false); + await fixture.whenStable(); + + expect(dashboardRails?.disabled).toBe(true); + expect( + component.settingsForm.get('dashboardRails.hero')?.disabled + ).toBe(true); + expect( + component.settingsForm.get('dashboardRails.continueWatching') + ?.disabled + ).toBe(true); + + showDashboard?.setValue(true); + await fixture.whenStable(); + + expect(dashboardRails?.enabled).toBe(true); + expect( + component.settingsForm.get('dashboardRails.hero')?.enabled + ).toBe(true); + }); + }); + + describe('Saving', () => { + it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => { + jest.spyOn(translate, 'instant').mockReturnValue('Settings saved'); + + component.applyChangedSettings(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Settings saved', + undefined, + { + duration: 2000, + horizontalPosition: 'center', + panelClass: ['settings-snackbar'], + verticalPosition: 'bottom', + } + ); + }); + + it('should save settings on submit', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const updateSettings = jest.spyOn( + window.electron, + 'updateSettings' + ); + + component.onSubmit(); + await fixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + expect(updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + }); + + it('saves the shared web controls setting on submit', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + component.settingsForm + .get('webPlayerSharedControls') + ?.setValue(true); + + component.onSubmit(); + await fixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + webPlayerSharedControls: true, + }) + ); + }); + + it('clears external player paths in Electron when saved as empty', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const setMpvPlayerPath = jest.spyOn( + window.electron, + 'setMpvPlayerPath' + ); + const setVlcPlayerPath = jest.spyOn( + window.electron, + 'setVlcPlayerPath' + ); + + component.settingsForm.patchValue({ + mpvPlayerPath: '', + vlcPlayerPath: '', + }); + + component.onSubmit(); + await fixture.whenStable(); + + expect(setMpvPlayerPath).toHaveBeenCalledWith(''); + expect(setVlcPlayerPath).toHaveBeenCalledWith(''); + }); + + it('saves external player command-line arguments with the settings payload', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const updateSettings = jest.spyOn( + window.electron, + 'updateSettings' + ); + + component.settingsForm.patchValue({ + mpvPlayerArguments: '--screen=1\n--geometry=1280x720', + vlcPlayerArguments: '--qt-fullscreen-screennumber=1', + }); + + component.onSubmit(); + await fixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + mpvPlayerArguments: '--screen=1\n--geometry=1280x720', + vlcPlayerArguments: '--qt-fullscreen-screennumber=1', + }) + ); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + mpvPlayerArguments: '--screen=1\n--geometry=1280x720', + vlcPlayerArguments: '--qt-fullscreen-screennumber=1', + }) + ); + }); + + it('preserves saved EPG settings when saving from the web settings form', async () => { + fixture.destroy(); + window.electron = undefined as unknown as typeof window.electron; + + settingsStore._setSettings({ + epgUrl: ['https://example.com/guide.xml'], + preferUploadedEpgOverXtream: true, + }); + settingsStore.updateSettings.mockResolvedValue(undefined); + + const webFixture = TestBed.createComponent(SettingsComponent); + const webComponent = webFixture.componentInstance; + stubSettingsSideEffects(webComponent); + webFixture.detectChanges(); + await webFixture.whenStable(); + + webComponent.settingsForm.patchValue({ theme: Theme.DarkTheme }); + webComponent.onSubmit(); + await webFixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + epgUrl: ['https://example.com/guide.xml'], + preferUploadedEpgOverXtream: true, + theme: Theme.DarkTheme, + }) + ); + + webFixture.destroy(); + }); + }); +}); diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index e04849a25..72442aec9 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -1,383 +1,49 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed, waitForAsync } 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 { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { - EpgRuntimeBridgeService, - EpgService, -} from '@iptvnator/epg/data-access'; -import { Store } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { DialogService } from '@iptvnator/ui/components'; -import { MockModule, MockProvider } from 'ng-mocks'; -import { - DatabaseService, - DataService, - PlaylistBackupService, - PlaylistsService, -} from '@iptvnator/services'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { EmbeddedMpvSupport, - ELECTRON_BRIDGE_APP_UPDATE_STATUSES, - ElectronBridgeAppUpdateStatus, - Language, PlaylistMeta, - StartupBehavior, - StreamFormat, - Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; +import { MockStore } from '@ngrx/store/testing'; import { SettingsComponent } from './settings.component'; -import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; -import { SettingsAppUpdateFacade } from './settings-app-update.facade'; - -import { signal } from '@angular/core'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { - PlaylistActions, - selectAllPlaylistsMeta, - selectIsEpgAvailable, -} from '@iptvnator/m3u-state'; -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 { SettingsSectionScrollDirective } from './settings-section-scroll.directive'; - -const DEFAULT_DASHBOARD_RAILS = { - hero: true, - continueWatching: true, - liveFavorites: true, - recentlyWatchedLive: true, - favoriteMoviesAndSeries: true, - recentSources: true, - xtreamRecentlyAdded: true, - tmdbTrending: true, -}; - -class MatSnackBarStub { - open = jest.fn(); -} - -export class MockRouter { - navigateByUrl(url: string): string { - return url; - } -} - -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: '' }, -}; - -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, -}; - -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) { - this._settings.set({ - ...this._settings(), - ...newSettings, - }); - } -} - -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 - ); -} - -interface SettingsSectionScrollDirectiveTestApi { - getScrollRoot(): HTMLElement | null; -} - -interface SettingsComponentPrivateTestApi { - matDialog: MatDialog; - waitForUiFeedbackFrame(): Promise; -} - -interface SettingsAppUpdateFacadePrivateTestApi { - loadStatus(): Promise; - waitForRetry(): Promise; -} + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + createPlaylistMeta, + DEFAULT_SETTINGS, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; /** - * `ngOnInit` kicks off a version check and a LAN address lookup; both are - * stubbed on the owning facades so tests stay deterministic. + * Page-shell behaviour: chrome, the facade lifecycle and the runtime + * capabilities that decide which sections and players are offered. Form + * editing and saving live in `settings.component.form.spec.ts`, section + * scrolling in `settings-section-scroll.directive.spec.ts`, and the + * per-section behaviour in the matching `*.facade.spec.ts` files. */ -function stubSettingsSideEffects(settingsComponent: SettingsComponent): void { - jest.spyOn( - settingsComponent.appUpdate, - 'checkAppVersion' - ).mockImplementation(); - jest.spyOn( - settingsComponent.remoteControl, - 'fetchLocalIpAddresses' - ).mockResolvedValue(undefined); -} - describe('SettingsComponent', () => { let component: SettingsComponent; let fixture: ComponentFixture; - let electronService: DataService; let router: Router; - let settingsStore: unknown; - let translate: TranslateService; - let dialogService: DialogService; - let playlistsService: PlaylistsService; - let playlistBackupService: PlaylistBackupService; - let store: Store; let mockStore: MockStore; - let databaseService: DatabaseService; - let snackBar: MatSnackBarStub; let epgBridge: Partial; const originalElectron = window.electron; - const importDate = '2026-04-21T00:00:00.000Z'; - - const createPlaylistMeta = ( - overrides: Partial = {} - ): PlaylistMeta => ({ - _id: overrides._id ?? 'playlist-id', - title: overrides.title ?? 'Playlist', - count: overrides.count ?? 10, - importDate: overrides.importDate ?? importDate, - autoRefresh: overrides.autoRefresh ?? false, - ...overrides, - }); - - const createDialogRef = (result: boolean): ReturnType => - ({ - afterClosed: () => of(result), - }) as unknown as ReturnType; beforeEach(waitForAsync(() => { - epgBridge = { - clearEpgData: jest.fn().mockResolvedValue({ success: true }), - clearEpgDataForSource: jest - .fn() - .mockResolvedValue({ success: true }), - forceFetchEpg: jest.fn().mockResolvedValue({ success: true }), - supportsDataManagement: true, - supportsImport: true, - }; - - TestBed.configureTestingModule({ - providers: [ - 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({ - defaultFileName: - 'iptvnator-playlist-backup-2026-04-21.json', - json: '{}', - manifest: { - kind: 'iptvnator-playlist-backup', - version: 1, - exportedAt: '2026-04-21T00:00:00.000Z', - includeSecrets: true, - playlists: [], - }, - }), - importBackup: jest.fn().mockResolvedValue({ - imported: 0, - merged: 0, - skipped: 0, - failed: 0, - errors: [], - }), - }), - ], - 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(); + epgBridge = createEpgBridgeStub(); + configureSettingsComponentTestBed(epgBridge); })); beforeEach(() => { - window.electron = { - 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; + window.electron = createElectronStub(); fixture = TestBed.createComponent(SettingsComponent); - electronService = TestBed.inject(DataService); - settingsStore = TestBed.inject(SettingsStore); router = TestBed.inject(Router); - translate = TestBed.inject(TranslateService); - dialogService = TestBed.inject(DialogService); - playlistsService = TestBed.inject(PlaylistsService); - playlistBackupService = TestBed.inject(PlaylistBackupService); - store = TestBed.inject(Store); mockStore = TestBed.inject(MockStore); - databaseService = TestBed.inject(DatabaseService); - snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; component = fixture.componentInstance; stubSettingsSideEffects(component); @@ -394,165 +60,46 @@ describe('SettingsComponent', () => { fixture.detectChanges(); } - function privateApi( - settingsComponent: SettingsComponent - ): SettingsComponentPrivateTestApi { - return settingsComponent as unknown as SettingsComponentPrivateTestApi; - } - - /** Exposes the app update facade's retry internals to the tests */ - function appUpdateFacade(): SettingsAppUpdateFacade & - SettingsAppUpdateFacadePrivateTestApi { - return component.appUpdate as SettingsAppUpdateFacade & - SettingsAppUpdateFacadePrivateTestApi; - } - it('should create and init component', () => { expect(component).toBeTruthy(); }); - 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, - }; + /** + * The facades own the behaviour, but only the component knows when to + * start and stop them — so the seam itself needs coverage here. + */ + it('drives the facade lifecycle from the page lifecycle', async () => { + fixture.destroy(); - await fixture.whenStable(); - 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 + const lifecycleFixture = TestBed.createComponent(SettingsComponent); + const lifecycleComponent = lifecycleFixture.componentInstance; + stubSettingsSideEffects(lifecycleComponent); + const appUpdateInit = jest.spyOn(lifecycleComponent.appUpdate, 'init'); + const appUpdateDispose = jest.spyOn( + lifecycleComponent.appUpdate, + 'dispose' ); - expect(component.appUpdate.status()).toEqual(pushedStatus); - }); - - 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, - }; - await fixture.whenStable(); - (window.electron.getAppUpdateStatus as jest.Mock) - .mockReset() - .mockRejectedValueOnce(new Error('No handler registered')) - .mockResolvedValueOnce(retriedStatus); - jest.spyOn(appUpdateFacade(), 'waitForRetry').mockResolvedValue( - undefined + const embeddedMpvLoad = jest.spyOn( + lifecycleComponent.embeddedMpv, + 'load' ); - await appUpdateFacade().loadStatus(); + lifecycleFixture.detectChanges(); + await lifecycleFixture.whenStable(); - expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(2); - expect(component.appUpdate.status()).toEqual(retriedStatus); - }); + expect(appUpdateInit).toHaveBeenCalledTimes(1); + expect(lifecycleComponent.appUpdate.checkAppVersion).toHaveBeenCalled(); + expect(embeddedMpvLoad).toHaveBeenCalled(); + expect( + lifecycleComponent.remoteControl.fetchLocalIpAddresses + ).toHaveBeenCalled(); + // The lifecycle really reaches the desktop bridge, not just the facade + expect(window.electron.getAppUpdateStatus).toHaveBeenCalled(); + expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalled(); - 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, - }; - await fixture.whenStable(); - delete (window.electron as Partial) - .getAppUpdateStatus; - let retryCount = 0; - const getStatus = jest.fn().mockResolvedValue(retriedStatus); - jest.spyOn(appUpdateFacade(), 'waitForRetry').mockImplementation( - async () => { - retryCount += 1; + lifecycleFixture.destroy(); - if (retryCount === 1) { - window.electron.getAppUpdateStatus = getStatus; - } - } - ); - - await appUpdateFacade().loadStatus(); - - expect(retryCount).toBe(1); - expect(getStatus).toHaveBeenCalledTimes(1); - expect(component.appUpdate.status()).toEqual(retriedStatus); - }); - - it('forwards app update actions to the desktop bridge', async () => { - await component.appUpdate.checkForAppUpdate(); - await component.appUpdate.downloadAppUpdate(); - await component.appUpdate.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); - component.appUpdate.status.set({ - ...DEFAULT_APP_UPDATE_STATUS, - status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, - supportedSelfUpdate: false, - }); - - component.appUpdate.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(privateApi(component).matDialog, 'open') - .mockReturnValue(createDialogRef(false)); - component.appUpdate.status.set({ - ...DEFAULT_APP_UPDATE_STATUS, - latestVersion: '0.23.0', - status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, - }); - - component.appUpdate.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(privateApi(component).matDialog, 'open') - .mockReturnValue(createDialogRef(false)); - component.appUpdate.status.set({ - ...DEFAULT_APP_UPDATE_STATUS, - latestVersion: '0.21.0', - release: { - version: '0.21.0', - }, - status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, - }); - - component.appUpdate.openReleaseNotes(); - - expect(openSpy).toHaveBeenCalledWith( - AppUpdateReleaseNotesDialogComponent, - expect.objectContaining({ - data: { - initialVersion: DEFAULT_APP_UPDATE_STATUS.currentVersion, - fallbackToLatest: true, - }, - }) - ); + expect(appUpdateDispose).toHaveBeenCalledTimes(1); }); it('should render a compact page header outside dialog mode', () => { @@ -583,116 +130,30 @@ describe('SettingsComponent', () => { ).not.toBeNull(); }); - it('should scroll the selected navigation target within the workspace viewport', async () => { - fixture.destroy(); - jest.useFakeTimers(); - - try { - const scrollFixture = TestBed.createComponent(SettingsComponent); - const scrollComponent = scrollFixture.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(scrollComponent); - scrollFixture.detectChanges(); - const scrollDirective = scrollFixture.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 scrollFixture.whenStable(); - scrollFixture.detectChanges(); - settingsContext.navigateToSection('about'); - scrollFixture.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(); - } + it('should navigate back to home page', () => { + jest.spyOn(router, 'navigateByUrl'); + component.backToHome(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); }); - describe('Get and set settings on component init', () => { - const settings = { - language: Language.GERMAN, - player: VideoPlayer.Html5Player, - theme: Theme.DarkTheme, - }; + it('enables the global wipe action only once a playlist exists', () => { + const deleteButton = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.danger-zone__button' + ) as HTMLButtonElement | null; - it('should init default settings if previous config was not saved', async () => { - await component.ngOnInit(); - //expect(settingsStore.loadSettings).toHaveBeenCalled(); - expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); - }); + setPlaylists([]); - it('should get and apply custom settings', async () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore._setSettings({ - ...DEFAULT_SETTINGS, - ...settings, - }); + expect(component.playlistReset.canRemoveAll()).toBe(false); + expect(deleteButton()?.disabled).toBe(true); - component.form.hydrateFromStore(); + setPlaylists([createPlaylistMeta({ _id: 'm3u-1' })]); - //expect(settingsStore.loadSettings).toHaveBeenCalled(); - expect(component.settingsForm.value).toEqual({ - ...DEFAULT_SETTINGS, - ...settings, - }); - }); - - it('hydrates the shared web controls setting from the settings store', () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore._setSettings({ - webPlayerSharedControls: true, - }); - - component.form.hydrateFromStore(); - - expect( - component.settingsForm.get('webPlayerSharedControls')?.value - ).toBe(true); - }); + expect(component.playlistReset.canRemoveAll()).toBe(true); + expect(deleteButton()?.disabled).toBe(false); + }); + describe('Runtime capabilities', () => { it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => { window.electron = { ...window.electron, @@ -735,7 +196,47 @@ describe('SettingsComponent', () => { } ); - it('hides external player path settings when the Electron bridge is incomplete', async () => { + it('does not block settings initialization while embedded mpv support is pending', async () => { + let resolveSupport: + ((value: EmbeddedMpvSupport) => void) | undefined; + window.electron = { + ...window.electron, + getEmbeddedMpvSupport: jest.fn( + () => + new Promise((resolve) => { + resolveSupport = resolve; + }) + ), + } as unknown as typeof window.electron; + + await expect(component.ngOnInit()).resolves.toBeUndefined(); + + expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); + expect(window.electron.getEmbeddedMpvSupport).toHaveBeenCalled(); + expect( + component + .players() + .some((player) => player.id === VideoPlayer.EmbeddedMpv) + ).toBe(false); + + if (!resolveSupport) { + throw new Error('Expected embedded MPV support probe to start'); + } + + resolveSupport({ + supported: true, + platform: 'darwin', + }); + await fixture.whenStable(); + + expect( + component + .players() + .some((player) => player.id === VideoPlayer.EmbeddedMpv) + ).toBe(true); + }); + + it('hides external player path settings when the Electron bridge is incomplete', () => { fixture.destroy(); epgBridge.supportsImport = false; window.electron = { @@ -820,697 +321,20 @@ describe('SettingsComponent', () => { ).toBe(true); }); - it('does not block settings initialization while embedded mpv support is pending', async () => { - let resolveSupport: - | ((value: EmbeddedMpvSupport) => void) - | undefined; + it('opens the playlist folder picker only when the desktop bridge offers one', async () => { + const selectFolder = jest.fn().mockResolvedValue('/tmp/recordings'); window.electron = { ...window.electron, - getEmbeddedMpvSupport: jest.fn( - () => - new Promise((resolve) => { - resolveSupport = resolve; - }) - ), + selectEmbeddedMpvRecordingFolder: selectFolder, } as unknown as typeof window.electron; - await expect(component.ngOnInit()).resolves.toBeUndefined(); + await component.selectRecordingFolder(); - expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); - expect(window.electron.getEmbeddedMpvSupport).toHaveBeenCalled(); - expect( - component - .players() - .some((player) => player.id === VideoPlayer.EmbeddedMpv) - ).toBe(false); - - if (!resolveSupport) { - throw new Error('Expected embedded MPV support probe to start'); - } - - resolveSupport({ - supported: true, - platform: 'darwin', - }); - await fixture.whenStable(); - - expect( - component - .players() - .some((player) => player.id === VideoPlayer.EmbeddedMpv) - ).toBe(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) + expect(selectFolder).toHaveBeenCalled(); + expect(component.settingsForm.value.recordingFolder).toBe( + '/tmp/recordings' ); - - // Add translation mock - 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; - }); + expect(component.settingsForm.dirty).toBe(true); }); - - it('should return true if version is outdated', () => { - jest.spyOn(electronService, 'getAppVersion').mockReturnValue( - currentVersion - ); - const isOutdated = - component.appUpdate.isCurrentVersionOutdated(latestVersion); - expect(isOutdated).toBeTruthy(); - }); - - it('should update notification message if version is outdated', () => { - jest.spyOn(translate, 'instant'); - jest.spyOn(electronService, 'getAppVersion').mockReturnValue( - currentVersion - ); - component.appUpdate.showVersionInformation(latestVersion); - expect(translate.instant).toHaveBeenCalledWith( - 'SETTINGS.NEW_VERSION_AVAILABLE' - ); - expect(component.appUpdate.updateMessage()).toBe( - 'New version available: 1.0.0' - ); - }); - }); - - it('disables the global wipe action when there are no playlists', () => { - setPlaylists([]); - - const deleteButton = ( - fixture.nativeElement as HTMLElement - ).querySelector('.danger-zone__button') as HTMLButtonElement | null; - - expect(component.playlistReset.canRemoveAll()).toBe(false); - expect(deleteButton?.disabled).toBe(true); - }); - - 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(privateApi(component).matDialog, 'open') - .mockReturnValue(createDialogRef(false)); - - component.removeAll(); - - expect(component.playlistReset.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'); - (databaseService.deleteAllPlaylists as jest.Mock).mockClear(); - jest.spyOn(privateApi(component).matDialog, 'open').mockReturnValue( - createDialogRef(true) - ); - jest.spyOn(translate, 'instant').mockImplementation( - (key: string, params?: Record) => { - if (key === 'SETTINGS.REMOVE_ALL_PROGRESS') { - return `${params?.current}/${params?.total}`; - } - return key; - } - ); - jest.spyOn( - privateApi(component), - 'waitForUiFeedbackFrame' - ).mockResolvedValue(undefined); - - 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((resolve) => { - resolveDelete = resolve; - }); - } - ); - - component.removeAll(); - await Promise.resolve(); - fixture.detectChanges(); - - expect(component.playlistReset.isRemovingAllPlaylists()).toBe(true); - expect(component.playlistReset.removeAllProgressLabel()).toBe('3/7'); - expect(databaseService.deleteAllPlaylists).toHaveBeenCalledWith( - expect.objectContaining({ - operationId: 'delete-all-op', - onEvent: expect.any(Function), - }) - ); - - resolveDelete(true); - await fixture.whenStable(); - - expect(component.playlistReset.isRemovingAllPlaylists()).toBe(false); - expect(component.playlistReset.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 () => { - fixture.destroy(); - window.electron = undefined as unknown as typeof window.electron; - epgBridge.supportsImport = false; - - const browserFixture = TestBed.createComponent(SettingsComponent); - const browserComponent = browserFixture.componentInstance; - stubSettingsSideEffects(browserComponent); - browserFixture.detectChanges(); - - expect(browserComponent.isDesktop).toBe(false); - expect(browserComponent.isPwa).toBe(true); - expect(browserComponent.supportsEpg).toBe(false); - expect(browserComponent.supportsRemoteControl).toBe(false); - expect(browserComponent.settingsForm.get('epgUrl')).toBeNull(); - - mockStore.overrideSelector(selectAllPlaylistsMeta, [ - createPlaylistMeta({ _id: 'browser-m3u' }), - ]); - mockStore.refreshState(); - browserFixture.detectChanges(); - - jest.spyOn( - privateApi(browserComponent).matDialog, - 'open' - ).mockReturnValue(createDialogRef(true)); - jest.spyOn( - privateApi(browserComponent), - 'waitForUiFeedbackFrame' - ).mockResolvedValue(undefined); - (databaseService.deleteAllPlaylists as jest.Mock).mockClear(); - (playlistsService.removeAll as jest.Mock).mockClear(); - (playlistsService.removeAll as jest.Mock).mockReturnValue( - of(undefined) - ); - const dispatchSpy = jest.spyOn(store, 'dispatch'); - - browserComponent.removeAll(); - await browserFixture.whenStable(); - - expect(playlistsService.removeAll).toHaveBeenCalled(); - expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled(); - expect(dispatchSpy).toHaveBeenCalledWith( - PlaylistActions.removeAllPlaylists() - ); - }); - - 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 force-fetch EPG for a single URL (bypassing freshness cache)', () => { - const url = 'http://epg-url-here/data.xml'; - component.epg.refresh(url); - expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, { - trustedPrivateNetworkEpgUrls: [], - trustedInsecureTlsHosts: [], - }); - }); - - 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); - (dialogService.openConfirmDialog as jest.Mock).mockImplementation( - ({ onConfirm }: { onConfirm: () => Promise }) => { - void onConfirm(); - } - ); - const refreshSpy = jest.spyOn(component.epg, 'refreshAll'); - jest.spyOn(translate, 'instant').mockImplementation((key) => key); - - component.epg.clear(); - - expect(component.epg.isClearing()).toBe(true); - expect(refreshSpy).not.toHaveBeenCalled(); - - resolveClear(); - await fixture.whenStable(); - - expect(component.epg.isClearing()).toBe(false); - expect(snackBar.open).toHaveBeenCalledWith( - 'SETTINGS.EPG_DATA_CLEARED', - undefined, - expect.objectContaining({ panelClass: ['settings-snackbar'] }) - ); - expect(refreshSpy).toHaveBeenCalled(); - }); - - it('shows an export busy state until the backup file has been written', async () => { - let resolveExport: (value: { - defaultFileName: string; - json: string; - manifest: { - kind: string; - version: number; - exportedAt: string; - includeSecrets: boolean; - playlists: never[]; - }; - }) => void = () => undefined; - - (playlistBackupService.exportBackup as jest.Mock).mockReturnValueOnce( - new Promise((resolve) => { - resolveExport = resolve; - }) - ); - - const exportPromise = component.exportData(); - - expect(component.backup.isExportingData()).toBe(true); - - resolveExport({ - defaultFileName: 'iptvnator-playlist-backup-2026-04-21.json', - json: '{}', - manifest: { - kind: 'iptvnator-playlist-backup', - version: 1, - exportedAt: '2026-04-21T00:00:00.000Z', - includeSecrets: true, - playlists: [], - }, - }); - - await exportPromise; - - expect(window.electron.saveFileDialog).toHaveBeenCalledWith( - 'iptvnator-playlist-backup-2026-04-21.json', - [ - { - extensions: ['json'], - name: 'JSON', - }, - ] - ); - expect(window.electron.writeFile).toHaveBeenCalledWith( - '/tmp/backup.json', - '{}' - ); - expect(component.backup.isExportingData()).toBe(false); - }); - - it('falls back to browser backup download when desktop file-save preload is incomplete', async () => { - fixture.destroy(); - const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json'); - window.electron = { - platform: 'linux', - saveFileDialog, - } as unknown as typeof window.electron; - - 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(); - let partialFileSaveFixture: ComponentFixture | null = - null; - - try { - partialFileSaveFixture = TestBed.createComponent(SettingsComponent); - const partialFileSaveComponent = - partialFileSaveFixture.componentInstance; - stubSettingsSideEffects(partialFileSaveComponent); - partialFileSaveFixture.detectChanges(); - - expect(partialFileSaveComponent.isDesktop).toBe(true); - expect(partialFileSaveComponent.supportsDesktopFileSave).toBe( - false - ); - - await partialFileSaveComponent.exportData(); - - expect(saveFileDialog).not.toHaveBeenCalled(); - expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); - expect(clickSpy).toHaveBeenCalled(); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup'); - } finally { - partialFileSaveFixture?.destroy(); - clickSpy.mockRestore(); - Object.defineProperty(window.URL, 'createObjectURL', { - configurable: true, - value: originalCreateObjectURL, - }); - Object.defineProperty(window.URL, 'revokeObjectURL', { - configurable: true, - value: originalRevokeObjectURL, - }); - } - }); - - it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => { - (epgBridge.clearEpgData as jest.Mock).mockRejectedValueOnce( - new Error('boom') - ); - (dialogService.openConfirmDialog as jest.Mock).mockImplementation( - ({ onConfirm }: { onConfirm: () => Promise }) => { - void onConfirm(); - } - ); - const refreshSpy = jest.spyOn(component.epg, 'refreshAll'); - jest.spyOn(translate, 'instant').mockImplementation((key) => key); - jest.spyOn(console, 'error').mockImplementation(); - - component.epg.clear(); - await fixture.whenStable(); - - expect(component.epg.isClearing()).toBe(false); - expect(snackBar.open).toHaveBeenCalledWith( - 'SETTINGS.EPG_DATA_CLEAR_FAILED', - undefined, - expect.objectContaining({ panelClass: ['settings-snackbar'] }) - ); - expect(refreshSpy).not.toHaveBeenCalled(); - }); - - it('should navigate back to home page', () => { - jest.spyOn(router, 'navigateByUrl'); - component.backToHome(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/'); - }); - - 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 mockStore = settingsStore as unknown as MockSettingsStore; - 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(mockStore.updateSettings).toHaveBeenCalledWith({ - coverSize: 'large', - }); - }); - - it('updates the EPG view mode through the epg section output', () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - 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(mockStore.updateSettings).toHaveBeenCalledWith({ - epgViewMode: 'list', - }); - }); - - 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 - ); - }); - - it('should save settings on submit', async () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore.updateSettings.mockResolvedValue(undefined); - const updateSettings = jest.spyOn(window.electron, 'updateSettings'); - - component.onSubmit(); - await fixture.whenStable(); - - expect(mockStore.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 () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore.updateSettings.mockResolvedValue(undefined); - component.settingsForm.get('webPlayerSharedControls')?.setValue(true); - - component.onSubmit(); - await fixture.whenStable(); - - expect(mockStore.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ - webPlayerSharedControls: true, - }) - ); - }); - - it('clears external player paths in Electron when saved as empty', async () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore.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 () => { - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore.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(mockStore.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; - - const mockStore = settingsStore as unknown as MockSettingsStore; - mockStore._setSettings({ - epgUrl: ['https://example.com/guide.xml'], - preferUploadedEpgOverXtream: true, - }); - mockStore.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(mockStore.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ - epgUrl: ['https://example.com/guide.xml'], - preferUploadedEpgOverXtream: true, - theme: Theme.DarkTheme, - }) - ); - - webFixture.destroy(); }); }); diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts new file mode 100644 index 000000000..2a4013874 --- /dev/null +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -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) { + 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 { + 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 { + 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 { + return { + afterClosed: () => of(result), + } as unknown as ReturnType; +} + +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 +) { + 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 +): 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); +} diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index f9eabcf59..8ac72bd1d 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',