Files
iptvnator/apps/web/src/app/settings/settings.component.spec.ts
T
4grayandClaude Fable 5.1 8030ced95a feat(settings): grouped navigation, cards and switches from the settings concept (#1853)
* feat(settings): grouped navigation, cards and switches from the settings concept

Implements the Claude Design "Settings concept" handoff.

Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices
and Data groups, with About pinned to the footer beside the installed version
and an update badge. The active item is a soft fill without a border. Esc
leaves settings like the header Back. Reset is no longer a page: its one
destructive action is the last card of "Backup & data".

Pages: a title and one-line subtitle, then rows grouped into titled cards
with one right-aligned control column. Selects are compact without floating
labels, checkboxes are switches, segmented controls are pills, and
descriptions are capped at 56ch. "Show subtitles" moves to Playback, the
Embedded MPV note becomes a callout under the player select (only while it
is selected), and the TMDB attribution becomes the About footer.

EPG: Add and Refresh all sit in the Sources header, the empty state has its
own Add, the format examples are one line, Clear EPG data is its own row and
the offset is a stepper. About: version, update state and channel share one
card; the nightly warning is a callout shown only while Nightly is selected,
with a shortcut to the backup page; the support buttons are neutral with
coloured icons.

Save model: the save bar stays (design option B) and now floats over the
content column, counts the staged changes, answers Cmd/Ctrl+S, and marks
pages with staged edits in the sidebar. A saved change that waits for a
restart shows a dismissible notice.

Rows stack under 600px of pane width (the page is a size container, so the
persistent sidebar is accounted for). Page-specific styles moved into the
About, EPG, Backup and Remote control section stylesheets to stay within the
component style budget. New SETTINGS keys are translated in all 19 locales
and NAV_RESET is removed; brand names and loanwords that stay English are in
the identical-value baseline.

E2E: Material slide toggles report aria-checked after the next render, so
both suites toggle through a setSwitch helper; the settings nav is a
navigation landmark, so the Dashboard rail link is scoped to the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): compare restart notices against the running app, fix CI fallout

The restart notice now lists only the restart controls whose saved value
differs from what the running app uses: the stored values at first load,
or what the embedded engine reports it actually runs for the frame-copy
opt-in. Saving the launch value back withdraws the notice instead of
leaving it up with no chip to explain it. The refresh reads the current
list untracked and writes only a changed one, because the engine probe
effect calls it.

CI: the zoneless checklist dropped the deleted Reset section component, and
the theme-tokens Electron E2E floats the recording folder label on the
Playback page now that the settings selects carry no floating label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep a dismissed restart reminder away until the setting changes

"Later" now records the saved value it dismissed, for the rest of the app
run, so an unrelated save (or reopening Settings) does not bring the same
reminder back. It returns once a restart setting is saved with another
value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 20:39:07 +02:00

446 lines
16 KiB
TypeScript

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
EmbeddedMpvSupport,
PlaylistMeta,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { MockStore } from '@ngrx/store/testing';
import { SettingsComponent } from './settings.component';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
createPlaylistMeta,
DEFAULT_SETTINGS,
MockRouter,
setSettingsSection,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
/**
* Page-shell behaviour: chrome, the `:section` page routing, 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`, and the per-section behaviour in the
* matching `*.facade.spec.ts` files.
*/
/** Writable double for the bridge's read-only capability getters. */
type EpgBridgeStub = {
-readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K];
};
describe('SettingsComponent', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let router: Router;
let mockStore: MockStore;
let epgBridge: EpgBridgeStub;
const originalElectron = window.electron;
beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub();
configureSettingsComponentTestBed(epgBridge);
}));
beforeEach(() => {
window.electron = createElectronStub();
fixture = TestBed.createComponent(SettingsComponent);
router = TestBed.inject(Router);
mockStore = TestBed.inject(MockStore);
component = fixture.componentInstance;
stubSettingsSideEffects(component);
fixture.detectChanges();
});
afterEach(() => {
window.electron = originalElectron;
});
function setPlaylists(playlists: PlaylistMeta[]): void {
mockStore.overrideSelector(selectAllPlaylistsMeta, playlists);
mockStore.refreshState();
fixture.detectChanges();
}
it('should create and init component', () => {
expect(component).toBeTruthy();
});
/**
* 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();
const lifecycleFixture = TestBed.createComponent(SettingsComponent);
const lifecycleComponent = lifecycleFixture.componentInstance;
stubSettingsSideEffects(lifecycleComponent);
const loadSettings = jest.spyOn(
lifecycleComponent.form,
'loadSettings'
);
const appUpdateInit = jest.spyOn(lifecycleComponent.appUpdate, 'init');
const appUpdateDispose = jest.spyOn(
lifecycleComponent.appUpdate,
'dispose'
);
const embeddedMpvLoad = jest.spyOn(
lifecycleComponent.embeddedMpv,
'load'
);
lifecycleFixture.detectChanges();
expect(loadSettings).toHaveBeenCalledTimes(1);
// Let ngOnInit resume after its native promise, which Zone cannot track.
await loadSettings.mock.results[0].value;
await lifecycleFixture.whenStable();
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();
lifecycleFixture.destroy();
expect(appUpdateDispose).toHaveBeenCalledTimes(1);
});
it('should render the hidden page header hook', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector('[data-test-id="settings-page-header"]')
).not.toBeNull();
expect(nativeElement.querySelector('.settings-intro')).toBeNull();
});
describe('Section pages', () => {
it('renders only the section named by the route param', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector('app-settings-general-section')
).not.toBeNull();
expect(
nativeElement.querySelector('app-settings-playback-section')
).toBeNull();
setSettingsSection('playback');
fixture.detectChanges();
expect(
nativeElement.querySelector('app-settings-general-section')
).toBeNull();
expect(
nativeElement.querySelector('app-settings-playback-section')
).not.toBeNull();
});
it('falls back to the general page and rewrites unknown section URLs', () => {
const navigate = (router as unknown as MockRouter).navigate;
setSettingsSection('nonsense');
fixture.detectChanges();
expect(component.activeSection()).toBe('general');
expect(navigate).toHaveBeenCalledWith(
['/workspace/settings', 'general'],
{ replaceUrl: true }
);
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'app-settings-general-section'
)
).not.toBeNull();
});
});
describe('Leaving with unsaved changes', () => {
const answerDialogWith = (
choice: 'save' | 'discard' | undefined
): jest.Mock => {
const open = TestBed.inject(MatDialog).open as jest.Mock;
open.mockReturnValue({ afterClosed: () => of(choice) });
return open;
};
it('lets a pristine form leave without asking', async () => {
const open = answerDialogWith(undefined);
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(true);
expect(open).not.toHaveBeenCalled();
});
it('stays when the dialog is dismissed', async () => {
component.settingsForm.markAsDirty();
answerDialogWith(undefined);
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(false);
expect(component.settingsForm.dirty).toBe(true);
});
it('discard-and-leave reverts the staged edits', async () => {
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
answerDialogWith('discard');
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(true);
expect(component.settingsForm.pristine).toBe(true);
});
it('save-and-leave persists before allowing the navigation', async () => {
component.settingsForm.markAsDirty();
jest.spyOn(
component.epg,
'fetchConfiguredEpg'
).mockImplementation();
answerDialogWith('save');
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(true);
expect(component.settingsForm.pristine).toBe(true);
});
it('offers save-and-leave only while the form is valid', async () => {
component.settingsForm.markAsDirty();
component.settingsForm.setErrors({ invalid: true });
const open = answerDialogWith(undefined);
await component.confirmLeaveWithUnsavedChanges();
expect(open).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
data: { canSave: false },
})
);
});
});
it('enables the global wipe action only once a playlist exists', () => {
setSettingsSection('backup');
fixture.detectChanges();
const deleteButton = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'.danger-zone__button'
) as HTMLButtonElement | null;
setPlaylists([]);
expect(component.playlistReset.canRemoveAll()).toBe(false);
expect(deleteButton()?.disabled).toBe(true);
setPlaylists([createPlaylistMeta({ _id: 'm3u-1' })]);
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,
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
supported: false,
platform: 'darwin',
reason: 'hidden harness',
}),
} as unknown as typeof window.electron;
await component.ngOnInit();
await fixture.whenStable();
expect(
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
});
it.each(['darwin', 'win32', 'linux'] as const)(
'shows the embedded mpv option when the %s desktop support probe reports supported',
async (platform) => {
window.electron = {
...window.electron,
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
supported: true,
platform,
}),
} as unknown as typeof window.electron;
await component.ngOnInit();
await fixture.whenStable();
expect(
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(true);
}
);
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 = {
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
platform: 'linux',
updateSettings: jest.fn().mockResolvedValue(undefined),
} as unknown as typeof window.electron;
const partialBridgeFixture =
TestBed.createComponent(SettingsComponent);
const partialBridgeComponent =
partialBridgeFixture.componentInstance;
stubSettingsSideEffects(partialBridgeComponent);
partialBridgeFixture.detectChanges();
expect(partialBridgeComponent.isDesktop).toBe(true);
expect(partialBridgeComponent.supportsManagedExternalPlayers).toBe(
false
);
expect(
partialBridgeComponent.supportsExternalPlayerPathSettings
).toBe(false);
expect(partialBridgeComponent.supportsEpg).toBe(false);
expect(partialBridgeComponent.supportsRemoteControl).toBe(false);
expect(
partialBridgeComponent.settingsForm.get('epgUrl')
).toBeNull();
expect(
partialBridgeComponent.sectionNav.find(
(section) => section.id === 'epg'
)
).toBeUndefined();
expect(
partialBridgeComponent.sectionNav.find(
(section) => section.id === 'remote-control'
)
).toBeUndefined();
expect(
partialBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.MPV)
).toBe(false);
expect(
partialBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.VLC)
).toBe(false);
});
it('keeps external player choices when launch support exists without path settings', () => {
fixture.destroy();
window.electron = {
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
updateSettings: jest.fn().mockResolvedValue(undefined),
} as unknown as typeof window.electron;
const launchOnlyBridgeFixture =
TestBed.createComponent(SettingsComponent);
const launchOnlyBridgeComponent =
launchOnlyBridgeFixture.componentInstance;
stubSettingsSideEffects(launchOnlyBridgeComponent);
launchOnlyBridgeFixture.detectChanges();
expect(
launchOnlyBridgeComponent.supportsManagedExternalPlayers
).toBe(true);
expect(
launchOnlyBridgeComponent.supportsExternalPlayerPathSettings
).toBe(false);
expect(
launchOnlyBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.MPV)
).toBe(true);
expect(
launchOnlyBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.VLC)
).toBe(true);
});
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,
selectEmbeddedMpvRecordingFolder: selectFolder,
} as unknown as typeof window.electron;
await component.selectRecordingFolder();
expect(selectFolder).toHaveBeenCalled();
expect(component.settingsForm.value.recordingFolder).toBe(
'/tmp/recordings'
);
expect(component.settingsForm.dirty).toBe(true);
});
});
});