mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
[codex] Cover playback stack helpers (#1026)
* cover playback stack helpers * harden playback session controller specs --------- Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
1 parent
6a5400e11a
commit
0fa050f4c3
5 files changed
+712
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AudioPlayerComponent } from './audio-player.component';
|
||||
|
||||
describe('AudioPlayerComponent', () => {
|
||||
let fixture: ComponentFixture<AudioPlayerComponent>;
|
||||
let component: AudioPlayerComponent;
|
||||
let store: { dispatch: jest.Mock };
|
||||
let playSpy: jest.SpyInstance;
|
||||
let pauseSpy: jest.SpyInstance;
|
||||
let loadSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
store = { dispatch: jest.fn() };
|
||||
playSpy = jest
|
||||
.spyOn(HTMLMediaElement.prototype, 'play')
|
||||
.mockResolvedValue(undefined);
|
||||
pauseSpy = jest
|
||||
.spyOn(HTMLMediaElement.prototype, 'pause')
|
||||
.mockImplementation(() => undefined);
|
||||
loadSpy = jest
|
||||
.spyOn(HTMLMediaElement.prototype, 'load')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AudioPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [{ provide: Store, useValue: store }],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createComponent(url = 'https://example.com/radio.mp3') {
|
||||
fixture = TestBed.createComponent(AudioPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('url', url);
|
||||
fixture.componentRef.setInput('channelName', 'Example Radio');
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement.querySelector('audio') as HTMLAudioElement;
|
||||
}
|
||||
|
||||
it('restores the persisted volume and applies it when a stream loads', () => {
|
||||
localStorage.setItem('volume', '0.42');
|
||||
|
||||
const audio = createComponent();
|
||||
|
||||
expect(component.volume()).toBe(0.42);
|
||||
expect(audio.src).toBe('https://example.com/radio.mp3');
|
||||
expect(audio.volume).toBe(0.42);
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(playSpy).toHaveBeenCalled();
|
||||
expect(component.playState()).toBe('play');
|
||||
});
|
||||
|
||||
it('clamps volume updates and persists them to localStorage', () => {
|
||||
const audio = createComponent();
|
||||
|
||||
component.setVolume(1.5);
|
||||
expect(component.volume()).toBe(1);
|
||||
expect(audio.volume).toBe(1);
|
||||
expect(localStorage.getItem('volume')).toBe('1');
|
||||
|
||||
component.setVolume(-0.25);
|
||||
expect(component.volume()).toBe(0);
|
||||
expect(audio.volume).toBe(0);
|
||||
expect(localStorage.getItem('volume')).toBe('0');
|
||||
});
|
||||
|
||||
it('handles volume and mute keyboard shortcuts without focusing inputs', () => {
|
||||
createComponent();
|
||||
component.setVolume(0.5);
|
||||
|
||||
const upEvent = createKeyboardEvent('ArrowUp');
|
||||
component.handleKeyboard(upEvent);
|
||||
expect(upEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(component.volume()).toBe(0.55);
|
||||
|
||||
const downEvent = createKeyboardEvent('ArrowDown');
|
||||
component.handleKeyboard(downEvent);
|
||||
expect(downEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(component.volume()).toBe(0.5);
|
||||
|
||||
const muteEvent = createKeyboardEvent('M');
|
||||
component.handleKeyboard(muteEvent);
|
||||
expect(muteEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(component.isMuted()).toBe(true);
|
||||
expect(component.volume()).toBe(0);
|
||||
|
||||
const inputEvent = createKeyboardEvent('ArrowUp', {
|
||||
target: document.createElement('input'),
|
||||
});
|
||||
component.handleKeyboard(inputEvent);
|
||||
expect(inputEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(component.volume()).toBe(0);
|
||||
});
|
||||
|
||||
it('restores the previous volume when unmuting', () => {
|
||||
const audio = createComponent();
|
||||
component.setVolume(0.65);
|
||||
|
||||
component.mute();
|
||||
expect(audio.muted).toBe(true);
|
||||
expect(component.isMuted()).toBe(true);
|
||||
expect(component.volume()).toBe(0);
|
||||
|
||||
component.mute();
|
||||
expect(audio.muted).toBe(false);
|
||||
expect(component.isMuted()).toBe(false);
|
||||
expect(component.volume()).toBe(0.65);
|
||||
expect(localStorage.getItem('volume')).toBe('0.65');
|
||||
});
|
||||
|
||||
it('emits or dispatches adjacent channel switches based on input mode', () => {
|
||||
createComponent();
|
||||
const emitted: string[] = [];
|
||||
component.channelSwitchRequested.subscribe((direction) =>
|
||||
emitted.push(direction)
|
||||
);
|
||||
|
||||
component.switchChannel('next');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
direction: 'next',
|
||||
})
|
||||
);
|
||||
expect(emitted).toEqual([]);
|
||||
|
||||
fixture.componentRef.setInput('dispatchAdjacentChannelAction', false);
|
||||
fixture.detectChanges();
|
||||
component.switchChannel('previous');
|
||||
|
||||
expect(emitted).toEqual(['previous']);
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('pauses the stream when the component is destroyed', () => {
|
||||
createComponent();
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(pauseSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createKeyboardEvent(
|
||||
key: string,
|
||||
options: { target?: EventTarget } = {}
|
||||
): KeyboardEvent & { preventDefault: jest.Mock } {
|
||||
const event = new KeyboardEvent('keydown', { key }) as KeyboardEvent & {
|
||||
preventDefault: jest.Mock;
|
||||
};
|
||||
Object.defineProperty(event, 'preventDefault', {
|
||||
configurable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
if (options.target) {
|
||||
Object.defineProperty(event, 'target', {
|
||||
configurable: true,
|
||||
value: options.target,
|
||||
});
|
||||
}
|
||||
return event;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
aspectLabel,
|
||||
audioTrackLabel,
|
||||
formatTime,
|
||||
measureBounds,
|
||||
persistVolume,
|
||||
readStoredVolume,
|
||||
subtitleTrackLabel,
|
||||
volumeIcon,
|
||||
volumeLabel,
|
||||
} from './embedded-mpv-format.utils';
|
||||
|
||||
describe('embedded MPV format utilities', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('formats playback time with hour rollover and safe null values', () => {
|
||||
expect(formatTime(null)).toBe('0:00');
|
||||
expect(formatTime(-30)).toBe('0:00');
|
||||
expect(formatTime(75.9)).toBe('1:15');
|
||||
expect(formatTime(3661)).toBe('1:01:01');
|
||||
});
|
||||
|
||||
it('clamps stored volume reads and persists raw volume values', () => {
|
||||
localStorage.setItem('volume', '2');
|
||||
expect(readStoredVolume()).toBe(1);
|
||||
|
||||
localStorage.setItem('volume', '-0.5');
|
||||
expect(readStoredVolume()).toBe(0);
|
||||
|
||||
localStorage.setItem('volume', 'not-a-number');
|
||||
expect(readStoredVolume()).toBe(1);
|
||||
|
||||
persistVolume(0.35);
|
||||
expect(localStorage.getItem('volume')).toBe('0.35');
|
||||
});
|
||||
|
||||
it('builds readable labels for tracks, aspects, and volume', () => {
|
||||
expect(
|
||||
audioTrackLabel(
|
||||
{
|
||||
id: 1,
|
||||
language: 'eng',
|
||||
selected: false,
|
||||
defaultTrack: true,
|
||||
},
|
||||
0
|
||||
)
|
||||
).toContain('Default');
|
||||
expect(
|
||||
subtitleTrackLabel({ id: 2, selected: false }, 1)
|
||||
).toContain('Subtitle 2');
|
||||
expect(aspectLabel('16:9')).toBe('16:9');
|
||||
expect(aspectLabel('custom')).toBe('custom');
|
||||
expect(volumeIcon(0)).toBe('volume_off');
|
||||
expect(volumeIcon(0.25)).toBe('volume_down');
|
||||
expect(volumeIcon(0.75)).toBe('volume_up');
|
||||
expect(volumeLabel(0.755)).toBe('Volume 76%');
|
||||
});
|
||||
|
||||
it('rounds host bounds and keeps minimum native view dimensions', () => {
|
||||
const host = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10.4,
|
||||
top: 20.6,
|
||||
width: 0,
|
||||
height: 0.2,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
|
||||
expect(measureBounds(host)).toEqual({
|
||||
x: 10,
|
||||
y: 21,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
EmbeddedMpvSession,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
|
||||
|
||||
describe('EmbeddedMpvSessionController', () => {
|
||||
let electron: {
|
||||
platform: string;
|
||||
getEmbeddedMpvSupport: jest.Mock;
|
||||
prepareEmbeddedMpv: jest.Mock;
|
||||
createEmbeddedMpvSession: jest.Mock;
|
||||
loadEmbeddedMpvPlayback: jest.Mock;
|
||||
disposeEmbeddedMpvSession: jest.Mock;
|
||||
setEmbeddedMpvBounds: jest.Mock;
|
||||
onEmbeddedMpvSessionUpdate: jest.Mock;
|
||||
setEmbeddedMpvPaused: jest.Mock;
|
||||
seekEmbeddedMpv: jest.Mock;
|
||||
setEmbeddedMpvVolume: jest.Mock;
|
||||
};
|
||||
let sessionUpdate: ((session: EmbeddedMpvSession) => void) | null;
|
||||
let unsubscribeSessionUpdate: jest.Mock;
|
||||
let testingModuleDestroyed: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
testingModuleDestroyed = false;
|
||||
sessionUpdate = null;
|
||||
unsubscribeSessionUpdate = jest.fn();
|
||||
electron = {
|
||||
platform: 'darwin',
|
||||
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
}),
|
||||
prepareEmbeddedMpv: jest.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
}),
|
||||
createEmbeddedMpvSession: jest
|
||||
.fn()
|
||||
.mockResolvedValue(createSession({ id: 'mpv-1' })),
|
||||
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
|
||||
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
|
||||
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
|
||||
onEmbeddedMpvSessionUpdate: jest.fn((callback) => {
|
||||
sessionUpdate = callback;
|
||||
return unsubscribeSessionUpdate;
|
||||
}),
|
||||
setEmbeddedMpvPaused: jest.fn().mockResolvedValue(
|
||||
createSession({
|
||||
id: 'mpv-1',
|
||||
status: 'paused',
|
||||
})
|
||||
),
|
||||
seekEmbeddedMpv: jest.fn().mockResolvedValue(
|
||||
createSession({
|
||||
id: 'mpv-1',
|
||||
positionSeconds: 15,
|
||||
})
|
||||
),
|
||||
setEmbeddedMpvVolume: jest.fn().mockResolvedValue(
|
||||
createSession({
|
||||
id: 'mpv-1',
|
||||
volume: 0.25,
|
||||
})
|
||||
),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electron,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
value: class MockResizeObserver {
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: (callback: FrameRequestCallback) => {
|
||||
return window.setTimeout(() => callback(0), 0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: (handle: number) => window.clearTimeout(handle),
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [EmbeddedMpvSessionController],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (!testingModuleDestroyed) {
|
||||
TestBed.resetTestingModule();
|
||||
}
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function destroyTestingModule(): void {
|
||||
TestBed.resetTestingModule();
|
||||
testingModuleDestroyed = true;
|
||||
}
|
||||
|
||||
it('loads support and unsubscribes from session updates on destroy', async () => {
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
|
||||
await waitFor(() => controller.support() !== null, 'support to load');
|
||||
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalled();
|
||||
expect(controller.support()).toEqual({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
destroyTestingModule();
|
||||
expect(unsubscribeSessionUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a session, forwards matching updates, and disposes on teardown', async () => {
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
const host = createHost();
|
||||
const playback = createPlayback();
|
||||
|
||||
const teardown = controller.startSession(host, playback, 0.7);
|
||||
expect(controller.session()).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'embedded-mpv-starting',
|
||||
status: 'loading',
|
||||
volume: 0.7,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => controller.sessionId() === 'mpv-1',
|
||||
'session to start'
|
||||
);
|
||||
|
||||
expect(electron.prepareEmbeddedMpv).toHaveBeenCalled();
|
||||
expect(electron.createEmbeddedMpvSession).toHaveBeenCalledWith(
|
||||
{ x: 11, y: 21, width: 640, height: 360 },
|
||||
'Example Movie',
|
||||
0.7
|
||||
);
|
||||
expect(electron.loadEmbeddedMpvPlayback).toHaveBeenCalledWith(
|
||||
'mpv-1',
|
||||
playback
|
||||
);
|
||||
expect(controller.sessionId()).toBe('mpv-1');
|
||||
|
||||
sessionUpdate?.(createSession({ id: 'other', status: 'paused' }));
|
||||
expect(controller.session()?.status).toBe('playing');
|
||||
|
||||
sessionUpdate?.(createSession({ id: 'mpv-1', status: 'paused' }));
|
||||
expect(controller.session()?.status).toBe('paused');
|
||||
|
||||
teardown();
|
||||
|
||||
expect(controller.session()).toBeNull();
|
||||
expect(controller.sessionId()).toBeNull();
|
||||
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
|
||||
'mpv-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets an error session when Electron cannot create playback', async () => {
|
||||
electron.prepareEmbeddedMpv.mockRejectedValueOnce(
|
||||
new Error('native module missing')
|
||||
);
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
|
||||
controller.startSession(createHost(), createPlayback(), 0.5);
|
||||
await waitFor(
|
||||
() => controller.session()?.status === 'error',
|
||||
'error session to be set'
|
||||
);
|
||||
|
||||
expect(controller.session()).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'embedded-mpv-error',
|
||||
status: 'error',
|
||||
error: 'native module missing',
|
||||
})
|
||||
);
|
||||
expect(controller.sessionId()).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards playback commands and updates session snapshots', async () => {
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
controller.sessionId.set('mpv-1');
|
||||
controller.session.set(
|
||||
createSession({
|
||||
id: 'mpv-1',
|
||||
status: 'playing',
|
||||
positionSeconds: 3,
|
||||
})
|
||||
);
|
||||
|
||||
await controller.togglePaused();
|
||||
expect(electron.setEmbeddedMpvPaused).toHaveBeenCalledWith(
|
||||
'mpv-1',
|
||||
true
|
||||
);
|
||||
expect(controller.session()?.status).toBe('paused');
|
||||
|
||||
await controller.seekBy(-30);
|
||||
expect(electron.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 0);
|
||||
expect(controller.session()?.positionSeconds).toBe(15);
|
||||
|
||||
await controller.applyVolume(0.25);
|
||||
expect(electron.setEmbeddedMpvVolume).toHaveBeenCalledWith(
|
||||
'mpv-1',
|
||||
0.25
|
||||
);
|
||||
expect(controller.session()?.volume).toBe(0.25);
|
||||
});
|
||||
|
||||
it('swallows command errors so stale IPC races do not clear current state', async () => {
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
const current = createSession({ id: 'mpv-1', volume: 0.8 });
|
||||
controller.sessionId.set('mpv-1');
|
||||
controller.session.set(current);
|
||||
electron.setEmbeddedMpvVolume.mockRejectedValueOnce(
|
||||
new Error('session disposed')
|
||||
);
|
||||
|
||||
await controller.applyVolume(0.25);
|
||||
|
||||
expect(controller.session()).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
function createHost(): HTMLElement {
|
||||
return {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10.6,
|
||||
top: 20.5,
|
||||
width: 640,
|
||||
height: 360,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
}
|
||||
|
||||
function createPlayback(): ResolvedPortalPlayback {
|
||||
return {
|
||||
streamUrl: 'https://example.com/movie.mp4',
|
||||
title: 'Example Movie',
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<EmbeddedMpvSession> = {}
|
||||
): EmbeddedMpvSession {
|
||||
return {
|
||||
id: 'mpv-1',
|
||||
title: 'Example Movie',
|
||||
streamUrl: 'https://example.com/movie.mp4',
|
||||
status: 'playing',
|
||||
positionSeconds: 10,
|
||||
durationSeconds: 120,
|
||||
volume: 0.7,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
startedAt: '2026-06-02T00:00:00.000Z',
|
||||
updatedAt: '2026-06-02T00:00:01.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
condition: () => boolean,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (condition()) {
|
||||
return;
|
||||
}
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts';
|
||||
|
||||
describe('EmbeddedMpvShortcuts', () => {
|
||||
let shortcuts: EmbeddedMpvShortcuts;
|
||||
let handlers: {
|
||||
isAvailable: jest.Mock<boolean, []>;
|
||||
onEscape: jest.Mock;
|
||||
togglePaused: jest.Mock;
|
||||
toggleFullscreen: jest.Mock;
|
||||
seekBy: jest.Mock;
|
||||
adjustVolume: jest.Mock;
|
||||
toggleMute: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
shortcuts = new EmbeddedMpvShortcuts();
|
||||
handlers = {
|
||||
isAvailable: jest.fn(() => true),
|
||||
onEscape: jest.fn(),
|
||||
togglePaused: jest.fn(),
|
||||
toggleFullscreen: jest.fn(),
|
||||
seekBy: jest.fn(),
|
||||
adjustVolume: jest.fn(),
|
||||
toggleMute: jest.fn(),
|
||||
};
|
||||
shortcuts.attach(handlers);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
shortcuts.detach();
|
||||
});
|
||||
|
||||
it('forwards playback, seek, volume, and fullscreen shortcuts', () => {
|
||||
expect(dispatchKey(' ')).toBe(true);
|
||||
expect(dispatchKey('ArrowLeft')).toBe(true);
|
||||
expect(dispatchKey('ArrowRight')).toBe(true);
|
||||
expect(dispatchKey('ArrowUp')).toBe(true);
|
||||
expect(dispatchKey('ArrowDown')).toBe(true);
|
||||
expect(dispatchKey('f')).toBe(true);
|
||||
expect(dispatchKey('M')).toBe(true);
|
||||
|
||||
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.seekBy).toHaveBeenCalledWith(-5);
|
||||
expect(handlers.seekBy).toHaveBeenCalledWith(5);
|
||||
expect(handlers.adjustVolume).toHaveBeenCalledWith(0.05);
|
||||
expect(handlers.adjustVolume).toHaveBeenCalledWith(-0.05);
|
||||
expect(handlers.toggleFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('always allows escape to close popovers even when playback is unavailable', () => {
|
||||
handlers.isAvailable.mockReturnValue(false);
|
||||
|
||||
expect(dispatchKey('Escape')).toBe(false);
|
||||
expect(dispatchKey('k')).toBe(false);
|
||||
|
||||
expect(handlers.onEscape).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.togglePaused).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores shortcuts inside form controls', () => {
|
||||
const input = document.createElement('input');
|
||||
const select = document.createElement('select');
|
||||
document.body.append(input, select);
|
||||
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowUp',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
select.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'm',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.adjustVolume).not.toHaveBeenCalled();
|
||||
expect(handlers.toggleMute).not.toHaveBeenCalled();
|
||||
input.remove();
|
||||
select.remove();
|
||||
});
|
||||
|
||||
it('detaches its document listener', () => {
|
||||
shortcuts.detach();
|
||||
|
||||
dispatchKey('k');
|
||||
|
||||
expect(handlers.togglePaused).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function dispatchKey(key: string): boolean {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
EmbeddedMpvFeedback,
|
||||
EmbeddedMpvMenuState,
|
||||
} from './embedded-mpv-ui-state';
|
||||
|
||||
describe('EmbeddedMpvMenuState', () => {
|
||||
it('keeps only one menu open at a time', () => {
|
||||
const menus = new EmbeddedMpvMenuState();
|
||||
|
||||
menus.open('volume');
|
||||
expect(menus.volumeOpen()).toBe(true);
|
||||
expect(menus.anyOpen()).toBe(true);
|
||||
|
||||
menus.open('audio');
|
||||
expect(menus.volumeOpen()).toBe(false);
|
||||
expect(menus.audioOpen()).toBe(true);
|
||||
|
||||
menus.toggle('audio');
|
||||
expect(menus.audioOpen()).toBe(false);
|
||||
expect(menus.anyOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmbeddedMpvFeedback', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('flashes feedback and clears the previous timeout when replaced', () => {
|
||||
const feedback = new EmbeddedMpvFeedback();
|
||||
|
||||
feedback.flash('volume_up', '60%', 700);
|
||||
expect(feedback.current()).toEqual({
|
||||
icon: 'volume_up',
|
||||
label: '60%',
|
||||
key: 1,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
feedback.flash('volume_down', '55%', 700);
|
||||
expect(feedback.current()).toEqual({
|
||||
icon: 'volume_down',
|
||||
label: '55%',
|
||||
key: 2,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(699);
|
||||
expect(feedback.current()?.label).toBe('55%');
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(feedback.current()).toBeNull();
|
||||
});
|
||||
|
||||
it('disposes pending feedback timers', () => {
|
||||
const feedback = new EmbeddedMpvFeedback();
|
||||
|
||||
feedback.flash('volume_off', 'Muted', 700);
|
||||
feedback.dispose();
|
||||
jest.advanceTimersByTime(700);
|
||||
|
||||
expect(feedback.current()?.label).toBe('Muted');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user