frontend/test/stage-player.test.ts
Rama master · réplica de lectura del repositorio original.
/* 舞台与小窗共用的那一个播放器(`src/react/stage/stage-player.ts`):同一条认领回来不重建、换一条
* 先拆旧的再挂新的、离开详情时哪些情形交给小窗。Video.js 本身换成假的:这里量的是实例在两个位置
* 之间怎么交接,挂载与拆除各发生了几次。真 Video.js 的挂载在 `e2e/stage.test.ts` 里量。 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerItem } from '../src/player';
interface FakePlayer {
el(): HTMLElement;
isDisposed(): boolean;
paused(): boolean;
ended(): boolean;
error(): null;
on(): void;
off(): void;
duration(): number;
currentTime(to?: number): number;
play(): Promise<void>;
pause(): void;
dispose(): void;
playing: boolean;
time: number;
total: number;
}
const fake = vi.hoisted(() => ({
mounts: [] as { player: FakePlayer; previousDisposed: boolean[] }[],
current: null as unknown,
}));
function fakePlayer(): FakePlayer {
const el = document.createElement('div');
el.className = 'video-js';
el.append(document.createElement('video'));
let disposed = false;
const player: FakePlayer = {
playing: true,
time: 0,
total: 100,
el: () => el,
isDisposed: () => disposed,
paused: () => !player.playing,
ended: () => false,
error: () => null,
on: () => {},
off: () => {},
duration: () => player.total,
currentTime: (to?: number) => { if (to !== undefined) player.time = to; return player.time },
play: () => Promise.resolve(),
pause: () => { player.playing = false },
dispose: () => { disposed = true; el.remove() },
};
return player;
}
vi.mock('../src/player', () => ({
attachPlayerChrome: vi.fn(() => () => {}),
cancelDetailStream: vi.fn(),
closePlayerMenu: vi.fn(),
configurePlayer: vi.fn(),
configurePlayerMenu: vi.fn(),
detailPlayer: () => fake.current,
setDetailPlayer: (player: unknown) => { fake.current = player },
disposePlayer: vi.fn((player: FakePlayer | null) => player?.dispose()),
preparePlayerFrame: vi.fn(),
resizeSoon: vi.fn(),
mountPlayer: vi.fn((video: HTMLVideoElement, options: {
onPlayer?: (player: FakePlayer) => void; handedOff?: (player: FakePlayer) => boolean;
}) => {
const player = fakePlayer();
fake.mounts.push({ player, previousDisposed: fake.mounts.map(({ player: before }) => before.isDisposed()) });
video.replaceWith(player.el());
fake.current = player;
options.onPlayer?.(player);
return () => { if (!options.handedOff?.(player)) player.dispose() };
}),
}));
type StagePlayer = typeof import('../src/react/stage/stage-player');
let stage: StagePlayer;
let open = true;
const settings = { miniplayer: true, seekSeconds: 10 };
let offline = false;
let dom: ReturnType<typeof miniplayerDom>;
const requestClose = vi.fn();
const expand = vi.fn();
function miniplayerDom() {
const node = (tag = 'div') => document.body.appendChild(document.createElement(tag));
const root = node('aside');
root.hidden = true;
return {
root, frame: node(), play: node('button'), time: node('span'), back: node('button'), ahead: node('button'),
title: node('span'), sub: node('span'), info: node('button'),
};
}
const item = (id: number) => ({ id, title: `作品 ${id}` }) as PlayerItem;
/* 舞台打开一条再离开:舞台岛拆的顺序是先问要不要进小窗,再卸 React 树(媒体框的清理跟着跑)。 */
function show(id: number, frame = document.body.appendChild(document.createElement('div'))) {
stage.setStageMeta({ kind: 'item', item: item(id), title: `作品 ${id}`, sub: '' });
const detach = stage.attachStagePlayer(frame, { kind: 'item', item: item(id) });
return {
frame,
leave(allow: boolean) { stage.handOffStage(allow); detach(); stage.setStageMeta(null) },
};
}
beforeEach(async () => {
vi.resetModules();
fake.mounts.length = 0;
fake.current = null;
open = true;
offline = false;
settings.miniplayer = true;
settings.seekSeconds = 10;
stage = await import('../src/react/stage/stage-player');
stage.configureStagePlayer({
player: { settings: () => settings, loadSourceStatus: async () => ({ local: !offline }),
offlineReason: () => '来源未挂载' } as never,
sourceOffline: () => offline, expand, openItem: vi.fn(),
}, { isOpen: () => open, requestClose });
dom = miniplayerDom();
stage.registerMiniplayer(dom);
});
afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals() });
describe('播放墙的共享播放器', () => {
const response = (extra: Record<string, unknown> = {}) => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 1, title: '作品 1', location: 'local', ...extra }) })));
};
it('收起播放墙交给小窗,打开详情认领同一实例和进度', async () => {
open = false; response();
const frame = document.body.appendChild(document.createElement('div'));
const release = await stage.mountWallPlayer(item(1), frame, new AbortController().signal);
const player = fake.mounts[0]!.player;
player.currentTime(43);
expect(stage.activeStageVideo()).toBe(player.el().querySelector('video'));
release(true);
expect(stage.miniplayerActive()).toBe(true);
expect(player.isDisposed()).toBe(false);
stage.prepareStageFor('item', 1);
const detail = show(1);
expect(detail.frame.contains(player.el())).toBe(true);
expect(player.currentTime()).toBe(43);
expect(fake.mounts).toHaveLength(1);
});
it('换片或停止释放实例,重复清理保持幂等', async () => {
open = false; response();
const release = await stage.mountWallPlayer(item(1), document.body.appendChild(document.createElement('div')), new AbortController().signal);
release(false); release(true);
expect(fake.mounts[0]!.player.isDisposed()).toBe(true);
expect(stage.miniplayerActive()).toBe(false);
expect(stage.stagePlayer()).toBeNull();
});
it('选定分卷或版本后使用共用播放器', async () => {
open = false; response({ part_group:{ key:'p' } });
const frame = document.body.appendChild(document.createElement('div'));
const release = await stage.mountWallPlayer({ ...item(1), part_group:{ key:'p' } }, frame, new AbortController().signal);
expect(fake.mounts).toHaveLength(1);
expect(frame.contains(fake.mounts[0]!.player.el())).toBe(true);
release();
});
it('计费、在线和脱盘作品不能直接拉流', async () => {
response();
const frame = document.body.appendChild(document.createElement('div'));
const signal = new AbortController().signal;
for (const extra of [{ cost: 'metered' }, { location: 'online' }]) {
response(extra);
await expect(stage.mountWallPlayer(item(1), frame, signal)).rejects.toThrow('请打开详情');
}
response(); offline = true;
await expect(stage.mountWallPlayer(item(1), frame, signal)).rejects.toThrow('来源未挂载');
expect(fake.mounts).toHaveLength(0);
});
it('离开页面后返回的作品响应不挂播放器', async () => {
let answer!: (response: unknown) => void;
vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { answer = resolve })));
const controller = new AbortController();
const mounting = stage.mountWallPlayer(item(1), document.body.appendChild(document.createElement('div')), controller.signal);
controller.abort();
answer({ ok: true, json: async () => ({ id: 1, location: 'local' }) });
await expect(mounting).rejects.toThrow();
expect(fake.mounts).toHaveLength(0);
});
});
describe('舞台与小窗之间交接播放器', () => {
it('离开详情时正在放的那一个搬进小窗;展开回同一条认领回来,不重建', () => {
show(1).leave(true);
const { player } = fake.mounts[0]!;
expect(player.isDisposed()).toBe(false);
expect(stage.miniplayerActive()).toBe(true);
expect(player.el().classList.contains('vjs-peach-mini')).toBe(true);
stage.prepareStageFor('item', 1);
const back = show(1);
expect(fake.mounts).toHaveLength(1);
expect(back.frame.contains(player.el())).toBe(true);
expect(player.el().classList.contains('vjs-peach-mini')).toBe(false);
expect(stage.stagePlayer()).toBe(player);
expect(stage.miniplayerActive()).toBe(false);
});
it('小窗放着别的条目时打开另一条:先拆小窗里那一个,再挂新的', () => {
show(1).leave(true);
stage.prepareStageFor('item', 2);
expect(fake.mounts[0]!.player.isDisposed()).toBe(true);
show(2);
expect(fake.mounts).toHaveLength(2);
expect(fake.mounts[1]!.previousDisposed).toEqual([true]);
});
it('队列换条:上一条的媒体框先卸,下一条才挂', () => {
show(1).leave(false);
show(2);
expect(fake.mounts[1]!.previousDisposed).toEqual([true]);
expect(stage.miniplayerActive()).toBe(false);
});
it.each([
['显式关闭', false, true, true],
['暂停着', true, false, true],
['设置里关了小窗', true, true, false],
])('%s就不进小窗,播放器随舞台拆掉', (_name, allow, playing, enabled) => {
settings.miniplayer = enabled;
const shown = show(1);
fake.mounts[0]!.player.playing = playing;
shown.leave(allow);
expect(fake.mounts[0]!.player.isDisposed()).toBe(true);
expect(stage.miniplayerActive()).toBe(false);
});
it('i 键在详情里请求进小窗:越过播放态判定,关闭请求照常走舞台', () => {
const shown = show(1);
fake.mounts[0]!.player.playing = false;
stage.toggleMiniplayer();
expect(requestClose).toHaveBeenCalledOnce();
shown.leave(false);
expect(fake.mounts[0]!.player.isDisposed()).toBe(false);
expect(stage.miniplayerActive()).toBe(true);
open = false;
stage.toggleMiniplayer();
expect(expand).toHaveBeenCalledWith('item', 1, null);
});
});
describe('小窗自己的键', () => {
it('快退快进按设置里的秒数跳,标签里带着这个数;时长取不到时不封顶', () => {
settings.seekSeconds = 15;
show(1).leave(true);
const { player } = fake.mounts[0]!;
expect(dom.back.getAttribute('aria-label')).toBe('后退 15 秒');
expect(dom.ahead.title).toBe('前进 15 秒');
player.time = 90;
stage.seekMiniplayer(1);
expect(player.time).toBe(100);
stage.seekMiniplayer(-1);
expect(player.time).toBe(85);
player.total = Number.NaN;
player.time = 100;
stage.seekMiniplayer(1);
expect(player.time).toBe(115);
});
it.each([
['分卷组', { part_group: { key: 'p' } }],
['版次组', { edition_group: { key: 'e' } }],
['图片', { medium: 'image' }],
['计费来源', { cost: 'metered', location: '115' }],
['反查不到关注条目的在线作品', { location: 'online' }],
])('小窗开着时%s的卡片照常进详情,不在小窗里换片', (_name, extra) => {
show(1).leave(true);
expect(stage.miniplayerTakesCard({ id: 2, location: 'local' } as PlayerItem)).toBe(true);
expect(stage.miniplayerTakesCard({ id: 2, location: 'local', ...extra } as PlayerItem)).toBe(false);
});
it('来源脱盘或小窗没开时也不接', () => {
expect(stage.miniplayerTakesCard({ id: 2, location: 'local' } as PlayerItem)).toBe(false);
show(1).leave(true);
offline = true;
expect(stage.miniplayerTakesCard({ id: 2, location: 'local' } as PlayerItem)).toBe(false);
});
});