test: cover player sync and lifecycle
This commit is contained in:
1 parent
6d6163a9de
commit
8b13436ba7
1 file changed
+483
@@ -0,0 +1,483 @@
|
||||
import type { NowPlaying } from '@/core/realtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const startedAtMs = 100_000
|
||||
const durationMs = 60_000
|
||||
|
||||
class FakeAudio extends EventTarget {
|
||||
src = ''
|
||||
loads = 0
|
||||
plays = 0
|
||||
currentTime = 0
|
||||
paused = true
|
||||
volume = 1
|
||||
muted = false
|
||||
playbackRate = 2
|
||||
readyState = 4
|
||||
seeking = false
|
||||
playError: Error | null = null
|
||||
loadError: Error | null = null
|
||||
|
||||
load(): void {
|
||||
const error = this.loadError
|
||||
this.loadError = null
|
||||
if (error)
|
||||
throw error
|
||||
this.loads += 1
|
||||
}
|
||||
|
||||
play(): Promise<void> {
|
||||
this.plays += 1
|
||||
if (this.playError)
|
||||
return Promise.reject(this.playError)
|
||||
this.paused = false
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.paused = true
|
||||
}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
if (name === 'src')
|
||||
this.src = ''
|
||||
}
|
||||
|
||||
emit(type: string): void {
|
||||
this.dispatchEvent(new Event(type))
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClock() {
|
||||
return Object.assign(new EventTarget(), {
|
||||
offsetMs: 0 as number | null,
|
||||
nowMs: startedAtMs,
|
||||
serverNow() {
|
||||
return this.offsetMs === null ? null : this.nowMs
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function fakeRoom() {
|
||||
return Object.assign(new EventTarget(), {
|
||||
state: { room: null as { nowPlaying: NowPlaying | null } | null },
|
||||
sync: vi.fn(() => Promise.resolve()),
|
||||
})
|
||||
}
|
||||
|
||||
function song(id: string, patch: Partial<NowPlaying> = {}): NowPlaying {
|
||||
return {
|
||||
entry: { id, meta: { title: id, artist: '', album: '' } },
|
||||
startedAtMs,
|
||||
durationMs,
|
||||
mediaUrl: `/media/netease/${id}`,
|
||||
gain: 1,
|
||||
...patch,
|
||||
} as unknown as NowPlaying
|
||||
}
|
||||
|
||||
let audio: FakeAudio
|
||||
let clock: ReturnType<typeof fakeClock>
|
||||
let room: ReturnType<typeof fakeRoom>
|
||||
let frames: FrameRequestCallback[]
|
||||
|
||||
function setPlaying(nowPlaying: NowPlaying | null): void {
|
||||
room.state = { room: { nowPlaying } }
|
||||
room.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function setClock(offsetMs: number | null, nowMs = clock.nowMs): void {
|
||||
clock.offsetMs = offsetMs
|
||||
clock.nowMs = nowMs
|
||||
clock.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function nextFrame(): void {
|
||||
const frame = frames.shift()
|
||||
if (!frame)
|
||||
throw new Error('没有等待中的动画帧')
|
||||
frame(0)
|
||||
}
|
||||
|
||||
async function attached() {
|
||||
const { playerStore } = await import('./player-store')
|
||||
playerStore.attach(audio as unknown as HTMLAudioElement, () => {})
|
||||
return playerStore
|
||||
}
|
||||
|
||||
const flush = () => vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
audio = new FakeAudio()
|
||||
clock = fakeClock()
|
||||
room = fakeRoom()
|
||||
frames = []
|
||||
vi.doMock('@/core/clock', () => ({ clock }))
|
||||
vi.doMock('@/core/room', () => ({ roomStore: room }))
|
||||
vi.stubGlobal('document', { title: '' })
|
||||
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {} })
|
||||
vi.stubGlobal('requestAnimationFrame', (frame: FrameRequestCallback) => frames.push(frame))
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {
|
||||
frames = []
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const { playerStore } = await import('./player-store')
|
||||
playerStore.detach()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('开始播放', () => {
|
||||
it('加载好之后从应在的位置开始', async () => {
|
||||
await attached()
|
||||
clock.nowMs = startedAtMs + 5000
|
||||
setPlaying(song('a'))
|
||||
expect(audio.src).toBe('/media/netease/a')
|
||||
expect(audio.loads).toBe(1)
|
||||
expect(audio.plays).toBe(0)
|
||||
|
||||
audio.emit('canplay')
|
||||
expect(audio.currentTime).toBe(5)
|
||||
expect(audio.playbackRate).toBe(1)
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
|
||||
it('定位之后再次触发 canplay 不重新开始', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
audio.emit('canplay')
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
|
||||
it('时钟校准之前等待,校准之后开始', async () => {
|
||||
clock.offsetMs = null
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
expect(audio.plays).toBe(0)
|
||||
|
||||
setClock(0, startedAtMs + 2000)
|
||||
expect(audio.currentTime).toBe(2)
|
||||
expect(audio.plays).toBe(1)
|
||||
|
||||
setClock(5)
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
|
||||
it('时钟之后的变化不重新开始', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
setClock(10, startedAtMs + 2000)
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
|
||||
it('还没到开始时间时停在开头,到时间再播放', async () => {
|
||||
await attached()
|
||||
clock.nowMs = startedAtMs - 3000
|
||||
setPlaying(song('a'))
|
||||
audio.currentTime = 9
|
||||
audio.emit('canplay')
|
||||
expect(audio.currentTime).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(2999)
|
||||
expect(audio.plays).toBe(0)
|
||||
|
||||
clock.nowMs = startedAtMs
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
|
||||
it('这首歌已经播完时不再播放', async () => {
|
||||
await attached()
|
||||
clock.nowMs = startedAtMs + durationMs
|
||||
setPlaying(song('a'))
|
||||
audio.paused = false
|
||||
audio.emit('canplay')
|
||||
expect(audio.plays).toBe(0)
|
||||
expect(audio.paused).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('房间状态变化', () => {
|
||||
it('同一首歌的新快照不重新加载,但应用新的增益', async () => {
|
||||
await attached()
|
||||
const current = song('a')
|
||||
setPlaying(current)
|
||||
setPlaying(song('a', { gain: 0.5 }))
|
||||
expect(audio.loads).toBe(1)
|
||||
expect(audio.volume).toBeCloseTo(0.375)
|
||||
})
|
||||
|
||||
it('同一个条目重新开播时重新加载', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
setPlaying(song('a', { startedAtMs: startedAtMs + 1000 }))
|
||||
expect(audio.loads).toBe(2)
|
||||
})
|
||||
|
||||
it('换歌时取消上一首的等待:新歌要等自己加载好', async () => {
|
||||
await attached()
|
||||
clock.nowMs = startedAtMs - 3000
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
setPlaying(song('b', { startedAtMs: startedAtMs - 10_000 }))
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(audio.plays).toBe(0)
|
||||
expect(audio.src).toBe('/media/netease/b')
|
||||
})
|
||||
|
||||
it('上一首还没加载好就换歌时,只为新歌开始一次', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
setPlaying(song('b'))
|
||||
audio.emit('canplay')
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
|
||||
it('没有在播的歌时停止,清除音频地址', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
audio.paused = false
|
||||
setPlaying(null)
|
||||
expect(audio.paused).toBe(true)
|
||||
expect(audio.src).toBe('')
|
||||
expect(audio.loads).toBe(2)
|
||||
})
|
||||
|
||||
it('已经在等待的开始在停止时取消', async () => {
|
||||
await attached()
|
||||
clock.nowMs = startedAtMs - 1000
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
setPlaying(null)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('增益放大有上限,音量不超过 1', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a', { gain: 1.2 }))
|
||||
expect(audio.volume).toBeCloseTo(0.9)
|
||||
setPlaying(song('b', { gain: 5 }))
|
||||
expect(audio.volume).toBe(1)
|
||||
setPlaying(song('c', { gain: -1 }))
|
||||
expect(audio.volume).toBe(0)
|
||||
})
|
||||
|
||||
it('调音量时立刻生效,增益上限不随音量变化', async () => {
|
||||
await attached()
|
||||
setPlaying(song('a'))
|
||||
const { volumeStore } = await import('./volume-store')
|
||||
volumeStore.setVolume(40)
|
||||
expect(audio.volume).toBeCloseTo(0.4)
|
||||
setPlaying(song('b', { gain: 5 }))
|
||||
expect(audio.volume).toBeCloseTo(0.6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('自动播放被拒绝', () => {
|
||||
it('浏览器要求用户操作时显示提示,点击后恢复', async () => {
|
||||
const playerStore = await attached()
|
||||
audio.playError = new DOMException('blocked', 'NotAllowedError')
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
await flush()
|
||||
expect(playerStore.state.needsGesture).toBe(true)
|
||||
|
||||
audio.playError = null
|
||||
playerStore.resume()
|
||||
expect(playerStore.state.needsGesture).toBe(false)
|
||||
expect(audio.plays).toBe(2)
|
||||
})
|
||||
|
||||
it('其他播放错误不要求用户操作', async () => {
|
||||
const playerStore = await attached()
|
||||
audio.playError = new DOMException('aborted', 'AbortError')
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
await flush()
|
||||
expect(playerStore.state.needsGesture).toBe(false)
|
||||
})
|
||||
|
||||
it('开始播放之后关掉提示', async () => {
|
||||
const playerStore = await attached()
|
||||
audio.playError = new DOMException('blocked', 'NotAllowedError')
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
await flush()
|
||||
audio.emit('playing')
|
||||
expect(playerStore.state.needsGesture).toBe(false)
|
||||
})
|
||||
|
||||
it('稍后播放只关掉提示', async () => {
|
||||
const playerStore = await attached()
|
||||
audio.playError = new DOMException('blocked', 'NotAllowedError')
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
await flush()
|
||||
playerStore.dismissGesture()
|
||||
expect(playerStore.state.needsGesture).toBe(false)
|
||||
expect(audio.plays).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('恢复播放', () => {
|
||||
it('取得最新快照之后按应在的位置再定位一次', async () => {
|
||||
const playerStore = await attached()
|
||||
setPlaying(song('a'))
|
||||
clock.nowMs = startedAtMs + 1000
|
||||
playerStore.resume()
|
||||
expect(audio.currentTime).toBe(1)
|
||||
expect(room.sync).toHaveBeenCalledTimes(1)
|
||||
|
||||
clock.nowMs = startedAtMs + 4000
|
||||
await flush()
|
||||
expect(audio.currentTime).toBe(4)
|
||||
})
|
||||
|
||||
it('同步期间换了歌时不按上一首定位', async () => {
|
||||
const playerStore = await attached()
|
||||
setPlaying(song('a'))
|
||||
let synced = (): void => {}
|
||||
room.sync.mockReturnValue(new Promise((resolve) => {
|
||||
synced = resolve
|
||||
}))
|
||||
playerStore.resume()
|
||||
const plays = audio.plays
|
||||
setPlaying(song('b'))
|
||||
synced()
|
||||
await flush()
|
||||
expect(audio.plays).toBe(plays)
|
||||
})
|
||||
|
||||
it('没有在播的歌时什么也不做', async () => {
|
||||
const playerStore = await attached()
|
||||
playerStore.resume()
|
||||
expect(room.sync).not.toHaveBeenCalled()
|
||||
expect(audio.plays).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('播放过程中', () => {
|
||||
async function playing() {
|
||||
const playerStore = await attached()
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
audio.emit('playing')
|
||||
return playerStore
|
||||
}
|
||||
|
||||
it('每 2 秒校正超过 1 秒的漂移', async () => {
|
||||
await playing()
|
||||
audio.currentTime = 10
|
||||
clock.nowMs = startedAtMs + 12_000
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(audio.currentTime).toBe(12)
|
||||
})
|
||||
|
||||
it('漂移不超过 1 秒时不打断播放', async () => {
|
||||
await playing()
|
||||
audio.currentTime = 10
|
||||
clock.nowMs = startedAtMs + 10_900
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(audio.currentTime).toBe(10)
|
||||
})
|
||||
|
||||
it('暂停之后不再校正', async () => {
|
||||
await playing()
|
||||
audio.emit('pause')
|
||||
audio.currentTime = 10
|
||||
clock.nowMs = startedAtMs + 30_000
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(audio.currentTime).toBe(10)
|
||||
})
|
||||
|
||||
it('已经过了结束时间时不校正', async () => {
|
||||
await playing()
|
||||
audio.currentTime = 10
|
||||
clock.nowMs = startedAtMs + durationMs
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(audio.currentTime).toBe(10)
|
||||
})
|
||||
|
||||
it('每帧发出当前位置,放到时长时暂停,位置不超过时长', async () => {
|
||||
const playerStore = await playing()
|
||||
const positions: number[] = []
|
||||
playerStore.frames.on(position => positions.push(position))
|
||||
audio.currentTime = 5
|
||||
nextFrame()
|
||||
audio.currentTime = 61
|
||||
nextFrame()
|
||||
expect(positions).toEqual([5000, durationMs])
|
||||
expect(audio.paused).toBe(true)
|
||||
expect(playerStore.positionMs()).toBe(durationMs)
|
||||
})
|
||||
|
||||
it('暂停之后不再发出位置', async () => {
|
||||
const playerStore = await playing()
|
||||
const positions: number[] = []
|
||||
playerStore.frames.on(position => positions.push(position))
|
||||
audio.paused = true
|
||||
nextFrame()
|
||||
expect(positions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('卸载', () => {
|
||||
it('卸载之后房间和时钟的变化不再影响音频', async () => {
|
||||
const playerStore = await attached()
|
||||
setPlaying(song('a'))
|
||||
audio.emit('canplay')
|
||||
audio.emit('playing')
|
||||
audio.paused = false
|
||||
playerStore.detach()
|
||||
expect(audio.paused).toBe(true)
|
||||
expect(audio.src).toBe('')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(frames).toEqual([])
|
||||
|
||||
const { loads, plays } = audio
|
||||
setPlaying(song('b'))
|
||||
setClock(0)
|
||||
audio.emit('canplay')
|
||||
expect(audio.loads).toBe(loads)
|
||||
expect(audio.plays).toBe(plays)
|
||||
})
|
||||
|
||||
it('再次挂载时重新加载正在播放的歌', async () => {
|
||||
const playerStore = await attached()
|
||||
setPlaying(song('a'))
|
||||
playerStore.detach()
|
||||
|
||||
const next = new FakeAudio()
|
||||
playerStore.attach(next as unknown as HTMLAudioElement, () => {})
|
||||
expect(next.src).toBe('/media/netease/a')
|
||||
})
|
||||
|
||||
it('卸载期间换的歌,再次挂载时照常加载', async () => {
|
||||
const playerStore = await attached()
|
||||
setPlaying(song('a'))
|
||||
playerStore.detach()
|
||||
setPlaying(song('b'))
|
||||
|
||||
const next = new FakeAudio()
|
||||
playerStore.attach(next as unknown as HTMLAudioElement, () => {})
|
||||
expect(next.src).toBe('/media/netease/b')
|
||||
})
|
||||
|
||||
it('挂载中途失败时清理干净,已移除的音频不再被房间驱动', async () => {
|
||||
const { playerStore } = await import('./player-store')
|
||||
setPlaying(song('a'))
|
||||
audio.loadError = new Error('加载失败')
|
||||
expect(() => playerStore.attach(audio as unknown as HTMLAudioElement, () => {})).toThrow('加载失败')
|
||||
|
||||
const { loads } = audio
|
||||
setPlaying(song('b'))
|
||||
expect(audio.loads).toBe(loads)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user