mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 10:56:43 -08:00
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>
351 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|