mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
444 lines
33 KiB
TypeScript
444 lines
33 KiB
TypeScript
import {savePlaybackPreferences} from '../src/features/media/services/playback-preferences.ts';
|
|
// @vitest-environment jsdom
|
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { MediaPlayer } from '../src/features/media/services/player.ts';
|
|
import type { MediaApi, Playback } from '../src/features/media/api/media-api.ts';
|
|
|
|
vi.mock('../src/features/media/components/web-video.ts',()=>({enhanceVideo:vi.fn(()=>({}))}));
|
|
const part=(id:string)=>({part:{id,assetId:id,title:id,start:0,end:100,available:true},title:id,video:false});
|
|
const session=(id:string):Playback=>({id,partId:id,streamUrl:'/stream/'+id,contentType:'audio/mpeg',expiresAt:Date.now()+6*60*60*1000,start:0,end:100,position:42,revision:1});
|
|
function setup(){const request=vi.fn().mockImplementation(async(_path,_method,body)=>({revision:body.revision+1,position:body.position,completed:body.completed}));const playback=vi.fn().mockImplementation(async(id:string)=>session(id));const api={preferenceScope:()=>'player-test',playback,request:(path:string,...args:unknown[])=>path.startsWith('assets/')?Promise.resolve({}):request(path,...args),streamUrl:(s:Playback)=>s.streamUrl} as unknown as MediaApi;return {player:new MediaPlayer(api),request,playback};}
|
|
function ready(player:MediaPlayer){player.audio.dispatchEvent(new Event('loadedmetadata'));}
|
|
describe('media playback lifecycle',()=>{
|
|
beforeEach(()=>{localStorage.clear();vi.spyOn(HTMLMediaElement.prototype,'play').mockImplementation(async function(this:HTMLMediaElement){Object.defineProperty(this,'paused',{configurable:true,value:false});});vi.spyOn(HTMLMediaElement.prototype,'pause').mockImplementation(function(this:HTMLMediaElement){Object.defineProperty(this,'paused',{configurable:true,value:true});});vi.spyOn(HTMLMediaElement.prototype,'load').mockImplementation(()=>{});});
|
|
afterEach(()=>{vi.restoreAllMocks();vi.useRealTimers();});
|
|
it('times out after metadata and retries without duplicating the session or queue',async()=>{
|
|
vi.useFakeTimers();
|
|
const {player,playback}=setup();
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockImplementation(()=>new Promise(()=>{}));
|
|
await player.play([part('slow')]);ready(player);
|
|
await vi.advanceTimersByTimeAsync(20001);
|
|
expect(player.error).toContain('超时');expect(player.loadingStatus).toBe('');
|
|
player.retryPlayback();await vi.advanceTimersByTimeAsync(0);
|
|
expect(player.loadingStatus).toContain('连接');expect(playback).toHaveBeenCalledTimes(1);
|
|
player.audio.dispatchEvent(new Event('playing'));
|
|
await vi.advanceTimersByTimeAsync(20001);
|
|
expect(player.error).toBe('');expect(player.loadingStatus).toBe('');await player.stop();
|
|
});
|
|
it('opens video controls and releases the action while playback is still buffering',async()=>{
|
|
const {player}=setup();let started=false;
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockImplementation(()=>{started=true;return new Promise(()=>{});});
|
|
const opened=vi.fn();player.addEventListener('open-controls',opened);
|
|
let returned=false;void player.play([{...part('film'),video:true}]).then(()=>{returned=true;});
|
|
await vi.waitFor(()=>expect(started).toBe(true));
|
|
expect(opened).toHaveBeenCalledTimes(1);expect(returned).toBe(true);
|
|
await player.stop();
|
|
});
|
|
it('falls back once in auto mode without creating a second playback session',async()=>{
|
|
const {player,playback}=setup();playback.mockResolvedValue({...session('film'),playbackMode:'auto'});
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new DOMException('source failed','NotSupportedError')).mockRejectedValueOnce(new DOMException('CORS failed','NotSupportedError')).mockResolvedValueOnce();
|
|
await player.play([{...part('film'),video:true}]);
|
|
await vi.waitFor(()=>expect(player.video.src).toContain('proxy=1'));
|
|
expect(playback).toHaveBeenCalledTimes(1);expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(3);
|
|
expect(player.video.hasAttribute('crossorigin')).toBe(false);
|
|
player.video.dispatchEvent(new Event('error'));
|
|
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(3);expect(player.error).not.toBe('');await player.stop();
|
|
});
|
|
it('does not proxy on autoplay rejection, explicit direct mode or a paused restore',async()=>{
|
|
for(const mode of ['autoplay','direct','restore'] as const){
|
|
const {player,playback}=setup();playback.mockResolvedValue({...session('film'),playbackMode:mode==='direct'?'direct':'auto'});
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValue(new DOMException('failed',mode==='autoplay'?'NotAllowedError':'NotSupportedError'));
|
|
await player.play([{...part('film'),video:true}],0,false,{autoplay:mode!=='restore'});
|
|
if(mode==='restore')player.video.dispatchEvent(new Event('error'));
|
|
await Promise.resolve();expect(player.video.src).not.toContain('proxy=1');await player.stop();
|
|
}
|
|
});
|
|
it('re-enables automatic recovery after a paused restore is resumed by the user',async()=>{
|
|
const {player,playback}=setup();playback.mockResolvedValue({...session('film'),playbackMode:'auto'});
|
|
await player.play([{...part('film'),video:true}],0,false,{autoplay:false});
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValue(new DOMException('source blocked','NotSupportedError'));
|
|
player.toggle();
|
|
await vi.waitFor(()=>expect(player.video.src).toContain('proxy=1'));
|
|
await player.stop();
|
|
});
|
|
it('tries anonymous CORS after opaque direct playback fails before using proxy',async()=>{
|
|
const {player,playback}=setup();playback.mockResolvedValue({...session('film'),playbackMode:'auto'});
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new DOMException('opaque source blocked','NotSupportedError')).mockResolvedValueOnce();
|
|
await player.play([{...part('film'),video:true}]);
|
|
await vi.waitFor(()=>expect(player.video.crossOrigin).toBe('anonymous'));
|
|
expect(player.video.src).not.toContain('proxy=1');expect(playback).toHaveBeenCalledTimes(1);await player.stop();
|
|
});
|
|
it('allows an explicit proxy retry in direct mode without another session',async()=>{
|
|
const {player,playback}=setup();playback.mockResolvedValue({...session('film'),playbackMode:'direct'});
|
|
await player.play([{...part('film'),video:true}],0,false,{autoplay:false});
|
|
expect(player.canRetryViaProxy).toBe(true);player.retryViaProxy();
|
|
expect(player.video.src).toContain('proxy=1');expect(player.canRetryViaProxy).toBe(false);
|
|
expect(playback).toHaveBeenCalledTimes(1);await player.stop();
|
|
});
|
|
it('closes the mini player, saves progress and releases its media and queue',async()=>{
|
|
const {player,request}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
player.audio.currentTime=57;
|
|
expect(player.element.querySelector('.media-mini-toggle svg')).not.toBeNull();
|
|
const close=player.element.querySelector<HTMLButtonElement>('[aria-label="关闭播放器"]')!;
|
|
expect(close.querySelector('svg')).not.toBeNull();close.click();
|
|
await vi.waitFor(()=>expect(player.active).toBe(false));
|
|
expect(request.mock.calls.some(call=>call[2].position===57)).toBe(true);
|
|
expect(player.audio.hasAttribute('src')).toBe(false);
|
|
expect(player.currentPlaylist).toHaveLength(0);
|
|
expect(player.element.hidden).toBe(true);
|
|
});
|
|
it('restores a resource at server progress without autoplay and ignores cancelled restores',async()=>{
|
|
const {player,playback}=setup();await player.play([part('a')],0,false,{autoplay:false,openControls:false});ready(player);
|
|
expect(player.audio.currentTime).toBe(42);expect(player.paused).toBe(true);expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
|
const controller=new AbortController();controller.abort();await player.play([part('b')],0,false,{autoplay:false,signal:controller.signal});
|
|
expect(playback).toHaveBeenCalledTimes(1);expect(player.currentPartId).toBe('a');await player.stop();
|
|
});
|
|
it('keeps the video attached while opening controls and applies volume and seek to the active video',async()=>{
|
|
const {player,playback}=setup();await player.play([{...part('film'),video:true}]);
|
|
player.video.dispatchEvent(new Event('loadedmetadata'));
|
|
const parent=player.video.parentElement,source=player.video.src,host=document.createElement('div'),volumeHost=document.createElement('div');
|
|
const restore=player.mountVideoTimeline(host),restoreVolume=player.mountVolumeControl(volumeHost);
|
|
const seek=host.querySelector<HTMLInputElement>('[aria-label="视频播放进度"]')!;
|
|
seek.value='65';seek.dispatchEvent(new Event('input'));seek.dispatchEvent(new Event('change'));
|
|
expect(player.video.currentTime).toBe(65);expect(player.video.controls).toBe(false);
|
|
const volume=volumeHost.querySelector<HTMLInputElement>('input')!;
|
|
volume.value='0.35';volume.dispatchEvent(new Event('input'));
|
|
expect(player.video.volume).toBe(0.35);expect(player.audio.volume).toBe(1);
|
|
player.video.volume=0.6;player.video.dispatchEvent(new Event('volumechange'));expect(volume.value).toBe('0.6');
|
|
player.video.dispatchEvent(new Event('play'));expect(player.video.controls).toBe(false);
|
|
restoreVolume();restore();
|
|
expect(player.video.parentElement).toBe(parent);expect(player.video.src).toBe(source);expect(player.video.currentTime).toBe(65);expect(player.video.controls).toBe(false);expect(playback).toHaveBeenCalledTimes(1);
|
|
await player.stop();
|
|
});
|
|
it('moves the live audio timeline into the player page and restores it without restarting',async()=>{
|
|
const {player,playback}=setup();await player.play([part('a')]);ready(player);
|
|
const host=document.createElement('div');
|
|
const restore=player.mountAudioControls(host);
|
|
const slider=host.querySelector<HTMLInputElement>('input[aria-label="当前章节播放位置"]')!;
|
|
expect(slider).not.toBeNull();
|
|
slider.value='63';slider.dispatchEvent(new Event('input'));slider.dispatchEvent(new Event('change'));
|
|
expect(player.audio.currentTime).toBe(63);
|
|
restore();expect(player.element.contains(slider)).toBe(true);
|
|
expect(host.childElementCount).toBe(0);expect(playback).toHaveBeenCalledTimes(1);
|
|
await player.stop();
|
|
});
|
|
it('disables playback controls after terminal renewal failure and restores them for a new session',async()=>{
|
|
vi.useFakeTimers();
|
|
const {player,playback,request}=setup();
|
|
playback.mockResolvedValueOnce({...session('a'),expiresAt:Date.now()+121000});
|
|
request.mockRejectedValue(Object.assign(new Error('access revoked'),{status:403}));
|
|
await player.play([part('a')]);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(player.playbackUnavailable).toBe(true);
|
|
expect(player.error).toContain('重新选择播放');
|
|
expect(player.element.querySelector<HTMLButtonElement>('.media-mini-toggle')!.disabled).toBe(true);
|
|
expect(player.audio.controls).toBe(false);
|
|
const attempts=vi.mocked(HTMLMediaElement.prototype.play).mock.calls.length;
|
|
player.toggle();await Promise.resolve();
|
|
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(attempts);
|
|
await player.play([part('b')]);
|
|
expect(player.playbackUnavailable).toBe(false);
|
|
expect(player.element.querySelector<HTMLButtonElement>('.media-mini-toggle')!.disabled).toBe(false);
|
|
expect(player.audio.controls).toBe(false);
|
|
await player.stop();
|
|
});
|
|
it('does not request media, play or start timers during an idle reading session',async()=>{
|
|
vi.useFakeTimers();
|
|
const request=vi.fn(),playback=vi.fn();
|
|
const player=new MediaPlayer({preferenceScope:()=>'player-test',request,playback} as unknown as MediaApi);
|
|
player.setVisible(false);player.reconnectNative();
|
|
await player.flush();await vi.advanceTimersByTimeAsync(60000);
|
|
expect(request).not.toHaveBeenCalled();expect(playback).not.toHaveBeenCalled();
|
|
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
|
expect(vi.getTimerCount()).toBe(0);expect(player.element.hidden).toBe(true);
|
|
expect(player.audio.hasAttribute('src')).toBe(false);expect(player.video.hasAttribute('src')).toBe(false);
|
|
await player.stop();
|
|
});
|
|
it('jumps to an arbitrary playlist entry only after saving unfinished progress',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b'),part('c'),part('d')]);ready(player);
|
|
player.audio.currentTime=23;await player.selectPlaylist(3);
|
|
expect(playback.mock.calls.map(call=>call[0])).toEqual(['a','d']);
|
|
expect(request.mock.calls.every(call=>call[2].completed===false)).toBe(true);
|
|
expect(player.currentPlaylist[3]?.current).toBe(true);
|
|
await player.selectPlaylist(-1);await player.selectPlaylist(4);await player.selectPlaylist(1.5);await player.selectPlaylist(3);
|
|
expect(playback).toHaveBeenCalledTimes(2);
|
|
ready(player);request.mockRejectedValue(new Error('offline'));await player.selectPlaylist(1);
|
|
expect(playback).toHaveBeenCalledTimes(2);expect(player.currentPlaylist[3]?.current).toBe(true);await player.stop();
|
|
});
|
|
it('advances an embedded chapter after the asynchronous boundary pause and its save',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
const completions:Array<()=>void>=[];
|
|
request.mockImplementation((_path,_method,body)=>new Promise(resolve=>completions.push(()=>resolve({revision:body.revision+1}))));
|
|
player.audio.currentTime=100;player.audio.dispatchEvent(new Event('timeupdate'));
|
|
await vi.waitFor(()=>expect(completions).toHaveLength(1));
|
|
player.audio.dispatchEvent(new Event('pause'));completions[0]!();
|
|
await vi.waitFor(()=>expect(completions).toHaveLength(2));
|
|
expect(playback).toHaveBeenCalledTimes(1);
|
|
request.mockImplementation(async(_path,_method,body)=>({revision:body.revision+1}));completions[1]!();
|
|
await vi.waitFor(()=>expect(playback).toHaveBeenCalledTimes(2));await player.stop();
|
|
});
|
|
it('shows chapter-relative time in the mini player and clips progress to its boundaries',async()=>{
|
|
const {player,playback}=setup();playback.mockResolvedValue({...session('chapter'),start:3600,end:3720,position:3630});
|
|
await player.play([part('chapter')]);ready(player);
|
|
expect(player.element.querySelector('.media-mini-time')!.textContent).toBe('00:30 / 02:00');
|
|
const seek=player.element.querySelector<HTMLInputElement>('[aria-label="当前章节播放位置"]')!;
|
|
expect(seek.disabled).toBe(false);expect(seek.max).toBe('120');expect(seek.value).toBe('30');
|
|
seek.value='90';seek.dispatchEvent(new Event('change'));expect(player.audio.currentTime).toBe(3690);
|
|
const volume=player.element.querySelector<HTMLInputElement>('[aria-label="音量"]')!;
|
|
volume.value='0.35';volume.dispatchEvent(new Event('input'));expect(player.audio.volume).toBe(0.35);
|
|
expect(player.audio.controls).toBe(false);
|
|
player.audio.currentTime=3660;player.audio.dispatchEvent(new Event('timeupdate'));
|
|
const progress=player.element.querySelector('progress')!;expect(progress.max).toBe(120);expect(progress.value).toBe(60);
|
|
player.audio.currentTime=3500;player.audio.dispatchEvent(new Event('timeupdate'));expect(progress.value).toBe(0);
|
|
await player.stop();
|
|
});
|
|
it('edits the live playlist without recreating the current session or losing duplicate occurrences',async()=>{
|
|
const {player,playback}=setup();await player.play([part('a'),{...part('a'),title:'repeat'},part('b')],1);ready(player);
|
|
player.audio.currentTime=18;
|
|
player.editPlaylist(1,0);expect(player.currentPlaylist).toHaveLength(3);
|
|
player.editPlaylist(1,-1);expect(player.currentPlaylist[0]).toMatchObject({title:'repeat',current:true});
|
|
player.editPlaylist(1,0);expect(player.currentPlaylist.map(entry=>entry.title)).toEqual(['repeat','b']);
|
|
expect(player.audio.currentTime).toBe(18);expect(playback).toHaveBeenCalledTimes(1);
|
|
await player.move(1);expect(playback.mock.calls.map(call=>call[0])).toEqual(['a','b']);await player.stop();
|
|
});
|
|
it('keeps a dragged seek value during playback updates and drops an old chapter gesture',async()=>{
|
|
const {player}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
const seek=player.element.querySelector<HTMLInputElement>('[aria-label="当前章节播放位置"]')!;
|
|
seek.value='70';seek.dispatchEvent(new Event('input'));
|
|
player.audio.currentTime=43;player.audio.dispatchEvent(new Event('timeupdate'));
|
|
expect(seek.value).toBe('70');
|
|
seek.dispatchEvent(new Event('change'));expect(player.audio.currentTime).toBe(70);
|
|
seek.value='80';seek.dispatchEvent(new Event('input'));
|
|
await player.move(1);ready(player);
|
|
seek.dispatchEvent(new Event('change'));expect(player.audio.currentTime).toBe(42);
|
|
seek.value='90';seek.dispatchEvent(new Event('input'));seek.dispatchEvent(new Event('pointercancel'));
|
|
expect(seek.value).toBe('42');
|
|
player.audio.currentTime=44;player.audio.dispatchEvent(new Event('timeupdate'));expect(seek.value).toBe('44');
|
|
await player.stop();
|
|
});
|
|
it('manual next and previous preserve unfinished progress and queue boundaries',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b'),part('c')]);ready(player);
|
|
expect(player.canPrevious).toBe(false);expect(player.canNext).toBe(true);
|
|
player.audio.currentTime=18;await player.move(1);
|
|
expect(playback.mock.calls.map(call=>call[0])).toEqual(['a','b']);
|
|
expect(request.mock.calls.every(call=>call[2].completed===false)).toBe(true);
|
|
expect(player.canPrevious).toBe(true);ready(player);await player.move(-1);
|
|
expect(playback.mock.calls.map(call=>call[0])).toEqual(['a','b','a']);
|
|
await player.move(-1);expect(playback).toHaveBeenCalledTimes(3);await player.stop();
|
|
expect(player.canNext).toBe(false);
|
|
});
|
|
it('does not switch past unsaved progress and retries after persistence recovers',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
request.mockRejectedValue(new Error('offline'));await player.move(1);
|
|
expect(playback).toHaveBeenCalledTimes(1);expect(player.error).toContain('尚未保存');
|
|
request.mockImplementation(async(_path,_method,body)=>({revision:body.revision+1}));
|
|
await player.move(1);expect(playback).toHaveBeenCalledTimes(2);await player.stop();
|
|
});
|
|
it('restores the queue position after a session request fails',async()=>{
|
|
const {player,playback}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
playback.mockRejectedValueOnce(new Error('temporarily unavailable'));await player.move(1);
|
|
expect(player.canNext).toBe(true);expect(player.canPrevious).toBe(false);
|
|
await player.move(1);expect(playback.mock.calls.map(call=>call[0])).toEqual(['a','b','b']);await player.stop();
|
|
});
|
|
it('waits for the save appended by the asynchronous browser pause event',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
const completions:Array<()=>void>=[];
|
|
request.mockImplementation((_path,_method,body)=>new Promise(resolve=>completions.push(()=>resolve({revision:body.revision+1}))));
|
|
const moving=player.move(1);await vi.waitFor(()=>expect(completions).toHaveLength(1));
|
|
player.audio.dispatchEvent(new Event('pause'));
|
|
completions[0]!();await vi.waitFor(()=>expect(completions).toHaveLength(2));
|
|
expect(playback).toHaveBeenCalledTimes(1);
|
|
request.mockImplementation(async(_path,_method,body)=>({revision:body.revision+1}));
|
|
completions[1]!();await moving;
|
|
expect(playback).toHaveBeenCalledTimes(2);expect(player.error).toBe('');await player.stop();
|
|
});
|
|
it('keeps unknown container support advisory and reports actual unsupported playback',async()=>{
|
|
vi.spyOn(HTMLMediaElement.prototype,'canPlayType').mockReturnValue('');
|
|
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new DOMException('unsupported','NotSupportedError'));
|
|
const {player}=setup();await player.play([part('a')]);
|
|
expect(player.support).toContain('仍可尝试');
|
|
expect(player.error).toContain('无法直放');
|
|
expect(player.audio.hasAttribute('src')).toBe(true);
|
|
player.toggle();await vi.waitFor(()=>expect(player.error).toBe(''));
|
|
await player.stop();
|
|
});
|
|
it('does not overwrite saved progress before media metadata is loaded',async()=>{
|
|
const {player,request}=setup();await player.play([part('a')]);await player.flush();expect(request).not.toHaveBeenCalled();ready(player);expect(player.audio.currentTime).toBe(42);player.audio.currentTime=16;await player.flush();expect(request.mock.calls[0]![2]).toMatchObject({position:16,sequence:0,revision:1});await player.stop();
|
|
});
|
|
it('serializes updates using the latest revision and permits a seek backwards',async()=>{
|
|
const {player,request}=setup();await player.play([part('a')]);ready(player);player.audio.currentTime=80;const a=player.flush();player.audio.currentTime=20;const b=player.flush();await Promise.all([a,b]);expect(request.mock.calls.map(call=>call[2])).toEqual([{sequence:0,revision:1,position:80,completed:false},{sequence:1,revision:2,position:20,completed:false}]);await player.stop();
|
|
});
|
|
it('retries the exact uncertain request before sending a newer position',async()=>{
|
|
const {player,request}=setup();await player.play([part('a')]);ready(player);
|
|
request.mockRejectedValueOnce(new Error('response lost'));
|
|
player.audio.currentTime=32;await player.flush();
|
|
player.audio.currentTime=40;await player.flush();
|
|
expect(request.mock.calls[1]![2]).toEqual(request.mock.calls[0]![2]);
|
|
expect(request.mock.calls[2]![2]).toMatchObject({sequence:1,revision:2,position:40});
|
|
await player.stop();
|
|
});
|
|
it('stops immediately even if the server never completes the progress save',async()=>{
|
|
const {player,request}=setup();await player.play([part('a')]);ready(player);let done!:(v:unknown)=>void;request.mockImplementation(()=>new Promise(resolve=>{done=resolve;}));const stop=player.stop();expect(player.active).toBe(false);expect(player.audio.hasAttribute('src')).toBe(false);expect(player.element.hidden).toBe(true);await Promise.resolve();done({revision:2});await stop;
|
|
});
|
|
it('ignores a late playback response after stopping',async()=>{
|
|
const {player,playback}=setup();let resolve!:(v:Playback)=>void;playback.mockImplementation(()=>new Promise(r=>{resolve=r;}));const play=player.play([part('a')]);await Promise.resolve();await player.stop();resolve(session('a'));await play;expect(player.active).toBe(false);expect(player.audio.hasAttribute('src')).toBe(false);
|
|
});
|
|
it('does not restart the next track after stop while completion is being saved',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b')]);ready(player);let resolve!:(v:unknown)=>void;request.mockImplementationOnce(()=>new Promise(r=>{resolve=r;}));player.audio.dispatchEvent(new Event('ended'));await Promise.resolve();const stop=player.stop();resolve({revision:2});await stop;await Promise.resolve();expect(playback).toHaveBeenCalledTimes(1);expect(player.active).toBe(false);
|
|
});
|
|
it('sleep timer pauses playback',async()=>{
|
|
vi.useFakeTimers();const {player}=setup();await player.play([part('a')]);ready(player);player.sleep(15);await vi.advanceTimersByTimeAsync(15*60000);expect(player.paused).toBe(true);await player.stop();
|
|
});
|
|
it('waits for acknowledged completion before advancing and retries when continued',async()=>{
|
|
const {player,request,playback}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
request.mockRejectedValueOnce(new Error('offline'));
|
|
player.audio.currentTime=100;player.audio.dispatchEvent(new Event('ended'));
|
|
await vi.waitFor(()=>expect(player.error).toContain('尚未保存'));
|
|
expect(playback).toHaveBeenCalledTimes(1);
|
|
// An ended HTML media element is paused; model that state in jsdom.
|
|
Object.defineProperty(player.audio,'paused',{configurable:true,value:true});
|
|
player.toggle();await vi.waitFor(()=>expect(playback).toHaveBeenCalledTimes(2));
|
|
expect(request.mock.calls[1]![2]).toEqual(request.mock.calls[0]![2]);
|
|
await player.stop();
|
|
});
|
|
it('times out an unresponsive progress transport and retries its original payload',async()=>{
|
|
vi.useFakeTimers();const {player,request}=setup();await player.play([part('a')]);ready(player);
|
|
let late!:(value:unknown)=>void;
|
|
request.mockImplementationOnce(()=>new Promise(resolve=>{late=resolve;}));
|
|
player.audio.currentTime=12;const first=player.flush();
|
|
await vi.advanceTimersByTimeAsync(15000);await first;
|
|
expect(player.error).toContain('超时');
|
|
expect((request.mock.calls[0]![3] as AbortSignal).aborted).toBe(true);
|
|
player.audio.currentTime=20;await player.flush();
|
|
expect(request.mock.calls[1]![2]).toEqual(request.mock.calls[0]![2]);
|
|
expect(request.mock.calls[2]![2]).toMatchObject({position:20,revision:2});
|
|
late({revision:999});await Promise.resolve();
|
|
player.audio.currentTime=21;await player.flush();
|
|
expect(request.mock.calls[3]![2]).toMatchObject({position:21,revision:3});
|
|
await player.stop();
|
|
});
|
|
it('tracks remaining sleep time, ignores invalid changes, and clears it on stop',async()=>{
|
|
vi.useFakeTimers();const {player}=setup();await player.play([part('a')]);ready(player);
|
|
player.sleep(15);const deadline=player.sleepAt;expect(player.sleepRemainingMinutes).toBe(15);
|
|
player.sleep(Number.NaN);expect(player.sleepAt).toBe(deadline);
|
|
await vi.advanceTimersByTimeAsync(60000);expect(player.sleepRemainingMinutes).toBe(14);
|
|
player.sleep(0);expect(player.sleepAt).toBe(0);
|
|
player.sleep(30);await player.stop();expect(player.sleepAt).toBe(0);
|
|
});
|
|
it('retains chosen speed when switching media elements and tracks native browser controls',async()=>{
|
|
const {player}=setup();await player.play([part('a')]);ready(player);
|
|
player.speed(1.75);expect(player.playbackRate).toBe(1.75);
|
|
await player.play([{...part('video'),video:true}]);
|
|
expect(player.video.playbackRate).toBe(1.75);
|
|
player.video.playbackRate=1.25;player.video.dispatchEvent(new Event('ratechange'));
|
|
expect(player.playbackRate).toBe(1.25);
|
|
player.speed(Number.NaN);expect(player.playbackRate).toBe(1.25);
|
|
await player.stop();
|
|
});
|
|
it('bounds relative seeking to the current chapter and keeps paused state',async()=>{
|
|
const {player,playback}=setup();
|
|
playback.mockResolvedValue({...session('chapter'),start:30,end:60,position:40});
|
|
await player.play([part('chapter')]);ready(player);player.pause();
|
|
player.skip(-15);expect(player.audio.currentTime).toBe(30);
|
|
player.skip(300);expect(player.audio.currentTime).toBe(60);
|
|
player.seek(Number.NaN);expect(player.audio.currentTime).toBe(60);
|
|
expect(player.paused).toBe(true);await player.stop();
|
|
});
|
|
it('keeps playback controls hidden when a late response arrives in the reading area',async()=>{
|
|
const {player,playback}=setup();
|
|
let resolve!:(value:Playback)=>void;
|
|
playback.mockImplementation(()=>new Promise(done=>{resolve=done;}));
|
|
const playing=player.play([part('a')]);
|
|
await Promise.resolve();
|
|
player.setVisible(false);
|
|
resolve(session('a'));
|
|
await playing;
|
|
expect(player.active).toBe(true);
|
|
expect(player.element.hidden).toBe(true);
|
|
expect(player.paused).toBe(false);
|
|
player.setVisible(true);
|
|
expect(player.element.hidden).toBe(false);
|
|
await player.stop();
|
|
});
|
|
it('renews before resuming without reloading or losing speed and position',async()=>{
|
|
const {player,request,playback}=setup();
|
|
const plan=session('a');plan.expiresAt=Date.now()-1;
|
|
playback.mockResolvedValue(plan);
|
|
request.mockImplementation(async(path,_method,body)=>path.endsWith('/renew')?{id:'a',expiresAt:Date.now()+6*60*60*1000}:{revision:body.revision+1});
|
|
await player.play([part('a')]);ready(player);player.audio.currentTime=25;player.speed(1.5);player.pause();
|
|
player.toggle();
|
|
await vi.waitFor(()=>expect(player.paused).toBe(false));
|
|
expect(request.mock.calls.filter(call=>call[0].endsWith('/renew'))).toHaveLength(1);
|
|
expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
|
|
expect(player.audio.currentTime).toBe(25);expect(player.audio.playbackRate).toBe(1.5);
|
|
await player.stop();
|
|
});
|
|
it('does not resume after a pause command while renewal is pending',async()=>{
|
|
const {player,request,playback}=setup();
|
|
const plan=session('a');plan.expiresAt=Date.now()-1;playback.mockResolvedValue(plan);
|
|
let finish!:(value:unknown)=>void;
|
|
request.mockImplementation(async(path,_method,body)=>path.endsWith('/renew')?new Promise(resolve=>{finish=resolve;}):{revision:body.revision+1});
|
|
await player.play([part('a')]);ready(player);player.pause();player.toggle();
|
|
await vi.waitFor(()=>expect(finish).toBeTypeOf('function'));
|
|
player.pause();finish({id:'a',expiresAt:Date.now()+6*60*60*1000});
|
|
await Promise.resolve();await Promise.resolve();await Promise.resolve();
|
|
expect(player.paused).toBe(true);
|
|
await player.stop();
|
|
});
|
|
it('loads the account default speed and preserves an explicit speed while advancing',async()=>{
|
|
savePlaybackPreferences('player-test',{defaultRate:1.5,continuous:true});
|
|
const {player}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
expect(player.playbackRate).toBe(1.5);player.speed(1.75);await player.move(1);ready(player);
|
|
expect(player.playbackRate).toBe(1.75);await player.stop();
|
|
});
|
|
it('saves completion without starting the next entry when continuous playback is disabled',async()=>{
|
|
savePlaybackPreferences('player-test',{defaultRate:1,continuous:false});
|
|
const {player,playback,request}=setup();await player.play([part('a'),part('b')]);ready(player);
|
|
player.audio.currentTime=100;player.audio.dispatchEvent(new Event('ended'));
|
|
await vi.waitFor(()=>expect(request.mock.calls.some(call=>call[2]?.completed)).toBe(true));
|
|
expect(playback).toHaveBeenCalledTimes(1);expect(player.currentPartId).toBe('a');
|
|
await player.move(1);expect(player.currentPartId).toBe('b');await player.stop();
|
|
});
|
|
|
|
});
|
|
|
|
describe('queue play modes and stopping after the current entry',()=>{
|
|
beforeEach(()=>{localStorage.clear();vi.spyOn(HTMLMediaElement.prototype,'play').mockImplementation(async function(this:HTMLMediaElement){Object.defineProperty(this,'paused',{configurable:true,value:false});});vi.spyOn(HTMLMediaElement.prototype,'pause').mockImplementation(function(this:HTMLMediaElement){Object.defineProperty(this,'paused',{configurable:true,value:true});});vi.spyOn(HTMLMediaElement.prototype,'load').mockImplementation(()=>{});});
|
|
afterEach(()=>{vi.restoreAllMocks();vi.useRealTimers();});
|
|
const finish=(player:MediaPlayer)=>{ready(player);player.audio.currentTime=100;player.audio.dispatchEvent(new Event('ended'));};
|
|
it('repeats the same entry on single-repeat even when continuous playback is off',async()=>{
|
|
savePlaybackPreferences('player-test',{defaultRate:1,continuous:false});
|
|
const {player,playback}=setup();await player.play([part('a'),part('b')]);expect(player.playMode).toBe('sequential');
|
|
player.setPlayMode('repeat-one');finish(player);
|
|
await vi.waitFor(()=>expect(playback).toHaveBeenCalledTimes(2));
|
|
expect(playback).toHaveBeenLastCalledWith('a');expect(player.currentPartId).toBe('a');await player.stop();
|
|
});
|
|
it('shuffles without repeating until every entry has played, and steps back through what was played',async()=>{
|
|
vi.spyOn(Math,'random').mockReturnValue(0);
|
|
const {player,playback}=setup();await player.play([part('a'),part('b'),part('c')]);
|
|
player.setPlayMode('shuffle');expect(player.canPrevious).toBe(false);expect(player.canNext).toBe(true);
|
|
finish(player);await vi.waitFor(()=>expect(playback).toHaveBeenLastCalledWith('b'));
|
|
expect(player.canPrevious).toBe(true);
|
|
finish(player);await vi.waitFor(()=>expect(playback).toHaveBeenLastCalledWith('c'));
|
|
ready(player);await player.move(-1);expect(playback).toHaveBeenLastCalledWith('b');expect(player.currentPartId).toBe('b');
|
|
ready(player);await player.move(-1);expect(playback).toHaveBeenLastCalledWith('a');expect(player.canPrevious).toBe(false);
|
|
await player.stop();
|
|
});
|
|
it('keeps manual next and previous in queue order outside shuffle, and ignores a mode the web player cannot honour',async()=>{
|
|
const {player}=setup();expect(()=>player.setPlayMode('shuffle')).not.toThrow();expect(player.playMode).toBe('sequential');
|
|
await player.play([part('a'),part('b')]);player.setPlayMode('repeat-one');expect(player.canNext).toBe(true);expect(player.canPrevious).toBe(false);
|
|
player.setPlayMode('bogus' as never);expect(player.playMode).toBe('repeat-one');await player.stop();
|
|
});
|
|
it('pauses once at the end of the current entry, then continues normally',async()=>{
|
|
const {player,playback}=setup();await player.play([part('a'),part('b')]);
|
|
player.setSleepAfterPart(true);expect(player.sleepAfterPart).toBe(true);
|
|
finish(player);await vi.waitFor(()=>expect(player.sleepAfterPart).toBe(false));
|
|
expect(playback).toHaveBeenCalledTimes(1);expect(player.currentPartId).toBe('a');await player.stop();
|
|
});
|
|
it('replaces stop-after-entry with a minute timer, and the other way round',async()=>{
|
|
const {player}=setup();await player.play([part('a')]);
|
|
player.setSleepAfterPart(true);player.sleep(15);expect(player.sleepAfterPart).toBe(false);expect(player.sleepAt).toBeGreaterThan(0);
|
|
player.setSleepAfterPart(true);expect(player.sleepAt).toBe(0);expect(player.sleepAfterPart).toBe(true);
|
|
await player.stop();expect(player.sleepAfterPart).toBe(false);
|
|
});
|
|
}); |