frontend/test/timeline.test.ts
Rama master · réplica de lectura del repositorio original.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { timelineFrame } from '../src/player/timeline';
import { mountPlayerSeekPreview } from '../src/player/controls';
import { createSeekPreview, PRELOAD_PAGES, preloadPages, previewCenter, sheetTile } from '../src/player/seek-preview';
import type { VjsPlayer } from '../src/player/types';
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren() });
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function stubTimeline(payload: object) {
const fetch = vi.fn(async (_url: string) => new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetch);
return fetch;
}
/** 进度条宿主:宽 `track`,预览实测宽 `width`。 */
function seekHost(track: number, width = 0) {
const host = document.createElement('div');
document.body.append(host);
host.getBoundingClientRect = () => ({ width: track, left: 0 } as DOMRect);
const measured = () => {
const preview = host.querySelector<HTMLElement>('[data-player-seek-preview]')!;
Object.defineProperty(preview, 'offsetWidth', { configurable: true, get: () => width });
return preview;
};
return { host, measured };
}
function stubImages() {
const loaded: string[] = [];
vi.stubGlobal('Image', class { decoding = ''; dataset: Record<string, string> = {}; set src(value: string) { loaded.push(value) } });
return loaded;
}
function loadPreviewImage(host: ParentNode) {
const image = host.querySelector('img')!;
Object.defineProperty(image, 'complete', { configurable: true, value: true });
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 320 });
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 180 });
image.dispatchEvent(new Event('load'));
}
describe('时间到雪碧图坐标', () => {
it('固定间隔按页与格换算,末张按自身帧数算行数', () => {
const sheet = { frames: 250, interval: 10, columns: 10, rows: 10 };
expect(sheetTile(0, sheet)).toEqual({ page: 0, slot: 0, columns: 10, rows: 10 });
expect(sheetTile(1055, sheet)).toEqual({ page: 1, slot: 5, columns: 10, rows: 10 });
expect(sheetTile(2499, sheet)).toEqual({ page: 2, slot: 49, columns: 10, rows: 5 });
expect(sheetTile(99999, sheet)).toEqual({ page: 2, slot: 49, columns: 10, rows: 5 });
});
it('非 10×10 的拼图按自身列数换算,未覆盖的时刻没有格子', () => {
expect(sheetTile(65, { frames: 30, interval: 5, columns: 5, rows: 4 })).toEqual({ page: 0, slot: 13, columns: 5, rows: 4 });
expect(sheetTile(100, { frames: 30, interval: 5, columns: 5, rows: 4 })).toEqual({ page: 1, slot: 0, columns: 5, rows: 2 });
expect(sheetTile(5, { frames: 1, interval: 0, cues: [[0, 3]] })).toBeNull();
});
it('只预热页数可数的自有接触印相', () => {
expect(preloadPages({ frames: 250, interval: 10 })).toBe(3);
expect(preloadPages({ frames: 2, interval: 0, cues: [[0, 3], [3, 6]] })).toBe(0);
expect(preloadPages({ source: 'jellyfin', frames: 250, interval: 10 })).toBe(0);
expect(preloadPages({ frames: 100 * PRELOAD_PAGES + 1, interval: 10 })).toBe(0);
expect(preloadPages(null)).toBe(0);
});
});
describe('边缘对齐', () => {
it('中心点跟着指针,两端整块留在进度条里', () => {
expect(previewCenter(300, 600, 240)).toBe(300);
expect(previewCenter(10, 600, 240)).toBe(120);
expect(previewCenter(595, 600, 240)).toBe(480);
expect(previewCenter(30, 200, 240)).toBe(100);
});
it('进度条贴着视口边时两端各让出边距', () => {
expect(previewCenter(10, 600, 240, 12)).toBe(132);
expect(previewCenter(595, 600, 240, 12)).toBe(468);
expect(previewCenter(30, 250, 240, 12)).toBe(125);
});
it('按显出来之后的实测宽度夹边,不按估算宽度', async () => {
stubTimeline({});
const { host, measured } = seekHost(600, 244);
const preview = createSeekPreview(host, { id: 7, width: 1920, height: 1080, has_thumb: true });
const element = measured();
preview.show(0, 100);
expect(element.hidden).toBe(true);
loadPreviewImage(host);
expect(element.style.left).toBe('122px');
preview.show(1, 100);
expect(element.style.left).toBe('478px');
preview.show(.5, 100);
expect(element.style.left).toBe('300px');
});
});
describe('无时间轴图降级', () => {
it('没有索引也没有快照时收起整块预览,不取任何图', async () => {
const fetch = stubTimeline({});
const { host, measured } = seekHost(900, 160);
const preview = createSeekPreview(host, { id: 7, has_thumb: false });
const element = measured();
await settle();
preview.show(.5, 600);
expect(host.querySelector('img')!.hasAttribute('src')).toBe(false);
expect(host.querySelector<HTMLElement>('.vjs-peach-seek-frame')!.hidden).toBe(true);
expect(element.hidden).toBe(true);
expect(element.querySelector('span')!.textContent).toBe('5:00');
expect(fetch.mock.calls.map(([url]) => url)).toEqual(['/api/timeline?id=7']);
});
it('已有九宫格成功后显示,取不到则收起且不再请求', async () => {
const fetch = stubTimeline({});
const { host, measured } = seekHost(900, 160);
const preview = createSeekPreview(host, { id: 7, has_thumb: true });
const element = measured();
await settle();
expect(fetch).toHaveBeenCalledTimes(1);
preview.show(.05, 600);
const frame = host.querySelector<HTMLElement>('.vjs-peach-seek-frame')!, image = host.querySelector('img')!;
expect(image.getAttribute('src')).toBe('/poster?id=7&c=0');
expect(element.hidden).toBe(true);
loadPreviewImage(host);
expect(element.hidden).toBe(false);
preview.hide();
loadPreviewImage(host);
expect(element.hidden).toBe(true);
preview.show(.05, 600);
image.dispatchEvent(new Event('error'));
preview.show(.95, 600);
expect(image.getAttribute('src')).toBe('/poster?id=7&c=0');
expect(frame.hidden).toBe(true);
expect(element.hidden).toBe(true);
expect(element.querySelector('span')!.textContent).toBe('9:30');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('关注条目不取图、不查索引,也不显示空预览', () => {
const fetch = stubTimeline({});
const { host } = seekHost(600);
createSeekPreview(host, null).show(.5, 120);
expect(host.querySelector('.vjs-peach-seek-frame')).toBeNull();
expect(host.querySelector<HTMLElement>('[data-player-seek-preview]')!.hidden).toBe(true);
expect(host.querySelector('[data-player-seek-preview] span')!.textContent).toBe('1:00');
expect(fetch).not.toHaveBeenCalled();
});
});
describe('雪碧图预加载', () => {
it('碰到进度条才预热,每页只取一次', async () => {
stubTimeline({ source: 'peach', frames: 250, interval: 10, columns: 10, rows: 10 });
const loaded = stubImages();
const { host } = seekHost(600);
const preview = createSeekPreview(host, { id: 9 });
await settle();
expect(loaded).toEqual([]);
preview.prime(); preview.prime();
preview.show(.2, 2500); preview.show(.8, 2500);
expect(loaded).toEqual(['/timeline?id=9&s=0&source=peach', '/timeline?id=9&s=1&source=peach', '/timeline?id=9&s=2&source=peach']);
});
it('外部逐帧来源不预热,自有缓存作为兜底照样预热', async () => {
stubTimeline({ source: 'bif', frames: 3, interval: 0, cues: [[0, 10], [10, 20], [20, 30]],
fallback: { source: 'peach', frames: 3, interval: 10, columns: 10, rows: 10 } });
const loaded = stubImages();
const { host } = seekHost(600);
const preview = createSeekPreview(host, { id: 9 });
preview.prime();
await settle();
expect(loaded).toEqual(['/timeline?id=9&s=0&source=peach']);
});
});
describe('Video.js 进度条接线', () => {
function mounted() {
stubTimeline({});
const root = document.createElement('div');
root.innerHTML = '<div class="vjs-control-bar"><div class="vjs-progress-control"></div></div>';
document.body.append(root);
const progress = root.querySelector<HTMLElement>('.vjs-progress-control')!;
progress.getBoundingClientRect = () => ({ width: 300, left: 0 } as DOMRect);
const player = { el: () => root, duration: () => 30, on: () => {} } as unknown as VjsPlayer;
mountPlayerSeekPreview(player, { id: 42, duration: 30, has_thumb: true });
const preview = progress.querySelector<HTMLElement>('[data-player-seek-preview]')!;
const fire = (type: string, x: number, pointerType: string) => {
progress.dispatchEvent(new PointerEvent(type, { clientX: x, pointerType, bubbles: true }));
loadPreviewImage(progress);
};
return { preview, fire };
}
it('触屏按住拖动时显示,松手收起', () => {
const { preview, fire } = mounted();
fire('pointerdown', 150, 'touch');
expect(preview.hidden).toBe(false);
expect(preview.querySelector('span')!.textContent).toBe('0:15');
fire('pointermove', 270, 'touch');
expect(preview.querySelector('span')!.textContent).toBe('0:27');
fire('pointerup', 270, 'touch');
expect(preview.hidden).toBe(true);
});
it('鼠标松开仍在悬停,预览留着;离开进度条才收起', () => {
const { preview, fire } = mounted();
fire('pointermove', 30, 'mouse');
fire('pointerup', 30, 'mouse');
expect(preview.hidden).toBe(false);
fire('pointerleave', 30, 'mouse');
expect(preview.hidden).toBe(true);
});
});
describe('索引晚于悬停到达', () => {
it('指针停着不动也换成拼图那一格,图到了就显示;已离开则不显示', async () => {
stubTimeline({ source: 'peach', frames: 20, interval: 1, columns: 10, rows: 10 });
const { host, measured } = seekHost(900, 160);
const preview = createSeekPreview(host, { id: 7, width: 1920, height: 1080, has_thumb: false });
const element = measured(), image = host.querySelector('img')!;
preview.show(.65, 20);
expect(image.hasAttribute('src')).toBe(false);
expect(element.hidden).toBe(true);
await settle();
expect(image.getAttribute('src')).toBe('/timeline?id=7&s=0&source=peach');
expect([image.style.left, image.style.top]).toEqual(['-300%', '-100%']);
loadPreviewImage(host);
expect(element.hidden).toBe(false);
stubTimeline({ source: 'peach', frames: 20, interval: 1, columns: 10, rows: 10 });
const late = seekHost(900, 160);
const left = createSeekPreview(late.host, { id: 8, has_thumb: false });
const leftElement = late.measured();
left.show(.65, 20);
left.hide();
await settle();
expect(late.host.querySelector('img')!.hasAttribute('src')).toBe(false);
expect(leftElement.hidden).toBe(true);
});
});
describe('时间轴索引', () => {
it('按预览自身的非均匀时间选择帧,并保留未覆盖区间', () => {
const source = { frames: 3, interval: 0, cues: [[0, 3.5], [3.5, 7], [10, 15]] as [number, number][] };
expect([0, 3.4, 3.5, 7, 9, 10, 15, 16].map(time => timelineFrame(time, source)))
.toEqual([0, 0, 1, null, null, 2, 2, null]);
});
it('按固定间隔选择接触印相中的帧', () => {
const source = { frames: 120, interval: 10 };
expect([0, 9, 10, 999, 1000, 1500].map(time => timelineFrame(time, source)))
.toEqual([0, 0, 1, 99, 100, 119]);
});
it('无效索引和时刻返回空值', () => {
expect(timelineFrame(-1, { frames: 2, interval: 10 })).toBeNull();
expect(timelineFrame(Infinity, { frames: 2, interval: 10 })).toBeNull();
expect(timelineFrame(0, { frames: 0, interval: 10 })).toBeNull();
expect(timelineFrame(0, { frames: 2, interval: 0 })).toBeNull();
expect(timelineFrame(1, { frames: 1, interval: 0, cues: [[2, 4]] })).toBeNull();
});
it('预览控件使用准确来源,在空隙与图片失败时读取已有缓存', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
source: 'webvtt', frames: 2, interval: 0, columns: 1, rows: 1, cues: [[0, 3], [8, 12]],
fallback: { source: 'peach', frames: 3, interval: 10, columns: 10, rows: 10 },
}), { headers: { 'Content-Type': 'application/json' } })));
const root = document.createElement('div');
root.innerHTML = '<div class="vjs-control-bar"><div class="vjs-progress-control"></div></div>';
document.body.append(root);
const progress = root.querySelector<HTMLElement>('.vjs-progress-control')!;
progress.getBoundingClientRect = () => ({ width: 300, left: 0 } as DOMRect);
const player = { el: () => root, duration: () => 30, on: () => {} } as unknown as VjsPlayer;
mountPlayerSeekPreview(player, { id: 42, duration: 30, width: 1920, height: 1080, has_thumb: true });
await new Promise(resolve => setTimeout(resolve, 0));
const image = root.querySelector('img')!;
const move = (x: number) => progress.dispatchEvent(new PointerEvent('pointermove', { clientX: x, pointerType: 'mouse' }));
move(90);
expect(image.getAttribute('src')).toBe('/timeline?id=42&s=1&source=webvtt');
expect(image.style.width).toBe('100%');
move(200);
expect(image.getAttribute('src')).toBe('/timeline?id=42&s=0&source=peach');
expect(image.style.left).toBe('-200%');
move(90);
image.dispatchEvent(new Event('error'));
move(90);
expect(image.getAttribute('src')).toBe('/timeline?id=42&s=0&source=peach');
image.dispatchEvent(new Event('error'));
move(90);
expect(image.getAttribute('src')).toBe('/poster?id=42&c=2');
});
});