test: cover search request races

This commit is contained in:
DiheChen committed 2026-11-04 21:28:32 +08:00
1 parent be016479ac
commit 6d6163a9de
1 file changed
+279 -1
+279 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { TrackSummary } from '@/core/realtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cachedResult, nextLoad, rememberResult } from './search-store'
describe('nextLoad', () => {
@@ -46,3 +47,280 @@ describe('搜索结果的缓存', () => {
expect([...cache.keys()]).toEqual(['new'])
})
})
interface Call {
name: string
params: unknown
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}
function song(id: string): TrackSummary {
return { track: { source: 'qq', id } } as unknown as TrackSummary
}
function found(...ids: string[]) {
return { items: ids.map(song), total: ids.length }
}
let calls: Call[]
let room: ReturnType<typeof fakeRoom>
function fakeRoom() {
const request = (name: string) => (params: unknown = null) => new Promise((resolve, reject) => {
calls.push({ name, params, resolve, reject })
})
return Object.assign(new EventTarget(), {
state: { roomId: 'r1' as string | null, status: 'open' },
requests: {
searchTracks: request('searchTracks'),
searchPlaylists: request('searchPlaylists'),
recommendList: request('recommendList'),
playlistTracks: request('playlistTracks'),
},
})
}
function setRoom(patch: Partial<typeof room.state>): void {
room.state = { ...room.state, ...patch }
room.dispatchEvent(new Event('change'))
}
function call(index: number): Call {
const found = calls[index]
if (!found)
throw new Error(`没有第 ${index} 个请求`)
return found
}
async function settled(): Promise<void> {
await new Promise(resolve => setTimeout(resolve))
}
async function load() {
const search = await import('./search-store')
const { notificationStore } = await import('@/shared/ui')
search.searchStore.start()
const ids = () => search.searchStore.state.tracks.map(({ track }) => track.id)
const tracks = (keyword: string, source: 'qq' | 'netease' = 'qq') => search.keywordSearch(keyword, source, 'tracks')
return { ...search, notificationStore, ids, tracks }
}
describe('搜索请求', () => {
beforeEach(() => {
vi.resetModules()
calls = []
room = fakeRoom()
vi.doMock('@/core/room', () => ({ roomStore: room }))
})
it('换了条件之后,先发出的请求晚到也不显示', async () => {
const { searchStore, ids, tracks } = await load()
searchStore.show(tracks('a'))
searchStore.show(tracks('b'))
call(1).resolve(found('b1'))
await settled()
call(0).resolve(found('a1'))
await settled()
expect(ids()).toEqual(['b1'])
expect(searchStore.state.loading).toBe(false)
})
it('换了房间之后,上一个房间的响应不显示,连上之后重新请求', async () => {
const { searchStore, ids, tracks } = await load()
searchStore.show(tracks('a'))
setRoom({ roomId: 'r2', status: 'connecting' })
call(0).resolve(found('old'))
await settled()
expect(ids()).toEqual([])
expect(calls).toHaveLength(1)
setRoom({ status: 'open' })
expect(calls).toHaveLength(2)
call(1).resolve(found('new'))
await settled()
expect(ids()).toEqual(['new'])
expect(searchStore.state.loading).toBe(false)
})
it('回到显示过的条件时用缓存,不发请求', async () => {
const { searchStore, ids, tracks } = await load()
searchStore.show(tracks('a'))
call(0).resolve(found('a1'))
await settled()
searchStore.show(tracks('b'))
call(1).resolve(found('b1'))
await settled()
searchStore.show(tracks('a'))
expect(calls).toHaveLength(2)
expect(ids()).toEqual(['a1'])
expect(searchStore.state.loading).toBe(false)
})
it('用缓存之后,之前发出的请求晚到也不显示', async () => {
const { searchStore, ids, tracks } = await load()
searchStore.show(tracks('a'))
call(0).resolve(found('a1'))
await settled()
searchStore.show(tracks('b'))
searchStore.show(tracks('a'))
call(1).resolve(found('b1'))
await settled()
expect(ids()).toEqual(['a1'])
})
it('换了房间之后缓存作废', async () => {
const { searchStore, tracks } = await load()
searchStore.show(tracks('a'))
call(0).resolve(found('a1'))
await settled()
setRoom({ roomId: 'r2' })
expect(calls).toHaveLength(2)
})
it('同样的条件不重复请求', async () => {
const { searchStore, tracks } = await load()
searchStore.show(tracks('a'))
searchStore.show(tracks('a'))
expect(calls).toHaveLength(1)
})
it('没连上房间时推迟请求,连上之后补发', async () => {
room.state.status = 'connecting'
const { searchStore, tracks } = await load()
searchStore.show(tracks('a'))
expect(calls).toHaveLength(0)
expect(searchStore.state.loading).toBe(true)
setRoom({ status: 'open' })
expect(calls).toHaveLength(1)
})
it('页面没有显示时,连上房间也不发请求', async () => {
room.state.status = 'connecting'
const { searchStore, tracks } = await load()
searchStore.show(tracks('a'))
searchStore.hide()
setRoom({ status: 'open' })
expect(calls).toHaveLength(0)
})
it('失败时提示一次、不缓存,回到这个条件时重新请求', async () => {
const { searchStore, notificationStore, tracks } = await load()
searchStore.show(tracks('a'))
call(0).reject(new Error('出错了'))
await settled()
expect(searchStore.state).toMatchObject({ loading: false, failed: true })
expect(notificationStore.state.notifications).toHaveLength(1)
searchStore.show(tracks('b'))
call(1).resolve(found('b1'))
await settled()
expect(searchStore.state.failed).toBe(false)
searchStore.show(tracks('a'))
expect(calls).toHaveLength(3)
})
it('重试时绕过缓存', async () => {
const { searchStore, ids, tracks } = await load()
searchStore.show(tracks('a'))
call(0).resolve(found('a1'))
await settled()
searchStore.retry()
expect(calls).toHaveLength(2)
call(1).resolve(found('a2'))
await settled()
expect(ids()).toEqual(['a2'])
})
it('同时搜歌曲和歌单时,一半失败也显示另一半', async () => {
const { searchStore, notificationStore, ids, keywordSearch } = await load()
searchStore.show(keywordSearch('a', 'netease', 'all'))
expect(calls.map(({ name }) => name)).toEqual(['searchTracks', 'searchPlaylists'])
call(0).resolve(found('a1'))
call(1).reject(new Error('出错了'))
await settled()
expect(ids()).toEqual(['a1'])
expect(searchStore.state.failed).toBe(true)
expect(notificationStore.state.notifications).toHaveLength(1)
})
it('两个请求因为同样的原因失败时只提示一次', async () => {
const { searchStore, notificationStore, keywordSearch } = await load()
searchStore.show(keywordSearch('a', 'netease', 'all'))
call(0).reject(new Error('出错了'))
call(1).reject(new Error('出错了'))
await settled()
expect(notificationStore.state.notifications).toHaveLength(1)
})
it('只有网易云搜歌单:其他来源的全部类别只搜歌曲', async () => {
const { searchStore, keywordSearch } = await load()
searchStore.show(keywordSearch('a', 'qq', 'all'))
expect(calls.map(({ name }) => name)).toEqual(['searchTracks'])
})
it('关键词太长时提示长度上限', async () => {
const { RequestError } = await import('@/core/realtime')
const { searchStore, notificationStore, tracks } = await load()
searchStore.show(tracks('a'))
call(0).reject(new RequestError('参数无效', 'INVALID_ARGUMENT'))
await settled()
expect(notificationStore.state.notifications[0]?.message).toBe('关键词太长了,最多 500 个字符。')
})
it('没有关键词时不请求,显示最近搜索', async () => {
const { searchStore, tracks } = await load()
searchStore.show(tracks(''))
expect(calls).toHaveLength(0)
expect(searchStore.state.loading).toBe(false)
})
it('推荐属于当前房间:换了房间就清空,连上之后重新请求', async () => {
const { searchStore } = await load()
searchStore.show({ view: 'recommend' })
call(0).resolve({ items: [song('r1')] })
await settled()
expect(searchStore.state.recommendations).toHaveLength(1)
setRoom({ roomId: 'r2', status: 'connecting' })
expect(searchStore.state.recommendations).toEqual([])
setRoom({ status: 'open' })
expect(calls).toHaveLength(2)
})
it('换了房间时正在进行的推荐不再等待,不一直显示加载中', async () => {
const { searchStore } = await load()
searchStore.show({ view: 'recommend' })
expect(searchStore.state.loading).toBe(true)
setRoom({ roomId: 'r2', status: 'connecting' })
expect(searchStore.state.loading).toBe(false)
})
it('打开歌单时显示歌单内的歌曲,同一个歌单不重复请求', async () => {
const { searchStore, ids } = await load()
searchStore.showPlaylist('netease', 'p1')
searchStore.showPlaylist('netease', 'p1')
expect(calls).toHaveLength(1)
expect(call(0)).toMatchObject({ name: 'playlistTracks', params: { source: 'netease', playlistId: 'p1' } })
call(0).resolve(found('p1-1'))
await settled()
expect(ids()).toEqual(['p1-1'])
})
it('条件没变时不动正在输入的文字,条件变了就跟着地址', async () => {
const { searchStore, tracks } = await load()
searchStore.show(tracks('a'))
searchStore.setText('ab')
searchStore.show(tracks('a'))
expect(searchStore.state.text).toBe('ab')
searchStore.show(tracks('c'))
expect(searchStore.state.text).toBe('c')
searchStore.show({ view: 'recommend' })
expect(searchStore.state.text).toBe('')
})
})