mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
The `@iptvnator/services` barrel ships in the initial bundle, so adding PlaybackHistoryGate there (and subscribing to it from the app-wide ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes. - Move the gate to a new lazy-only `playback-data-access` project (`@iptvnator/playback/data-access`; scope:shared, domain:playback, type:data-access) and register it in the coverage policy. - The gate subscribes to MPV/VLC session updates itself; it is created by the first deferred write, which precedes the launch it waits for. ExternalPlaybackService is back to master. - The Xtream "playlist switched before confirmation" check moves to the lazy helper; the initial-path store only takes a `skipListRefresh` flag. Net effect on this branch: +27 bytes over master (master itself is 108 bytes over the ratchet baseline already). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
|
|
import { ExternalPlaybackService } from './external-playback.service';
|
|
|
|
describe('ExternalPlaybackService', () => {
|
|
let listener: ((session: ExternalPlayerSession) => void) | undefined;
|
|
let closeExternalPlayerSession: jest.Mock;
|
|
let service: ExternalPlaybackService;
|
|
|
|
const createSession = (
|
|
overrides: Partial<ExternalPlayerSession> = {}
|
|
): ExternalPlayerSession => ({
|
|
id: 'session-1',
|
|
player: 'mpv',
|
|
status: 'launching',
|
|
title: 'Example',
|
|
streamUrl: 'https://example.com/video.m3u8',
|
|
contentInfo: {
|
|
playlistId: 'playlist-1',
|
|
contentXtreamId: 42,
|
|
contentType: 'vod',
|
|
},
|
|
startedAt: '2026-03-07T10:00:00.000Z',
|
|
updatedAt: '2026-03-07T10:00:00.000Z',
|
|
canClose: true,
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
listener = undefined;
|
|
closeExternalPlayerSession = jest.fn().mockResolvedValue(null);
|
|
|
|
Object.defineProperty(window, 'electron', {
|
|
configurable: true,
|
|
value: {
|
|
onExternalPlayerSessionUpdate: jest.fn((callback) => {
|
|
listener = callback;
|
|
return () => undefined;
|
|
}),
|
|
closeExternalPlayerSession,
|
|
},
|
|
});
|
|
|
|
service = new ExternalPlaybackService();
|
|
});
|
|
|
|
it('tracks the latest launch and hides dismissed sessions until the next launch', () => {
|
|
const first = createSession();
|
|
listener?.(first);
|
|
|
|
expect(service.activeSession()).toEqual(first);
|
|
expect(service.visibleSession()).toEqual(first);
|
|
|
|
service.dismissActiveSession();
|
|
expect(service.visibleSession()).toBeNull();
|
|
|
|
const next = createSession({
|
|
id: 'session-2',
|
|
title: 'Another Example',
|
|
});
|
|
listener?.(next);
|
|
|
|
expect(service.visibleSession()).toEqual(next);
|
|
});
|
|
|
|
it('matches only active non-error sessions to content info', () => {
|
|
const playing = createSession({
|
|
status: 'playing',
|
|
updatedAt: '2026-03-07T10:00:05.000Z',
|
|
});
|
|
listener?.(playing);
|
|
|
|
expect(
|
|
service.findMatchingSession({
|
|
playlistId: 'playlist-1',
|
|
contentXtreamId: 42,
|
|
contentType: 'vod',
|
|
})
|
|
).toEqual(playing);
|
|
|
|
listener?.(
|
|
createSession({
|
|
status: 'error',
|
|
error: 'Launch failed',
|
|
updatedAt: '2026-03-07T10:00:06.000Z',
|
|
})
|
|
);
|
|
|
|
expect(
|
|
service.findMatchingSession({
|
|
playlistId: 'playlist-1',
|
|
contentXtreamId: 42,
|
|
contentType: 'vod',
|
|
})
|
|
).toBeNull();
|
|
expect(service.visibleSession()).toEqual(
|
|
expect.objectContaining({ status: 'error' })
|
|
);
|
|
});
|
|
|
|
it('delegates close requests for closable sessions', async () => {
|
|
const session = createSession({ status: 'opened' });
|
|
listener?.(session);
|
|
|
|
closeExternalPlayerSession.mockResolvedValue(
|
|
createSession({
|
|
...session,
|
|
status: 'closed',
|
|
canClose: false,
|
|
updatedAt: '2026-03-07T10:00:10.000Z',
|
|
})
|
|
);
|
|
|
|
await service.closeActiveSession();
|
|
|
|
expect(closeExternalPlayerSession).toHaveBeenCalledWith(session.id);
|
|
expect(service.visibleSession()).toBeNull();
|
|
});
|
|
|
|
it('can close a specific session directly', async () => {
|
|
const session = createSession({ id: 'session-2', status: 'playing' });
|
|
|
|
closeExternalPlayerSession.mockResolvedValue(
|
|
createSession({
|
|
...session,
|
|
status: 'closed',
|
|
canClose: false,
|
|
updatedAt: '2026-03-07T10:00:12.000Z',
|
|
})
|
|
);
|
|
|
|
await service.closeSession(session);
|
|
|
|
expect(closeExternalPlayerSession).toHaveBeenCalledWith('session-2');
|
|
});
|
|
|
|
it('hides closed sessions but keeps errors visible until dismissed', () => {
|
|
listener?.(createSession({ status: 'closed', canClose: false }));
|
|
expect(service.visibleSession()).toBeNull();
|
|
|
|
const failed = createSession({
|
|
id: 'session-3',
|
|
status: 'error',
|
|
error: 'Launch failed',
|
|
canClose: false,
|
|
});
|
|
listener?.(createSession({ id: failed.id }));
|
|
listener?.(failed);
|
|
|
|
expect(service.visibleSession()).toEqual(failed);
|
|
|
|
service.dismissActiveSession();
|
|
expect(service.visibleSession()).toBeNull();
|
|
});
|
|
|
|
it('adopts an exactly correlated restored session after replacement teardown fails', () => {
|
|
const previous = createSession({
|
|
id: 'session-previous',
|
|
status: 'opened',
|
|
title: 'Previous stream',
|
|
});
|
|
const replacement = createSession({
|
|
id: 'session-replacement',
|
|
title: 'Replacement stream',
|
|
});
|
|
listener?.(previous);
|
|
listener?.(replacement);
|
|
|
|
listener?.({
|
|
...previous,
|
|
restoredFromSessionId: replacement.id,
|
|
updatedAt: '2026-03-07T10:00:20.000Z',
|
|
});
|
|
|
|
expect(service.activeSession()).toMatchObject({
|
|
id: previous.id,
|
|
status: 'opened',
|
|
});
|
|
expect(service.visibleSession()?.id).toBe(previous.id);
|
|
});
|
|
});
|