ytptube/ui/tests/composables/usePlayerSubtitles.test.ts
2026-05-04 22:23:19 +03:00

291 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { nextTick, ref } from 'vue';
type MockResponseInput = {
ok: boolean;
status: number;
jsonData?: unknown;
textData?: string;
};
const runtimeConfig = {
app: {
baseURL: '/',
},
};
const testGlobals = globalThis as typeof globalThis & {
useRuntimeConfig?: () => typeof runtimeConfig;
useNotification?: () => { error: ReturnType<typeof mock> };
};
const notificationErrorMock = mock(() => {});
testGlobals.useRuntimeConfig = () => runtimeConfig;
testGlobals.useNotification = () => ({ error: notificationErrorMock });
mock.module('#imports', () => ({
useRuntimeConfig: () => runtimeConfig,
useNotification: () => ({ error: notificationErrorMock }),
}));
function createMockResponse({ ok, status, jsonData, textData }: MockResponseInput): Response {
return {
ok,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
redirected: false,
statusText: ok ? 'OK' : 'Error',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this;
},
async json() {
return jsonData;
},
async text() {
return textData ?? JSON.stringify(jsonData ?? {});
},
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response;
}
async function flushPromises(times = 4) {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
await nextTick();
}
}
describe('usePlayerSubtitles', () => {
const fetchMock = mock(async (_input: RequestInfo | URL) => createMockResponse({ ok: true, status: 200, jsonData: {} }));
const assShowMock = mock(() => {});
const assDestroyMock = mock(() => {});
const assConstructorMock = mock(() => ({
show: assShowMock,
destroy: assDestroyMock,
}));
beforeEach(() => {
runtimeConfig.app.baseURL = '/';
fetchMock.mockClear();
assShowMock.mockClear();
assDestroyMock.mockClear();
assConstructorMock.mockClear();
notificationErrorMock.mockClear();
globalThis.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
delete (globalThis as { fetch?: typeof fetch }).fetch;
});
it('load_native_track', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'English',
source_format: 'vtt',
delivery_format: 'vtt',
renderer: 'native',
url: '/api/player/subtitles/vtt/video.vtt',
},
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const manifestUrl = ref('/api/player/subtitles/manifest/video%20file.mkv');
const isVideo = ref(true);
const canPlay = ref(true);
const shouldRender = ref(false);
const video = ref<HTMLVideoElement | null>(document.createElement('video'));
const overlay = ref<HTMLElement | null>(document.createElement('div'));
const { hasSubtitles, nativeSubtitleTrack, selectedSubtitleTrack, usesAssSubtitleTrack } =
usePlayerSubtitles({
manifestUrl,
isVideo,
canPlay,
shouldRender,
video,
overlay,
});
await flushPromises();
expect(fetchMock).toHaveBeenCalledWith('/api/player/subtitles/manifest/video%20file.mkv', expect.anything());
expect(hasSubtitles.value).toBe(true);
expect(selectedSubtitleTrack.value?.source_format).toBe('vtt');
expect(nativeSubtitleTrack.value?.url).toBe('/api/player/subtitles/vtt/video.vtt');
expect(usesAssSubtitleTrack.value).toBe(false);
});
it('keep_manifest_path', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { subtitles: [] },
}),
);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
usePlayerSubtitles({
manifestUrl: ref(
'/api/player/subtitles/manifest/youtube/Channel%20Name/Season%202026/video%20file.mkv',
),
isVideo: ref(true),
canPlay: ref(true),
shouldRender: ref(false),
video: ref<HTMLVideoElement | null>(document.createElement('video')),
overlay: ref<HTMLElement | null>(document.createElement('div')),
});
await flushPromises();
expect(fetchMock).toHaveBeenCalledWith(
'/api/player/subtitles/manifest/youtube/Channel%20Name/Season%202026/video%20file.mkv',
expect.anything(),
);
});
it('mount_ass_renderer', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const fetchText = mock(async () => '[Script Info]\nTitle: Demo\n');
const loadRenderer = mock(async () => assConstructorMock as any);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const manifestUrl = ref('/api/player/subtitles/manifest/video.mkv');
const isVideo = ref(true);
const canPlay = ref(true);
const shouldRender = ref(false);
const video = ref<HTMLVideoElement | null>(document.createElement('video'));
const overlay = ref<HTMLElement | null>(document.createElement('div'));
const { usesAssSubtitleTrack } = usePlayerSubtitles({
manifestUrl,
isVideo,
canPlay,
shouldRender,
video,
overlay,
fetchText,
loadRenderer,
});
await flushPromises();
expect(usesAssSubtitleTrack.value).toBe(true);
expect(assConstructorMock).not.toHaveBeenCalled();
shouldRender.value = true;
await flushPromises(5);
expect(fetchText).toHaveBeenCalledWith('/api/player/subtitles/ass/video.ass');
expect(loadRenderer).toHaveBeenCalledTimes(1);
expect(assConstructorMock).toHaveBeenCalledTimes(1);
expect(assShowMock).toHaveBeenCalledTimes(1);
manifestUrl.value = '/api/player/subtitles/manifest/second.mkv';
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { subtitles: [] },
}),
);
await flushPromises();
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it('remount_on_layout_change', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const fetchText = mock(async () => '[Script Info]\nTitle: Demo\n');
const loadRenderer = mock(async () => assConstructorMock as any);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const assLayoutVersion = ref(0);
usePlayerSubtitles({
manifestUrl: ref('/api/player/subtitles/manifest/video.mkv'),
isVideo: ref(true),
canPlay: ref(true),
shouldRender: ref(true),
assLayoutVersion,
video: ref<HTMLVideoElement | null>(document.createElement('video')),
overlay: ref<HTMLElement | null>(document.createElement('div')),
fetchText,
loadRenderer,
});
await flushPromises(5);
expect(fetchText).toHaveBeenCalledTimes(1);
expect(assConstructorMock).toHaveBeenCalledTimes(1);
assLayoutVersion.value += 1;
await flushPromises(5);
expect(fetchText).toHaveBeenCalledTimes(1);
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(assConstructorMock).toHaveBeenCalledTimes(2);
});
});