Ctrl K
ComunidadGuías…

frontend/test/player-progress.test.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { playbackKey, playbackPosition, stopPlaybackPosition, trackPlaybackPosition } from '../src/player/progress';
import { disposePlayer } from '../src/player/dispose';
import { stopTelemetry, wireFollowTelemetry } from '../src/player/telemetry';
import type { VjsPlayer } from '../src/player/types';
import { mountDetailPlayer } from '../src/player/detail-player';
import { configurePlayer } from '../src/player/host';
import { setDetailPlayer } from '../src/player/registry';
import * as videojsLoader from '../src/player/videojs';

const item = { id: 12, duration: 300, leave_ratio: .4 };
const key = playbackKey('item', item);
let video: HTMLVideoElement;

beforeEach(() => { localStorage.clear(); video = document.createElement('video') });
afterEach(() => { stopPlaybackPosition(video); stopTelemetry(video); vi.useRealTimers(); vi.unstubAllGlobals() });

describe('视频续播位置', () => {
  it('关注片段的实际时长独立于帖子时长,恢复到该片段的保存位置', async () => {
    configurePlayer({ settings: () => ({ ambientMode: false, theaterMode: false, seekSeconds: 10,
      miniplayer: true, detailAutoplay: false, javImage: 'cover' }), saveSettings: () => {}, toast: () => {},
      loadSourceStatus: async () => ({}), offlineReason: () => '', stage: () => null });
    setDetailPlayer(null);
    const root = document.createElement('div'); root.append(video);
    Object.defineProperty(video, 'duration', { value: 108 });
    let time = 0, duration = 108, disposed = false;
    const listeners = new Map<string, (() => void)[]>();
    const player = {
      el: () => root, isDisposed: () => disposed, ready: () => {}, off: () => {},
      on: (events: string | string[], callback: () => void) => {
        for (const event of [events].flat()) listeners.set(event, [...listeners.get(event) || [], callback]);
      },
      one: (event: string, callback: () => void) => listeners.set(event, [...listeners.get(event) || [], callback]),
      duration: (seconds?: number) => { if (seconds !== undefined) duration = seconds; return duration },
      currentTime: (seconds?: number) => { if (seconds !== undefined) time = seconds; return time },
      src: () => listeners.get('loadedmetadata')?.forEach((callback) => callback()),
    } as unknown as VjsPlayer;
    vi.spyOn(videojsLoader, 'ensureVideojs').mockResolvedValue(() => player);
    try {
      await mountDetailPlayer({ id: 12, duration: 30 }, video, false,
        { source: { src: '/follow-stream?id=12&media=2', type: 'video/mp4' }, resume: { time: 97, autoplay: false } });
      await Promise.resolve();
      expect(time).toBe(97);
    } finally { disposed = true; setDetailPlayer(null) }
  });

  it('暂停、关闭和页面退出保留当前秒数,存储读取独立于条目的旧比例', () => {
    trackPlaybackPosition(key, video);
    video.dispatchEvent(new Event('loadedmetadata'));
    video.currentTime = 53.25;
    video.dispatchEvent(new Event('seeked'));
    expect(playbackPosition(key, item)).toBe(53.25);
    video.currentTime = 17;
    window.dispatchEvent(new Event('pagehide'));
    expect(playbackPosition(key, item)).toBe(17);
    video.currentTime = 8;
    stopPlaybackPosition(video);
    expect(playbackPosition(key, item)).toBe(8);
    video.currentTime = 29;
    window.dispatchEvent(new Event('pagehide'));
    expect(playbackPosition(key, item)).toBe(8);
  });

  it('未加载元数据与换源时的零位置不会覆盖已保存秒数', () => {
    trackPlaybackPosition(key, video);
    video.dispatchEvent(new Event('loadedmetadata'));
    video.currentTime = 91;
    video.dispatchEvent(new Event('pause'));
    video.dispatchEvent(new Event('emptied'));
    video.currentTime = 0;
    stopPlaybackPosition(video);
    expect(playbackPosition(key, item)).toBe(91);
    trackPlaybackPosition(key, video);
    video.dispatchEvent(new Event('timeupdate'));
    stopPlaybackPosition(video);
    expect(playbackPosition(key, item)).toBe(91);
  });

  it('本地旧记录按离开比例恢复,已播完与未知时长从头开始', () => {
    expect(playbackPosition(key, item)).toBe(120);
    expect(playbackPosition(key, { ...item, duration: -1 })).toBe(0);
    expect(playbackPosition(key, { ...item, leave_ratio: 1 })).toBe(0);
    trackPlaybackPosition(key, video);
    video.dispatchEvent(new Event('loadedmetadata'));
    video.currentTime = 300;
    Object.defineProperty(video, 'ended', { value: true });
    video.dispatchEvent(new Event('ended'));
    expect(playbackPosition(key, item)).toBe(0);
  });

  it('关注与本地身份互不覆盖,多视频按媒体序号分别记位置', () => {
    expect(playbackKey('follow', item)).not.toBe(key);
    expect(playbackKey('follow', item, 0)).not.toBe(playbackKey('follow', item, 1));
    expect(playbackKey('item', { ...item, location: 'online', follow_item_id: 42 }))
      .toBe(playbackKey('follow', { id: 42 }));
  });

  it('损坏的存储与存储拒绝不会阻断播放器', () => {
    localStorage.setItem(`peach.playback-position.v1:${key}`, 'broken');
    expect(playbackPosition(key, item)).toBe(120);
    vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('denied') });
    trackPlaybackPosition(key, video);
    video.dispatchEvent(new Event('loadedmetadata'));
    expect(() => stopPlaybackPosition(video)).not.toThrow();
    vi.restoreAllMocks();
  });
});

describe('关注视频关闭上报', () => {
  it('暂停状态的恢复定位不累计观看时间', () => {
    const fetch = vi.fn(); vi.stubGlobal('fetch', fetch);
    wireFollowTelemetry(item, video);
    video.currentTime = .7;
    video.ontimeupdate?.(new Event('timeupdate'));
    video.onpause?.(new Event('pause'));
    expect(fetch).not.toHaveBeenCalled();
  });

  it('换源停止定时器,继续播放仍能累计与上报', () => {
    vi.useFakeTimers();
    const calls: RequestInit[] = [];
    vi.stubGlobal('fetch', vi.fn(async (_url: string, init: RequestInit) => {
      calls.push(init);
      return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
    }));
    Object.defineProperty(video, 'paused', { value: false });
    wireFollowTelemetry(item, video);
    video.onplay?.(new Event('play'));
    video.currentTime = 1;
    video.ontimeupdate?.(new Event('timeupdate'));
    video.dispatchEvent(new Event('emptied'));
    const count = calls.length;
    vi.advanceTimersByTime(20_000);
    expect(calls).toHaveLength(count);
    video.currentTime = 42;
    video.onplay?.(new Event('play'));
    video.currentTime = 43;
    video.ontimeupdate?.(new Event('timeupdate'));
    video.onpause?.(new Event('pause'));
    expect(JSON.parse(String(calls.at(-1)?.body))).toMatchObject({ item: 12, position: 43, delta: 1 });
    expect(calls).toHaveLength(count + 1);
  });

  it('同步销毁保留最后一段并停止定时器与页面退出监听', () => {
    vi.useFakeTimers();
    const calls: RequestInit[] = [];
    vi.stubGlobal('fetch', vi.fn(async (_url: string, init: RequestInit) => {
      calls.push(init);
      return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
    }));
    const root = document.createElement('div'); root.append(video);
    Object.defineProperty(video, 'paused', { value: false });
    wireFollowTelemetry(item, video);
    video.onplay?.(new Event('play'));
    video.currentTime = 1;
    video.ontimeupdate?.(new Event('timeupdate'));
    video.currentTime = 42;
    video.dispatchEvent(new Event('seeking'));
    disposePlayer({ el: () => root, pause: () => {}, isDisposed: () => false, dispose: () => {} } as unknown as VjsPlayer);
    expect(JSON.parse(String(calls.at(-1)?.body))).toMatchObject({ item: 12, position: 42, delta: 1 });
    expect(calls.at(-1)?.keepalive).toBe(true);
    const count = calls.length;
    vi.advanceTimersByTime(60_000);
    window.dispatchEvent(new Event('pagehide'));
    expect(calls).toHaveLength(count);
  });
});