Files
iptvnator/apps/web/src/app/settings/settings.component.form.spec.ts
T
4grayandClaude Opus 5 4779642e6e test(settings): move the storage-failure coverage to its own spec
Rebase follow-up. #1274 split the settings page into facades and #1277 split
its spec along the same seams, so this PR's renderer changes moved:

- the failed-read warning is now raised by `SettingsFormFacade.loadSettings()`,
  which owns the store call
- the cover-size and EPG-view-mode writes moved into the facade, so their
  rejection handling went with them
- `showStorageFailure()` became `SettingsSnackbarService.storageFailure()`,
  because the component and the facade both need it and neither can reach the
  other's copy
- `onSubmit()` still owns the dialog, so the catch and the "close only after
  the write succeeded" ordering stay in the component

The four regression tests live in `settings.component.storage-failure.spec.ts`
rather than being appended to `settings.component.form.spec.ts`, which would
have pushed that file to 427 lines, past the 400-line lint maximum. They assert
against the MatDialog the component actually calls — spying on the instance
from `TestBed.inject(MatDialog)` does not observe it — and one of them stubs
the post-save EPG fetch, since a throw there would otherwise land in the same
catch and look like a failed write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:32:56 +02:00

351 lines
12 KiB
TypeScript

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
Language,
StartupBehavior,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
DEFAULT_DASHBOARD_RAILS,
DEFAULT_SETTINGS,
MatSnackBarStub,
MockSettingsStore,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
/**
* Everything the rendered settings form does: hydration from the store, the
* section outputs that write straight through, and the submit path.
*/
describe('SettingsComponent form', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let settingsStore: MockSettingsStore;
let translate: TranslateService;
let snackBar: MatSnackBarStub;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub();
configureSettingsComponentTestBed(epgBridge);
}));
beforeEach(() => {
window.electron = createElectronStub();
fixture = TestBed.createComponent(SettingsComponent);
settingsStore = TestBed.inject(
SettingsStore
) as unknown as MockSettingsStore;
translate = TestBed.inject(TranslateService);
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
component = fixture.componentInstance;
stubSettingsSideEffects(component);
fixture.detectChanges();
});
afterEach(() => {
window.electron = originalElectron;
});
describe('Get and set settings on component init', () => {
const settings = {
language: Language.GERMAN,
player: VideoPlayer.Html5Player,
theme: Theme.DarkTheme,
};
it('should init default settings if previous config was not saved', async () => {
await component.ngOnInit();
expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS);
});
it('should get and apply custom settings', () => {
settingsStore._setSettings({
...DEFAULT_SETTINGS,
...settings,
});
component.form.hydrateFromStore();
expect(component.settingsForm.value).toEqual({
...DEFAULT_SETTINGS,
...settings,
});
});
it('hydrates the shared web controls setting from the settings store', () => {
settingsStore._setSettings({
webPlayerSharedControls: true,
});
component.form.hydrateFromStore();
expect(
component.settingsForm.get('webPlayerSharedControls')?.value
).toBe(true);
});
});
describe('Section outputs', () => {
it('updates the selected theme through the general section and marks the form dirty', () => {
const darkThemeButton = (
fixture.nativeElement as HTMLElement
).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement;
darkThemeButton.click();
fixture.detectChanges();
expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme);
expect(component.settingsForm.dirty).toBeTruthy();
});
it('updates cover size through the general section output', () => {
const largeCoverButton = (
fixture.nativeElement as HTMLElement
).querySelector(
'[data-test-id="cover-size-large"]'
) as HTMLButtonElement;
largeCoverButton.click();
fixture.detectChanges();
expect(component.settingsForm.value.coverSize).toBe('large');
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
coverSize: 'large',
});
});
it('updates the EPG view mode through the epg section output', () => {
const listButton = (
fixture.nativeElement as HTMLElement
).querySelector(
'[data-test-id="epg-view-mode-list"]'
) as HTMLButtonElement;
listButton.click();
fixture.detectChanges();
expect(component.settingsForm.value.epgViewMode).toBe('list');
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
epgViewMode: 'list',
});
});
});
describe('Dashboard controls', () => {
it('renders dashboard controls with the expected defaults', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector(
'[data-test-id="toggle-show-dashboard"]'
)
).not.toBeNull();
expect(
nativeElement.querySelector(
'[data-test-id="toggle-dashboard-hero"]'
)
).not.toBeNull();
expect(
nativeElement.querySelector(
'[data-test-id="toggle-dashboard-rail-live-favorites"]'
)
).not.toBeNull();
expect(
nativeElement.querySelector(
'[data-test-id="toggle-dashboard-rail-recently-watched-live"]'
)
).not.toBeNull();
expect(
nativeElement.querySelector(
'[data-test-id="toggle-dashboard-rail-favorite-movies-and-series"]'
)
).not.toBeNull();
expect(component.settingsForm.value.showDashboard).toBe(true);
expect(component.settingsForm.value.dashboardRails).toEqual(
DEFAULT_DASHBOARD_RAILS
);
expect(component.settingsForm.value.startupBehavior).toBe(
StartupBehavior.FirstView
);
});
it('disables dashboard surface controls when the dashboard is off', async () => {
const showDashboard = component.settingsForm.get('showDashboard');
const dashboardRails = component.settingsForm.get('dashboardRails');
showDashboard?.setValue(false);
await fixture.whenStable();
expect(dashboardRails?.disabled).toBe(true);
expect(
component.settingsForm.get('dashboardRails.hero')?.disabled
).toBe(true);
expect(
component.settingsForm.get('dashboardRails.continueWatching')
?.disabled
).toBe(true);
showDashboard?.setValue(true);
await fixture.whenStable();
expect(dashboardRails?.enabled).toBe(true);
expect(
component.settingsForm.get('dashboardRails.hero')?.enabled
).toBe(true);
});
});
describe('Saving', () => {
it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => {
jest.spyOn(translate, 'instant').mockReturnValue('Settings saved');
component.applyChangedSettings();
expect(snackBar.open).toHaveBeenCalledWith(
'Settings saved',
undefined,
{
duration: 2000,
horizontalPosition: 'center',
panelClass: ['settings-snackbar'],
verticalPosition: 'bottom',
}
);
});
it('should save settings on submit', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const updateSettings = jest.spyOn(
window.electron,
'updateSettings'
);
component.onSubmit();
await fixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
expect(updateSettings).toHaveBeenCalledWith({
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
});
it('saves the shared web controls setting on submit', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
component.settingsForm
.get('webPlayerSharedControls')
?.setValue(true);
component.onSubmit();
await fixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
webPlayerSharedControls: true,
})
);
});
it('clears external player paths in Electron when saved as empty', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const setMpvPlayerPath = jest.spyOn(
window.electron,
'setMpvPlayerPath'
);
const setVlcPlayerPath = jest.spyOn(
window.electron,
'setVlcPlayerPath'
);
component.settingsForm.patchValue({
mpvPlayerPath: '',
vlcPlayerPath: '',
});
component.onSubmit();
await fixture.whenStable();
expect(setMpvPlayerPath).toHaveBeenCalledWith('');
expect(setVlcPlayerPath).toHaveBeenCalledWith('');
});
it('saves external player command-line arguments with the settings payload', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const updateSettings = jest.spyOn(
window.electron,
'updateSettings'
);
component.settingsForm.patchValue({
mpvPlayerArguments: '--screen=1\n--geometry=1280x720',
vlcPlayerArguments: '--qt-fullscreen-screennumber=1',
});
component.onSubmit();
await fixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
mpvPlayerArguments: '--screen=1\n--geometry=1280x720',
vlcPlayerArguments: '--qt-fullscreen-screennumber=1',
})
);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
mpvPlayerArguments: '--screen=1\n--geometry=1280x720',
vlcPlayerArguments: '--qt-fullscreen-screennumber=1',
})
);
});
it('preserves saved EPG settings when saving from the web settings form', async () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
settingsStore._setSettings({
epgUrl: ['https://example.com/guide.xml'],
preferUploadedEpgOverXtream: true,
});
settingsStore.updateSettings.mockResolvedValue(undefined);
const webFixture = TestBed.createComponent(SettingsComponent);
const webComponent = webFixture.componentInstance;
stubSettingsSideEffects(webComponent);
webFixture.detectChanges();
await webFixture.whenStable();
webComponent.settingsForm.patchValue({ theme: Theme.DarkTheme });
webComponent.onSubmit();
await webFixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
epgUrl: ['https://example.com/guide.xml'],
preferUploadedEpgOverXtream: true,
theme: Theme.DarkTheme,
})
);
webFixture.destroy();
});
});
});