Files
iptvnator/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts
T
4grayandClaude Fable 5.1 e6ef8cdd54 perf(web): take Angular Material off the startup path (#1931)
Takes Angular Material off the renderer's startup path: renderer.initialBytes
1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with
master, where #1928 lands the form-field defaults per component, the branch
measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler
keeps each module in one chunk, so one eager import of any export carries the
module's whole code; each item removes the last eager importer of one Material
module.

- EPG progress and update panels: eager host + `@defer (when visible)` view
  with an `@error` notice; the startup spinner is static markup copied from
  mat-spinner.
- LazyMatDialog: the confirm dialog service, player service, parental lock
  prompt and shell actions resolve MatDialog on first use.
- The theme emits tokens for the 21 imported components only, through a
  `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs
  (CI) keeps it in step with the imports in both directions.
- No provideAnimations(): Material 22 animates with CSS and reads
  prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts
  proves dialogs, selects, menus, snack-bars and tooltips animate in and out and
  stop under reduced motion. Its renderer had deferred every DOM removal to the
  end of the tick, so the catalog list now tracks the grid offset from scroll
  events and the player settings panel decides "focus was inside" in a
  microtask, both with specs that detach the view first.
- The snack-bar stays, and with it button, the icon-button chunk and the CDK
  overlay (about 190 KB): root services inject it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 00:08:58 +02:00

534 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
COMPACT_LAYOUT_MAX_WIDTH,
ROOMY_LAYOUT_MIN_WIDTH,
} from './controls-layout';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
/**
* The settings panel behind the `tune` button: chips on wide players, the
* panel beside the video, the compact bottom sheet, and the dock changes
* that come with each.
*/
describe('PlayerControlsComponent settings panel', () => {
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallbacks: ResizeCallback[];
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const queryAll = (selector: string) =>
Array.from(
fixture.nativeElement.querySelectorAll(
selector
) as NodeListOf<HTMLElement>
);
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const resizeTo = (width: number) => {
for (const callback of resizeCallbacks) {
callback([
{
borderBoxSize: [{ inlineSize: width, blockSize: 300 }],
contentRect: { width } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry,
]);
}
fixture.detectChanges();
};
const withEverything = () => {
setCapabilities({
volume: true,
audioTracks: true,
subtitles: true,
externalSubtitles: true,
playbackSpeed: true,
aspectRatio: true,
pictureInPicture: true,
fullscreen: true,
});
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
canPictureInPicture: true,
});
fixture.detectChanges();
};
beforeEach(async () => {
resizeCallbacks = [];
class FakeResizeObserver {
constructor(callback: ResizeCallback) {
resizeCallbacks.push(callback);
}
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
}
globalThis.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
SETTINGS: 'Settings',
CLOSE_SETTINGS: 'Close settings',
SUBTITLES: 'Subtitles',
SUBTITLES_OFF: 'Off',
PLAYBACK_SPEED: 'Playback speed',
AUDIO_TRACKS: 'Audio tracks',
ASPECT_RATIO: 'Aspect ratio',
ASPECT_DEFAULT: 'Default',
ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture',
ENTER_FULLSCREEN: 'Enter fullscreen',
},
},
});
translate.use('en');
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
globalThis.ResizeObserver = originalResizeObserver;
});
describe('wide dock', () => {
beforeEach(withEverything);
it('shows subtitle and speed chips with their values and state colors', () => {
const subtitles = query(
'[data-test-id="player-controls-subtitle-chip"]'
);
const speed = query('[data-test-id="player-controls-speed-chip"]');
expect(subtitles?.textContent).toContain('Off');
expect(subtitles?.classList).not.toContain(
'player-controls__chip--on'
);
expect(speed?.textContent).toContain('1×');
expect(speed?.classList).not.toContain(
'player-controls__chip--modified'
);
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
playbackSpeed: 1.25,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
?.textContent
).toContain('Russian');
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
?.classList
).toContain('player-controls__chip--on');
expect(
query('[data-test-id="player-controls-speed-chip"]')
?.textContent
).toContain('1.25×');
expect(
query('[data-test-id="player-controls-speed-chip"]')?.classList
).toContain('player-controls__chip--modified');
});
it('opens the panel on the chip group, shifts the dock and folds the chips away', () => {
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
query('[data-test-id="player-controls-speed-chip"]')?.click();
fixture.detectChanges();
const panel = query(
'[data-test-id="player-controls-settings-panel"]'
);
expect(panel).not.toBeNull();
expect(panel?.classList).not.toContain(
'player-controls__settings--sheet'
);
expect(panel?.getAttribute('role')).toBe('dialog');
const title = panel?.querySelector('h2');
expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id);
expect(title?.textContent?.trim()).toBe('Settings');
expect(
query('[data-test-id="player-settings-speed"]')?.classList
).toContain('player-settings__group--focused');
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--panel-open'
);
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(query('[aria-label="Enter picture-in-picture"]')).toBeNull();
expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
?.classList
).toContain('player-controls__tune--active');
});
it('renders every available group in order and applies choices in place', () => {
component.settings.open();
fixture.detectChanges();
expect(
queryAll('.player-settings__group').map((group) =>
group.getAttribute('data-group')
)
).toEqual(['audio', 'subtitles', 'speed', 'aspect']);
queryAll(
'[data-test-id="player-settings-audio"] .player-settings__option'
)[1]?.click();
expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2);
const preset = queryAll(
'[data-test-id="player-settings-aspect"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('16:9'));
preset?.click();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.settings.isOpen()).toBe(true);
});
it('closes from the panel button and from the tune button', () => {
component.settings.open();
fixture.detectChanges();
query('[data-test-id="player-settings-close"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--panel-open'
);
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).not.toBeNull();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
});
it('toggles subtitles from the chip context menu without opening the panel', () => {
const chip = query(
'[data-test-id="player-controls-subtitle-chip"]'
) as HTMLElement;
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
chip.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(5);
expect(component.settings.isOpen()).toBe(false);
});
it('closes the panel once no group is left', () => {
component.settings.open('audio');
fixture.detectChanges();
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
expect(component.settings.isOpen()).toBe(false);
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
});
});
describe('wide dock without room for extras', () => {
beforeEach(() => {
withEverything();
resizeTo(ROOMY_LAYOUT_MIN_WIDTH - 1);
});
it('stays wide but folds the chips into tune and opens a sheet', () => {
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).not.toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-dots"]')
).not.toBeNull();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
?.classList
).toContain('player-controls__settings--sheet');
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--sheet-open'
);
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--panel-open'
);
});
});
describe('compact dock', () => {
beforeEach(() => {
withEverything();
resizeTo(COMPACT_LAYOUT_MAX_WIDTH);
});
it('folds the chips into the tune button with state dots', () => {
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-settings-dots"]')
).toBeNull();
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
playbackSpeed: 2,
});
fixture.detectChanges();
const dots = query(
'[data-test-id="player-controls-settings-dots"]'
);
expect(
dots?.querySelector('.player-controls__tune-dot--cyan')
).not.toBeNull();
expect(
dots?.querySelector('.player-controls__tune-dot--violet')
).not.toBeNull();
});
it('makes the hidden dock inert and moves keyboard focus into the sheet and back', async () => {
const tune = query(
'[data-test-id="player-controls-settings-button"]'
) as HTMLButtonElement;
document.body.appendChild(fixture.nativeElement);
tune.focus();
// jsdom has no keyboard modality: report the focused tune button
// as :focus-visible, as a keyboard focus would be.
const matches = tune.matches.bind(tune);
Object.defineProperty(tune, 'matches', {
configurable: true,
value: (selector: string) =>
selector === ':focus-visible' || matches(selector),
});
tune.click();
fixture.detectChanges();
await fixture.whenStable();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
) as HTMLElement;
expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe(
true
);
expect(document.activeElement).toBe(sheet);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe(
false
);
expect(document.activeElement).toBe(
query('[data-test-id="player-controls-settings-button"]')
);
fixture.nativeElement.remove();
});
it('returns focus to tune although removal fires focusout with no related target', async () => {
const tune = query(
'[data-test-id="player-controls-settings-button"]'
) as HTMLButtonElement;
document.body.appendChild(fixture.nativeElement);
tune.focus();
const matches = tune.matches.bind(tune);
Object.defineProperty(tune, 'matches', {
configurable: true,
value: (selector: string) =>
selector === ':focus-visible' || matches(selector),
});
tune.click();
fixture.detectChanges();
await fixture.whenStable();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
) as HTMLElement;
expect(document.activeElement).toBe(sheet);
// Chromium fires focusout with relatedTarget null on the focused
// element while its subtree is being removed, in the same task
// as the removal; jsdom does not, so the event is dispatched by
// hand right before the close destroys the panel.
sheet.dispatchEvent(
new FocusEvent('focusout', {
bubbles: true,
relatedTarget: null,
})
);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(document.activeElement).toBe(
query('[data-test-id="player-controls-settings-button"]')
);
fixture.nativeElement.remove();
});
it('leaves focus alone when a pointer opens the sheet', async () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).not.toBe(
query('[data-test-id="player-controls-settings-panel"]')
);
});
it('opens a bottom sheet that replaces the dock and keeps PiP reachable after close', () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
);
expect(sheet?.classList).toContain(
'player-controls__settings--sheet'
);
expect(
sheet?.querySelector('.player-settings__grip')
).not.toBeNull();
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--sheet-open'
);
expect(
query('[data-test-id="player-controls-settings-dots"]')
).toBeNull();
query('[data-test-id="player-settings-close"]')?.click();
fixture.detectChanges();
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--sheet-open'
);
expect(
query('[aria-label="Enter picture-in-picture"]')
).not.toBeNull();
});
});
});