test: add automated tests and fixtures
This commit is contained in:
1 parent
c4008505e6
commit
50039353ad
215 files changed
+34050
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import type { Route } from './route'
|
||||
import type { SearchQuery } from '@/features/search'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route'
|
||||
|
||||
function parse(url: string): Route | null {
|
||||
return parseRoute(new URL(url, 'https://alisten.test'))
|
||||
}
|
||||
|
||||
describe('parseRoute', () => {
|
||||
it.each<[string, Route]>([
|
||||
['/', { name: 'lobby', filter: '', panel: null }],
|
||||
['/?q=+%E5%A4%9C&panel=chat', { name: 'lobby', filter: ' 夜', panel: 'chat' }],
|
||||
['/admin', { name: 'admin', panel: null }],
|
||||
['/settings?panel=queue', { name: 'settings', panel: 'queue' }],
|
||||
['/history', { name: 'history', filter: '', panel: null }],
|
||||
['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }],
|
||||
['/r/my-room', { name: 'listen', ref: 'my-room', panel: null }],
|
||||
['/r/my-room?panel=history', { name: 'listen', ref: 'my-room', panel: 'history' }],
|
||||
['/r/my-room/queue?panel=queue', { name: 'queue', ref: 'my-room', panel: 'queue' }],
|
||||
['/r/my-room/chat', { name: 'chat', ref: 'my-room', panel: null }],
|
||||
['/r/my-room/search', { name: 'search', ref: 'my-room', panel: null, query: { view: 'all', keyword: '', source: 'qq', page: 1 } }],
|
||||
['/r/my-room/search?q=%E6%99%B4%E5%A4%A9&source=netease&view=playlists&page=2&panel=chat', { name: 'search', ref: 'my-room', panel: 'chat', query: { view: 'playlists', keyword: '晴天', source: 'netease', page: 2 } }],
|
||||
['/r/my-room/search?source=netease&view=tracks', { name: 'search', ref: 'my-room', panel: null, query: { view: 'tracks', keyword: '', source: 'netease', page: 1 } }],
|
||||
['/r/my-room/search?view=recommend', { name: 'search', ref: 'my-room', panel: null, query: { view: 'recommend' } }],
|
||||
['/r/my-room/playlists/netease/123?page=3&panel=queue', { name: 'playlist', ref: 'my-room', panel: 'queue', source: 'netease', id: '123', page: 3 }],
|
||||
])('%s', (url, route) => {
|
||||
expect(parse(url)).toEqual(route)
|
||||
expect(formatRoute(route)).toBe(url)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/admin?from=menu', '/admin'],
|
||||
['/settings?panel=lyrics', '/settings'],
|
||||
['/?q=', '/'],
|
||||
['/r/my-room?panel=lyrics', '/r/my-room'],
|
||||
['/r/my-room/search?q=%20a%20', '/r/my-room/search?q=a&source=qq'],
|
||||
['/r/my-room/search?q=a&source=kuwo&view=lyrics&page=0', '/r/my-room/search?q=a&source=qq'],
|
||||
['/r/my-room/search?q=a&source=qq&view=playlists', '/r/my-room/search?q=a&source=qq'],
|
||||
['/r/my-room/search?q=a&page=2', '/r/my-room/search?q=a&source=qq'],
|
||||
['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'],
|
||||
['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'],
|
||||
['/r/my-room/playlists/netease/123?page=1.5', '/r/my-room/playlists/netease/123'],
|
||||
])('%s 规范化为 %s', (url, canonical) => {
|
||||
const route = parse(url)
|
||||
expect(route && formatRoute(route)).toBe(canonical)
|
||||
})
|
||||
|
||||
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease'])('%s 不对应任何页面', (url) => {
|
||||
expect(parse(url)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNewEntry', () => {
|
||||
it('换页面和进入另一个房间写入新记录', () => {
|
||||
expect(isNewEntry({ name: 'lobby', filter: '', panel: null }, { name: 'listen', ref: 'a', panel: null })).toBe(true)
|
||||
expect(isNewEntry({ name: 'listen', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: null })).toBe(true)
|
||||
expect(isNewEntry({ name: 'listen', ref: 'a', panel: null }, { name: 'listen', ref: 'b', panel: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('开关侧栏和输入筛选词只替换当前地址', () => {
|
||||
expect(isNewEntry({ name: 'queue', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: 'chat' })).toBe(false)
|
||||
expect(isNewEntry({ name: 'settings', panel: null }, { name: 'settings', panel: 'chat' })).toBe(false)
|
||||
expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('提交新的关键词写入新记录,换来源、类别、页码和推荐只替换当前地址', () => {
|
||||
const search = (query: SearchQuery): Route => ({ name: 'search', ref: 'a', panel: null, query })
|
||||
const sunny = search({ view: 'all', keyword: '晴天', source: 'qq', page: 1 })
|
||||
expect(isNewEntry(sunny, search({ view: 'all', keyword: '七里香', source: 'qq', page: 1 }))).toBe(true)
|
||||
expect(isNewEntry(sunny, search({ view: 'all', keyword: '', source: 'qq', page: 1 }))).toBe(true)
|
||||
expect(isNewEntry(sunny, search({ view: 'tracks', keyword: '晴天', source: 'netease', page: 2 }))).toBe(false)
|
||||
expect(isNewEntry(sunny, search({ view: 'recommend' }))).toBe(false)
|
||||
expect(isNewEntry(search({ view: 'recommend' }), search({ view: 'tracks', keyword: '', source: 'qq', page: 1 }))).toBe(false)
|
||||
expect(isNewEntry(search({ view: 'recommend' }), sunny)).toBe(true)
|
||||
})
|
||||
|
||||
it('打开另一个歌单写入新记录,翻页只替换当前地址', () => {
|
||||
const playlist = (id: string, page: number): Route => ({ name: 'playlist', ref: 'a', panel: null, source: 'netease', id, page })
|
||||
expect(isNewEntry(playlist('1', 1), playlist('2', 1))).toBe(true)
|
||||
expect(isNewEntry(playlist('1', 1), playlist('1', 2))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveStep', () => {
|
||||
const leaving = new Set(['my-room', 'a1b2c3'])
|
||||
|
||||
it('不在这个房间的页面上时留在原处', () => {
|
||||
expect(leaveStep({ name: 'admin', panel: null }, leaving, true)).toBe('stay')
|
||||
expect(leaveStep({ name: 'listen', ref: 'other-room', panel: null }, leaving, true)).toBe('stay')
|
||||
})
|
||||
|
||||
it('在这个房间的页面上一直后退,直接打开的房间换成发现房间', () => {
|
||||
expect(leaveStep({ name: 'queue', ref: 'a1b2c3', panel: 'chat' }, leaving, true)).toBe('back')
|
||||
expect(leaveStep({ name: 'listen', ref: 'my-room', panel: null }, leaving, false)).toBe('lobby')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Clock } from './clock'
|
||||
|
||||
function manualClock(start = 1000) {
|
||||
let now = start
|
||||
return {
|
||||
clock: new Clock(() => now),
|
||||
advance: (ms: number) => {
|
||||
now += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('clock', () => {
|
||||
it('没有样本时不知道服务器时间', () => {
|
||||
const { clock } = manualClock()
|
||||
expect(clock.offsetMs).toBeNull()
|
||||
expect(clock.serverNow()).toBeNull()
|
||||
})
|
||||
|
||||
it('偏移等于服务器时间减去发送与收到时间的中点', () => {
|
||||
const { clock, advance } = manualClock(1000)
|
||||
clock.addSample(1000, 1100, 5050)
|
||||
expect(clock.offsetMs).toBe(4000)
|
||||
advance(500)
|
||||
expect(clock.serverNow()).toBe(5500)
|
||||
})
|
||||
|
||||
it('取往返时间最小的样本', () => {
|
||||
const { clock } = manualClock()
|
||||
clock.addSample(1000, 1300, 6150)
|
||||
clock.addSample(2000, 2040, 7020)
|
||||
clock.addSample(3000, 3200, 9100)
|
||||
expect(clock.offsetMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('只看最近 12 个样本', () => {
|
||||
const { clock } = manualClock()
|
||||
clock.addSample(0, 10, 1005)
|
||||
for (let i = 1; i <= 11; i++)
|
||||
clock.addSample(i * 1000, i * 1000 + 100, i * 1000 + 50 + 2000)
|
||||
expect(clock.offsetMs).toBe(1000)
|
||||
clock.addSample(12_000, 12_100, 12_050 + 2000)
|
||||
expect(clock.offsetMs).toBe(2000)
|
||||
})
|
||||
|
||||
it('清空后丢弃样本,也不接受清空前发出的 ping', () => {
|
||||
const { clock, advance } = manualClock(1000)
|
||||
clock.addSample(1000, 1100, 5050)
|
||||
advance(1000)
|
||||
clock.reset()
|
||||
expect(clock.offsetMs).toBeNull()
|
||||
clock.addSample(1500, 2100, 9000)
|
||||
expect(clock.offsetMs).toBeNull()
|
||||
clock.addSample(2000, 2100, 7050)
|
||||
expect(clock.offsetMs).toBe(5000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ErrorCode } from '@/core/realtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError, IdentityRenewedError } from '@/core/api'
|
||||
import { RequestError } from '@/core/realtime'
|
||||
import { errorMessage, errorText } from './error-message'
|
||||
|
||||
describe('errorText', () => {
|
||||
const cases: { name: string, code: ErrorCode, message: string, want: string }[] = [
|
||||
{ name: '字段路径换成通用说法', code: 'INVALID_ARGUMENT', message: 'name:长度不能少于 1 个字符', want: '提交的内容不符合要求,请检查后再试。' },
|
||||
{ name: '协议版本不一致', code: 'CLIENT_OUTDATED', message: '前端与服务端的协议版本不一致,请刷新页面', want: '应用已经更新,稍后会自动刷新页面。' },
|
||||
{ name: '房间密码错误照旧显示', code: 'UNAUTHORIZED', message: '房间密码错误', want: '房间密码错误' },
|
||||
{ name: '重复点歌照旧显示', code: 'CONFLICT', message: '这首歌已经在播放列表里了。', want: '这首歌已经在播放列表里了。' },
|
||||
{ name: '播放列表已满照旧显示', code: 'QUEUE_FULL', message: '临时房间的播放列表最多 10 首歌,等播放几首之后再点。', want: '临时房间的播放列表最多 10 首歌,等播放几首之后再点。' },
|
||||
{ name: '没有在放的歌照旧显示', code: 'NOTHING_PLAYING', message: '现在没有在放的歌。', want: '现在没有在放的歌。' },
|
||||
{ name: '音乐平台出错照旧显示', code: 'PROVIDER_ERROR', message: '音乐平台没有正常响应,请稍后再试。', want: '音乐平台没有正常响应,请稍后再试。' },
|
||||
{ name: '房间暂时不可用照旧显示', code: 'UNAVAILABLE', message: '房间暂时不可用,请稍后再试。', want: '房间暂时不可用,请稍后再试。' },
|
||||
{ name: '服务器出错照旧显示', code: 'INTERNAL', message: '服务器出错了,请稍后再试。', want: '服务器出错了,请稍后再试。' },
|
||||
]
|
||||
|
||||
for (const tc of cases) {
|
||||
it(tc.name, () => {
|
||||
expect(errorText(tc.code, tc.message)).toBe(tc.want)
|
||||
})
|
||||
}
|
||||
|
||||
it('操作给出的说法优先', () => {
|
||||
expect(errorText('INVALID_ARGUMENT', 'keyword:长度不能超过 500 个字符', { INVALID_ARGUMENT: '关键词太长了' })).toBe('关键词太长了')
|
||||
expect(errorText('CONFLICT', '这首歌已经在播放列表里了。', { INVALID_ARGUMENT: '关键词太长了' })).toBe('这首歌已经在播放列表里了。')
|
||||
})
|
||||
|
||||
it('操作可以照原样显示服务端的 message', () => {
|
||||
expect(errorText('INVALID_ARGUMENT', '不是能识别的 cookies', { INVALID_ARGUMENT: message => message })).toBe('不是能识别的 cookies')
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorMessage', () => {
|
||||
it('按错误码转换服务端的错误', () => {
|
||||
expect(errorMessage(new ApiError('cookies:长度不能少于 1 个字符', 'INVALID_ARGUMENT', 400))).toBe('提交的内容不符合要求,请检查后再试。')
|
||||
expect(errorMessage(new RequestError('前端与服务端的协议版本不一致,请刷新页面', 'CLIENT_OUTDATED'))).toBe('应用已经更新,稍后会自动刷新页面。')
|
||||
expect(errorMessage(new RequestError('你已经投过票了。', 'CONFLICT'))).toBe('你已经投过票了。')
|
||||
})
|
||||
|
||||
it('前端构造的错误照旧显示', () => {
|
||||
expect(errorMessage(new ApiError('无法连接服务器,请检查网络后再试。', null, 0))).toBe('无法连接服务器,请检查网络后再试。')
|
||||
expect(errorMessage(new RequestError('与房间的连接断开了,连上之后再试一次。', null))).toBe('与房间的连接断开了,连上之后再试一次。')
|
||||
expect(errorMessage(new IdentityRenewedError())).toBe(new IdentityRenewedError().message)
|
||||
})
|
||||
|
||||
it('浏览器和程序缺陷的错误不显示原文', () => {
|
||||
expect(errorMessage(new TypeError('Cannot read properties of undefined'))).toBe('出错了,请稍后再试。')
|
||||
expect(errorMessage('boom')).toBe('出错了,请稍后再试。')
|
||||
})
|
||||
|
||||
it('操作给出的说法只替换对应的错误码', () => {
|
||||
const overrides = { INVALID_ARGUMENT: '昵称不能为空' }
|
||||
expect(errorMessage(new ApiError('name:长度不能少于 1 个字符', 'INVALID_ARGUMENT', 400), overrides)).toBe('昵称不能为空')
|
||||
expect(errorMessage(new ApiError('服务器出错了,请稍后再试。', 'INTERNAL', 500), overrides)).toBe('服务器出错了,请稍后再试。')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { reconnectDelay } from './backoff'
|
||||
|
||||
describe('reconnectDelay', () => {
|
||||
it.each([
|
||||
[1, 3000],
|
||||
[4, 24_000],
|
||||
[5, 30_000],
|
||||
])('第 %i 次等待 %i 毫秒', (attempt, delay) => {
|
||||
expect(reconnectDelay(attempt)).toBe(delay)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLink } from './protocol.gen'
|
||||
|
||||
function cases(name: string): { value: string, valid: boolean }[] {
|
||||
return JSON.parse(readFileSync(new URL(`../../../../protocol/testdata/${name}.json`, import.meta.url), 'utf8')) as { value: string, valid: boolean }[]
|
||||
}
|
||||
|
||||
describe('isLink', () => {
|
||||
it('对 protocol/testdata/Link.json 中的每个用例与服务端的判断相同', () => {
|
||||
expect(cases('Link').filter(c => isLink(c.value) !== c.valid).map(c => c.value)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Entry, RoomEventMessage, RoomSnapshot } from '@/core/realtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RoomReplica } from './replica'
|
||||
|
||||
const alice = { id: 'm1', name: 'alice', avatarHash: '' }
|
||||
|
||||
function snapshot(term: number, seq: number, patch: Partial<RoomSnapshot> = {}): RoomSnapshot {
|
||||
return {
|
||||
term,
|
||||
seq,
|
||||
room: { id: 'r1', name: '房间', description: '', kind: 'persistent' },
|
||||
playMode: 'sequential',
|
||||
nowPlaying: null,
|
||||
pending: null,
|
||||
items: [],
|
||||
members: { sessions: [], onlineCount: 0 },
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
function entry(id: string): Entry {
|
||||
return {
|
||||
id,
|
||||
track: { source: 'netease', id },
|
||||
meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
|
||||
by: alice,
|
||||
auto: false,
|
||||
likes: 0,
|
||||
status: 'ready',
|
||||
}
|
||||
}
|
||||
|
||||
function queued(term: number, seq: number, ids: string[]): RoomEventMessage {
|
||||
return { kind: 'event', type: 'queue.updated', term, seq, data: { pending: null, items: ids.map(entry) } }
|
||||
}
|
||||
|
||||
function chat(term: number, seq: number, content: string): RoomEventMessage {
|
||||
return { kind: 'event', type: 'chat.message', term, seq, data: { member: alice, content, sentAtMs: 1 } }
|
||||
}
|
||||
|
||||
const nothing = { changed: false, notice: null, sync: false }
|
||||
|
||||
describe('roomReplica', () => {
|
||||
it('依次应用连续的事件', () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
expect(replica.receive(queued(3, 11, ['a']))).toEqual({ changed: true, notice: null, sync: false })
|
||||
expect(replica.receive({ kind: 'event', type: 'settings.updated', term: 3, seq: 12, data: { playMode: 'random' } }).changed).toBe(true)
|
||||
expect(replica.receive({ kind: 'event', type: 'room.updated', term: 3, seq: 13, data: { name: '新名字', description: '介绍' } }).changed).toBe(true)
|
||||
expect(replica.state.items.map(e => e.id)).toEqual(['a'])
|
||||
expect(replica.state.playMode).toBe('random')
|
||||
expect(replica.state.room).toEqual({ id: 'r1', name: '新名字', description: '介绍', kind: 'persistent' })
|
||||
})
|
||||
|
||||
it('丢弃旧任期和重复的事件', () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
expect(replica.receive(queued(2, 11, ['old']))).toEqual(nothing)
|
||||
expect(replica.receive(queued(3, 10, ['dup']))).toEqual(nothing)
|
||||
expect(replica.state.items).toEqual([])
|
||||
})
|
||||
|
||||
it('seq 不连续或任期更大时请求快照,等待期间只请求一次', () => {
|
||||
const gap = new RoomReplica(snapshot(3, 10))
|
||||
expect(gap.receive(queued(3, 12, ['a']))).toEqual({ changed: false, notice: null, sync: true })
|
||||
expect(gap.receive(queued(3, 13, ['b']))).toEqual(nothing)
|
||||
expect(gap.state.items).toEqual([])
|
||||
|
||||
const newer = new RoomReplica(snapshot(3, 10))
|
||||
expect(newer.receive(queued(4, 1, ['a'])).sync).toBe(true)
|
||||
})
|
||||
|
||||
it('等待快照期间照常显示通知,并按编号去重', () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
expect(replica.receive(chat(3, 12, '第一条')).notice?.data).toMatchObject({ content: '第一条' })
|
||||
expect(replica.receive(chat(3, 12, '第一条')).notice).toBeNull()
|
||||
expect(replica.receive(chat(3, 13, '第二条')).notice?.data).toMatchObject({ content: '第二条' })
|
||||
expect(replica.receive(chat(2, 20, '旧任期')).notice).toBeNull()
|
||||
})
|
||||
|
||||
it('以快照为准,之后接着快照的编号应用事件', () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
replica.receive(chat(3, 12, '等待时收到'))
|
||||
replica.applySnapshot(snapshot(3, 11, { items: [entry('a')] }))
|
||||
expect(replica.state.items.map(e => e.id)).toEqual(['a'])
|
||||
expect(replica.receive(chat(3, 12, '等待时收到'))).toEqual({ changed: false, notice: null, sync: false })
|
||||
expect(replica.receive(queued(3, 13, ['a', 'b'])).changed).toBe(true)
|
||||
expect(replica.state.items.map(e => e.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('连续的通知事件推进编号', () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
expect(replica.receive(chat(3, 11, '你好')).notice?.data).toMatchObject({ content: '你好' })
|
||||
expect(replica.receive(queued(3, 12, ['a'])).changed).toBe(true)
|
||||
})
|
||||
|
||||
it('获取快照失败后,下一个不连续的事件再次请求', async () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
expect(replica.receive(queued(3, 12, ['a'])).sync).toBe(true)
|
||||
await expect(replica.sync(() => Promise.reject(new Error('与房间的连接已断开')))).rejects.toThrow('与房间的连接已断开')
|
||||
expect(replica.receive(queued(3, 13, ['b'])).sync).toBe(true)
|
||||
})
|
||||
|
||||
it('请求快照期间丢弃状态事件,快照到达后接着它的编号应用', async () => {
|
||||
const replica = new RoomReplica(snapshot(3, 10))
|
||||
let respond: (room: RoomSnapshot) => void = () => {}
|
||||
const syncing = replica.sync(() => new Promise((resolve) => {
|
||||
respond = resolve
|
||||
}))
|
||||
expect(replica.receive(queued(3, 11, ['a']))).toEqual(nothing)
|
||||
respond(snapshot(3, 11, { items: [entry('b')] }))
|
||||
await syncing
|
||||
expect(replica.state.items.map(e => e.id)).toEqual(['b'])
|
||||
expect(replica.receive(queued(3, 12, ['b', 'c'])).changed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { QrLoginState } from './qr-login'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextQrLogin, startQrLogin } from './qr-login'
|
||||
|
||||
const expiresAtMs = 120_000
|
||||
const waiting = startQrLogin(expiresAtMs)
|
||||
|
||||
describe('nextQrLogin', () => {
|
||||
it('等待扫码和已扫码时继续查询,并记下平台给出的说明', () => {
|
||||
expect(nextQrLogin(waiting, { kind: 'checked', result: { status: 'waiting', message: '' } })).toEqual({ state: waiting, polling: true })
|
||||
expect(nextQrLogin(waiting, { kind: 'checked', result: { status: 'scanned', message: '请在手机上确认' } }))
|
||||
.toEqual({ state: { status: 'scanned', message: '请在手机上确认', expiresAtMs }, polling: true })
|
||||
})
|
||||
|
||||
it('登录成功、二维码过期或登录失败时停止查询', () => {
|
||||
for (const status of ['success', 'expired', 'failed'] as const) {
|
||||
expect(nextQrLogin(waiting, { kind: 'checked', result: { status, message: '二维码已失效' } }))
|
||||
.toEqual({ state: { status, message: '二维码已失效', expiresAtMs }, polling: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('查询失败时按登录失败处理,停止查询', () => {
|
||||
expect(nextQrLogin(waiting, { kind: 'checked', result: null })).toEqual({ state: { status: 'failed', message: '', expiresAtMs }, polling: false })
|
||||
})
|
||||
|
||||
it('到了 expiresAt 按过期处理,停止查询', () => {
|
||||
expect(nextQrLogin(waiting, { kind: 'tick', nowMs: expiresAtMs - 1 })).toEqual({ state: waiting, polling: true })
|
||||
const scanned: QrLoginState = { status: 'scanned', message: '', expiresAtMs }
|
||||
expect(nextQrLogin(scanned, { kind: 'tick', nowMs: expiresAtMs })).toEqual({ state: { status: 'expired', message: '', expiresAtMs }, polling: false })
|
||||
})
|
||||
|
||||
it('结束之后到达的查询结果和计时都不再改变状态', () => {
|
||||
const success: QrLoginState = { status: 'success', message: '', expiresAtMs }
|
||||
expect(nextQrLogin(success, { kind: 'tick', nowMs: expiresAtMs })).toEqual({ state: success, polling: false })
|
||||
const expired: QrLoginState = { status: 'expired', message: '', expiresAtMs }
|
||||
expect(nextQrLogin(expired, { kind: 'checked', result: { status: 'scanned', message: '' } })).toEqual({ state: expired, polling: false })
|
||||
expect(nextQrLogin(expired, { kind: 'checked', result: null })).toEqual({ state: expired, polling: false })
|
||||
const failed: QrLoginState = { status: 'failed', message: '', expiresAtMs }
|
||||
expect(nextQrLogin(failed, { kind: 'checked', result: { status: 'success', message: '' } })).toEqual({ state: failed, polling: false })
|
||||
})
|
||||
|
||||
it('本机判定过期之后才到的登录成功仍按成功处理', () => {
|
||||
const expired: QrLoginState = { status: 'expired', message: '', expiresAtMs }
|
||||
expect(nextQrLogin(expired, { kind: 'checked', result: { status: 'success', message: '已登录' } }))
|
||||
.toEqual({ state: { status: 'success', message: '已登录', expiresAtMs }, polling: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceAccountView } from './source-account-view'
|
||||
|
||||
type State = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | null | undefined
|
||||
|
||||
const cases: { name: string, state: State, want: ReturnType<typeof sourceAccountView> }[] = [
|
||||
{ name: '正在查询', state: undefined, want: { status: '正在查询…', details: [] } },
|
||||
{ name: '查询失败', state: null, want: { status: '查询失败', details: [] } },
|
||||
{ name: '没有保存凭据', state: { set: false, account: null }, want: { status: '未登录', details: [] } },
|
||||
{ name: '保存凭据但没有账号资料', state: { set: true, account: null }, want: { status: '已保存登录信息,未识别到账号', details: [] } },
|
||||
{
|
||||
name: '上游已验证的会员账号',
|
||||
state: { set: true, account: { id: '123', name: '测试账号', verified: true, vip: true } },
|
||||
want: { status: '已登录', details: ['昵称:测试账号', '账号 ID:123', '会员状态:会员'] },
|
||||
},
|
||||
{
|
||||
name: '非会员和未知昵称',
|
||||
state: { set: true, account: { id: '123', name: null, verified: true, vip: false } },
|
||||
want: { status: '已登录', details: ['账号 ID:123', '会员状态:非会员'] },
|
||||
},
|
||||
{
|
||||
name: '仅从本地凭据识别的账号',
|
||||
state: { set: true, account: { id: '456', name: null, verified: false, vip: null } },
|
||||
want: { status: '已保存登录信息,未经 QQ 音乐核实', details: ['账号 ID:456'] },
|
||||
},
|
||||
]
|
||||
|
||||
describe('sourceAccountView', () => {
|
||||
for (const tc of cases) {
|
||||
it(tc.name, () => {
|
||||
expect(sourceAccountView(tc.state, 'QQ 音乐')).toEqual(tc.want)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AdminGetYoutubeCredentialResponse } from '@/core/api'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canSubmitYoutubeCookies, shouldWarnAboutPlaintext, youtubeCookiesText, youtubeCookiesTooLarge, youtubeCookiesView } from './youtube-cookies-view'
|
||||
|
||||
describe('canSubmitYoutubeCookies', () => {
|
||||
it('空字符串和纯空白不能提交', () => {
|
||||
expect(canSubmitYoutubeCookies('', false)).toBe(false)
|
||||
expect(canSubmitYoutubeCookies(' \n\t ', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('非空内容可以提交,保存期间不能重复提交', () => {
|
||||
expect(canSubmitYoutubeCookies('# Netscape HTTP Cookie File\n', false)).toBe(true)
|
||||
expect(canSubmitYoutubeCookies('# Netscape HTTP Cookie File\n', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('超过 64 KiB 不能提交', () => {
|
||||
expect(canSubmitYoutubeCookies('a'.repeat(64 * 1024), false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('youtubeCookiesTooLarge', () => {
|
||||
// 请求体 {"cookies":"…"} 在内容之外还有 14 个字节。
|
||||
it('按请求体的字节数计算,刚好 64 KiB 不算超过', () => {
|
||||
expect(youtubeCookiesTooLarge('a'.repeat(64 * 1024 - 14))).toBe(false)
|
||||
expect(youtubeCookiesTooLarge('a'.repeat(64 * 1024 - 13))).toBe(true)
|
||||
})
|
||||
|
||||
it('汉字按 UTF-8 的字节数计算,换行和制表符按 JSON 转义之后计算', () => {
|
||||
expect(youtubeCookiesTooLarge('中'.repeat(21_840))).toBe(false)
|
||||
expect(youtubeCookiesTooLarge('中'.repeat(21_841))).toBe(true)
|
||||
expect(youtubeCookiesTooLarge('\t'.repeat(32_761))).toBe(false)
|
||||
expect(youtubeCookiesTooLarge('\t'.repeat(32_762))).toBe(true)
|
||||
expect(youtubeCookiesTooLarge('\n'.repeat(32_761))).toBe(false)
|
||||
expect(youtubeCookiesTooLarge('\n'.repeat(32_762))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldWarnAboutPlaintext', () => {
|
||||
const cases = [
|
||||
{ location: { protocol: 'https:', hostname: 'music.example.com' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: 'localhost' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: '127.0.0.1' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: '127.0.0.2' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: 'app.localhost' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: '::1' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: '[::1]' }, want: false },
|
||||
{ location: { protocol: 'http:', hostname: '192.168.1.20' }, want: true },
|
||||
{ location: { protocol: 'http:', hostname: 'music.example.com' }, want: true },
|
||||
]
|
||||
|
||||
for (const tc of cases) {
|
||||
it(`${tc.location.protocol}//${tc.location.hostname} 的警示为 ${tc.want}`, () => {
|
||||
expect(shouldWarnAboutPlaintext(tc.location)).toBe(tc.want)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('youtubeCookiesText', () => {
|
||||
it('说明明文传输风险', () => {
|
||||
expect(youtubeCookiesText.plaintextWarning).toBe('当前连接不是 HTTPS,填写的 cookies 会以明文在网络上传输,只在可信网络中填写。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('youtubeCookiesView', () => {
|
||||
const cases: { name: string, state: AdminGetYoutubeCredentialResponse | null | undefined, want: ReturnType<typeof youtubeCookiesView> }[] = [
|
||||
{ name: '正在查询', state: undefined, want: { status: '正在查询…', updatedAtMs: null } },
|
||||
{ name: '查询失败', state: null, want: { status: '查询失败', updatedAtMs: null } },
|
||||
{ name: '未配置', state: { set: false, updatedAtMs: 0 }, want: { status: '未配置', updatedAtMs: null } },
|
||||
{ name: '已配置并显示更新时间', state: { set: true, updatedAtMs: 1_793_184_000_000 }, want: { status: '已配置', updatedAtMs: 1_793_184_000_000 } },
|
||||
]
|
||||
|
||||
for (const tc of cases) {
|
||||
it(tc.name, () => {
|
||||
expect(youtubeCookiesView(tc.state)).toEqual(tc.want)
|
||||
})
|
||||
}
|
||||
|
||||
it('已有 cookies 不进入状态视图', () => {
|
||||
const state = { set: true, updatedAtMs: 1_793_184_000_000, cookies: '虚构 cookie,不能回显' }
|
||||
expect(youtubeCookiesView(state)).toEqual({ status: '已配置', updatedAtMs: 1_793_184_000_000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePlays, parseSearches, playHistoryLimit, recordPlay, recordSearch } from './records'
|
||||
|
||||
function play(id: string, playedAtMs: number, source: PlayRecord['track']['source'] = 'netease'): PlayRecord {
|
||||
return {
|
||||
track: { source, id },
|
||||
meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
|
||||
playedAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
function search(query: string, searchedAtMs: number, source: SearchSource = 'netease', category: SearchCategory = 'all'): SearchRecord {
|
||||
return { query, source, category, searchedAtMs }
|
||||
}
|
||||
|
||||
describe('recordPlay', () => {
|
||||
it('新的记录排在最前,同一首歌只留最近一次', () => {
|
||||
const plays = recordPlay([play('2', 200), play('1', 100)], play('1', 300))
|
||||
expect(plays).toEqual([play('1', 300), play('2', 200)])
|
||||
})
|
||||
|
||||
it('来源不同的同一个 ID 是两首歌', () => {
|
||||
expect(recordPlay([play('1', 100, 'qq')], play('1', 200))).toEqual([play('1', 200), play('1', 100, 'qq')])
|
||||
})
|
||||
|
||||
it('最多保留 500 条', () => {
|
||||
const plays = Array.from({ length: playHistoryLimit }, (_, i) => play(String(i), i))
|
||||
const next = recordPlay(plays, play('new', 1000))
|
||||
expect(next).toHaveLength(500)
|
||||
expect(next[0]).toEqual(play('new', 1000))
|
||||
expect(next[499]).toEqual(play('498', 498))
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePlays', () => {
|
||||
it('保留 B 站的播放记录', () => {
|
||||
const record = play('BV1xx411c7mD/2', 1, 'bilibili')
|
||||
expect(parsePlays(JSON.stringify([record]))).toEqual([record])
|
||||
})
|
||||
|
||||
it('丢弃不完整的记录', () => {
|
||||
const broken = [
|
||||
{ track: { source: 'netease', id: '2' }, playedAtMs: 1 },
|
||||
{ ...play('3', 3), track: { source: 'other', id: '3' } },
|
||||
{ ...play('4', 4), meta: { title: '4', artist: '', album: '', coverUrl: '', durationMs: 1 } },
|
||||
null,
|
||||
]
|
||||
expect(parsePlays(JSON.stringify([play('1', 1), ...broken]))).toEqual([play('1', 1)])
|
||||
})
|
||||
|
||||
it('没有记录或者内容无法解析时为空', () => {
|
||||
expect(parsePlays(null)).toEqual([])
|
||||
expect(parsePlays('{')).toEqual([])
|
||||
expect(parsePlays('{}')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordSearch', () => {
|
||||
it('不区分大小写和首尾空白,同一个查询只留最近的一条,平台和类别取最近一次', () => {
|
||||
const searches = recordSearch([search('晴天', 1), search('Jay', 2, 'qq', 'tracks')], search(' jay ', 3, 'netease', 'playlists'), 10)
|
||||
expect(searches).toEqual([search('jay', 3, 'netease', 'playlists'), search('晴天', 1)])
|
||||
})
|
||||
|
||||
it('超过上限时丢弃最旧的', () => {
|
||||
expect(recordSearch([search('b', 2), search('a', 1)], search('c', 3), 2)).toEqual([search('c', 3), search('b', 2)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSearches', () => {
|
||||
it('合并重复的查询,保留最新的一条,按时间从新到旧排列', () => {
|
||||
const stored = JSON.stringify([search('Jay', 1, 'qq'), search('晴天', 2), search(' jay', 3, 'netease', 'tracks')])
|
||||
expect(parseSearches(stored, 10)).toEqual([search('jay', 3, 'netease', 'tracks'), search('晴天', 2)])
|
||||
})
|
||||
|
||||
it('丢弃无效的记录,只保留上限以内的', () => {
|
||||
const stored = JSON.stringify([search('a', 1), search(' ', 2), { ...search('b', 3), source: 'link' }, { query: 'c' }, search('d', 4), search('e', 5)])
|
||||
expect(parseSearches(stored, 2)).toEqual([search('e', 5), search('d', 4)])
|
||||
})
|
||||
|
||||
it('没有记录或者内容无法解析时为空', () => {
|
||||
expect(parseSearches(null, 10)).toEqual([])
|
||||
expect(parseSearches('[', 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { lineIndexAt, parseLrc } from './lrc'
|
||||
|
||||
describe('parseLrc', () => {
|
||||
it('解析分、秒和不同精度的小数部分', () => {
|
||||
expect(parseLrc('[00:12.34]晴天\n[01:02]刮风这天\n[00:01.5]a\n[00:02.123]b')).toEqual([
|
||||
{ timeMs: 1500, text: 'a' },
|
||||
{ timeMs: 2123, text: 'b' },
|
||||
{ timeMs: 12_340, text: '晴天' },
|
||||
{ timeMs: 62_000, text: '刮风这天' },
|
||||
])
|
||||
})
|
||||
|
||||
it('一行有多个时间标签时,每个标签各生成一行', () => {
|
||||
expect(parseLrc('[00:05.00][00:01.00] 副歌 ')).toEqual([
|
||||
{ timeMs: 1000, text: '副歌' },
|
||||
{ timeMs: 5000, text: '副歌' },
|
||||
])
|
||||
})
|
||||
|
||||
it('忽略没有时间标签的行,连续的空白行只保留一行', () => {
|
||||
expect(parseLrc('[ar:周杰伦]\n[00:01.00]一\n[00:02.00]\n[00:03.00] \n[00:04.00]二\r\n[00:05.00]')).toEqual([
|
||||
{ timeMs: 1000, text: '一' },
|
||||
{ timeMs: 2000, text: '' },
|
||||
{ timeMs: 4000, text: '二' },
|
||||
{ timeMs: 5000, text: '' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lineIndexAt', () => {
|
||||
const lines = [{ timeMs: 1000, text: '一' }, { timeMs: 2000, text: '二' }, { timeMs: 3000, text: '三' }]
|
||||
|
||||
it('返回开始时间不晚于当前位置的最后一行', () => {
|
||||
expect(lineIndexAt(lines, 0)).toBe(-1)
|
||||
expect(lineIndexAt(lines, 1000)).toBe(0)
|
||||
expect(lineIndexAt(lines, 2999)).toBe(1)
|
||||
expect(lineIndexAt(lines, 60_000)).toBe(2)
|
||||
expect(lineIndexAt([], 1000)).toBe(-1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { documentTitle } from './document-title'
|
||||
|
||||
describe('documentTitle', () => {
|
||||
it('没有在放的歌时只有品牌名', () => {
|
||||
expect(documentTitle('周末听歌会', null)).toBe('周末听歌会')
|
||||
})
|
||||
|
||||
it('放歌时是歌名 - 歌手 | 品牌名', () => {
|
||||
expect(documentTitle('周末听歌会', { title: '晴天', artist: '周杰伦' })).toBe('晴天 - 周杰伦 | 周末听歌会')
|
||||
})
|
||||
|
||||
it('歌手为空时不留多余的分隔符', () => {
|
||||
expect(documentTitle('Alisten', { title: '片头', artist: '' })).toBe('片头 | Alisten')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Member, NowPlaying } from '@/core/realtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldRemind } from './reminder'
|
||||
|
||||
const me: Member = { id: 'm1', name: '小明', avatarHash: '' }
|
||||
const other: Member = { id: 'm2', name: '阿青', avatarHash: '' }
|
||||
const bot: Member = { id: 'm3', name: '点歌机器人', avatarHash: '' }
|
||||
|
||||
const live = true
|
||||
const snapshot = false
|
||||
const on = true
|
||||
const off = false
|
||||
const waited = 60_000
|
||||
|
||||
function playing(id: string, by: Member | null, likes = 0): NowPlaying {
|
||||
return {
|
||||
durationMs: 1000,
|
||||
entry: {
|
||||
id,
|
||||
track: { source: 'netease', id },
|
||||
meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
|
||||
by,
|
||||
auto: by === null,
|
||||
likes,
|
||||
status: 'ready',
|
||||
},
|
||||
gain: 1,
|
||||
lyric: '',
|
||||
mediaUrl: `/media/${id}`,
|
||||
startedAtMs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('shouldRemind', () => {
|
||||
it('实时事件开始播放自己点的歌时提醒', () => {
|
||||
expect(shouldRemind(null, playing('1', me), me.id, live, on, waited)).toBe(true)
|
||||
expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, waited)).toBe(true)
|
||||
})
|
||||
|
||||
it('快照里正在播放自己点的歌时不提醒', () => {
|
||||
expect(shouldRemind(null, playing('1', me), me.id, snapshot, on, waited)).toBe(false)
|
||||
expect(shouldRemind(playing('1', other), playing('2', me), me.id, snapshot, on, waited)).toBe(false)
|
||||
})
|
||||
|
||||
it('同一首歌的后续更新不再提醒', () => {
|
||||
expect(shouldRemind(playing('1', me), playing('1', me, 1), me.id, live, on, waited)).toBe(false)
|
||||
})
|
||||
|
||||
it('别人、机器人和 AutoDJ 点的歌不提醒', () => {
|
||||
for (const by of [other, bot, null])
|
||||
expect(shouldRemind(null, playing('1', by), me.id, live, on, waited)).toBe(false)
|
||||
})
|
||||
|
||||
it('关掉提醒后不提醒', () => {
|
||||
expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, off, waited)).toBe(false)
|
||||
})
|
||||
|
||||
it('点完不到 30 秒就开播的歌不提醒', () => {
|
||||
expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, 29_999)).toBe(false)
|
||||
})
|
||||
|
||||
it('等满 30 秒才开播的歌提醒', () => {
|
||||
expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, 30_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('开播前没见过的歌不提醒', () => {
|
||||
expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PlaybackSnapshot } from './timeline'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { driftSeekPosition, startPlan } from './timeline'
|
||||
|
||||
function playback(positionMs: number, state: Partial<PlaybackSnapshot> = {}): PlaybackSnapshot {
|
||||
return { positionMs, paused: false, seeking: false, readyState: 4, ...state }
|
||||
}
|
||||
|
||||
describe('startPlan', () => {
|
||||
it('还没到开始时间时等待', () => {
|
||||
expect(startPlan(-200, 180_000)).toEqual({ kind: 'wait', delayMs: 200 })
|
||||
})
|
||||
|
||||
it('在时长之内时从应在位置开始', () => {
|
||||
expect(startPlan(0, 180_000)).toEqual({ kind: 'play', positionMs: 0 })
|
||||
expect(startPlan(42_000, 180_000)).toEqual({ kind: 'play', positionMs: 42_000 })
|
||||
})
|
||||
|
||||
it('超过时长时这首歌已经结束', () => {
|
||||
expect(startPlan(180_000, 180_000)).toEqual({ kind: 'ended' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('driftSeekPosition', () => {
|
||||
it('偏差不超过 1 秒时不进行校正', () => {
|
||||
for (const actual of [10_149, 10_150, 11_000, 9851, 9850, 9000])
|
||||
expect(driftSeekPosition(playback(actual), 10_000)).toBeNull()
|
||||
})
|
||||
|
||||
it('偏差超过 1 秒时返回定位位置', () => {
|
||||
expect(driftSeekPosition(playback(11_001), 10_000)).toBe(10_000)
|
||||
expect(driftSeekPosition(playback(8999), 10_000)).toBe(10_000)
|
||||
})
|
||||
|
||||
it('没有足够数据时不进行校正', () => {
|
||||
for (const readyState of [0, 1, 2])
|
||||
expect(driftSeekPosition(playback(5001, { readyState }), 4000)).toBeNull()
|
||||
expect(driftSeekPosition(playback(5001, { readyState: 3 }), 4000)).toBe(4000)
|
||||
})
|
||||
|
||||
it('暂停时不进行校正', () => {
|
||||
expect(driftSeekPosition(playback(5001, { paused: true }), 4000)).toBeNull()
|
||||
})
|
||||
|
||||
it('正在定位时不进行校正', () => {
|
||||
expect(driftSeekPosition(playback(5001, { seeking: true }), 4000)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { linkOf, pickableLink } from './link'
|
||||
|
||||
describe('linkOf', () => {
|
||||
it.each([
|
||||
[' http://example.com/a.mp3 ', 'http://example.com/a.mp3'],
|
||||
['HTTPS://Example.com/A', 'HTTPS://Example.com/A'],
|
||||
])('%s 是链接', (input, link) => {
|
||||
expect(linkOf(input)).toBe(link)
|
||||
})
|
||||
|
||||
it.each(['晴天'])('%s 不是链接', (input) => {
|
||||
expect(linkOf(input)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://user@example.com/a',
|
||||
])('服务端不接受的 %s 不是链接', (input) => {
|
||||
expect(linkOf(input)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickableLink', () => {
|
||||
it.each([
|
||||
'https://www.bilibili.com/video/BV1xx411c7mD?p=2',
|
||||
'https://m.bilibili.com/video/BV1xx411c7mD',
|
||||
'https://bilibili.com/video/av170001',
|
||||
'HTTPS://B23.TV/qingtian',
|
||||
])('临时房间可以点 B 站的链接 %s', (link) => {
|
||||
expect(pickableLink(` ${link} `, 'ephemeral')).toBe(link)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://www.youtube.com/watch?v=x',
|
||||
'https://notbilibili.com/video/BV1xx411c7mD',
|
||||
'https://bilibili.com.example/video/BV1xx411c7mD',
|
||||
])('其他链接 %s 只在常驻房间可以点', (link) => {
|
||||
expect(pickableLink(link, 'ephemeral')).toBeNull()
|
||||
expect(pickableLink(link, 'persistent')).toBe(link)
|
||||
})
|
||||
|
||||
it('不在房间里时都不能点', () => {
|
||||
expect(pickableLink('https://www.bilibili.com/video/BV1xx411c7mD', undefined)).toBeNull()
|
||||
expect(pickableLink('https://www.youtube.com/watch?v=x', undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('不是链接时都不能点', () => {
|
||||
expect(pickableLink('晴天', 'persistent')).toBeNull()
|
||||
expect(pickableLink('晴天', 'ephemeral')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cachedResult, nextLoad, rememberResult } from './search-store'
|
||||
|
||||
describe('nextLoad', () => {
|
||||
it('条件没变时沿用当前结果,强制刷新时绕过缓存重新请求', () => {
|
||||
expect(nextLoad('a', 'a', true, true, false)).toBeNull()
|
||||
expect(nextLoad('a', 'a', true, true, true)).toEqual({ shown: 'a', loading: true, from: 'request' })
|
||||
})
|
||||
|
||||
it('没有关键词时显示最近搜索,不发出请求', () => {
|
||||
expect(nextLoad(null, 'a', false, true, false)).toEqual({ shown: null, loading: false, from: 'none' })
|
||||
})
|
||||
|
||||
it('回到已经显示过的条件时直接用缓存,不必连着房间', () => {
|
||||
expect(nextLoad('a', 'b', true, false, false)).toEqual({ shown: 'a', loading: false, from: 'cache' })
|
||||
})
|
||||
|
||||
it('没连上房间时推迟请求并让当前结果作废,连上之后回到原来的条件也会重新请求', () => {
|
||||
expect(nextLoad('a', 'b', false, false, false)).toEqual({ shown: null, loading: true, from: 'none' })
|
||||
expect(nextLoad('b', null, false, false, false)).toEqual({ shown: null, loading: true, from: 'none' })
|
||||
expect(nextLoad('b', null, false, true, false)).toEqual({ shown: 'b', loading: true, from: 'request' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('搜索结果的缓存', () => {
|
||||
const minute = 60 * 1000
|
||||
|
||||
it('取回超过 10 分钟的结果失效', () => {
|
||||
const cache = rememberResult(new Map(), 'a', { value: 1, at: 0 }, 0)
|
||||
expect(cachedResult(cache, 'a', 10 * minute - 1)?.value).toBe(1)
|
||||
expect(cachedResult(cache, 'a', 10 * minute)).toBeNull()
|
||||
expect(cachedResult(cache, 'b', 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('最多保留 10 份,丢掉最久没显示的;再次显示的移到最近', () => {
|
||||
let cache = new Map<string, { value: number, at: number }>()
|
||||
for (let i = 0; i < 10; i++)
|
||||
cache = rememberResult(cache, `k${i}`, { value: i, at: i }, i)
|
||||
cache = rememberResult(cache, 'k0', { value: 0, at: 0 }, 10)
|
||||
cache = rememberResult(cache, 'k10', { value: 10, at: 11 }, 11)
|
||||
expect([...cache.keys()]).toEqual(['k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8', 'k9', 'k0', 'k10'])
|
||||
})
|
||||
|
||||
it('记下新结果时去掉已经失效的', () => {
|
||||
const cache = rememberResult(new Map([['old', { value: 1, at: 0 }]]), 'new', { value: 2, at: 10 * minute }, 10 * minute)
|
||||
expect([...cache.keys()]).toEqual(['new'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bookTitle } from './book-title'
|
||||
|
||||
describe('歌名的书名号', () => {
|
||||
it('加上书名号', () => {
|
||||
expect(bookTitle('晴天')).toBe('《晴天》')
|
||||
})
|
||||
|
||||
it('歌名里已经有书名号时不再加', () => {
|
||||
expect(bookTitle('日语填词《5:20AM》(完整版)')).toBe('日语填词《5:20AM》(完整版)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterMarker, afterPop, afterPush, canGoBack, canGoForward } from './session-history'
|
||||
|
||||
describe('历史记录的序号', () => {
|
||||
it('第一条记录不能后退,写入新记录之后可以后退、不能前进', () => {
|
||||
const first = { index: 0, last: 0 }
|
||||
expect(canGoBack(first)).toBe(false)
|
||||
const second = afterPush(first)
|
||||
expect(canGoBack(second)).toBe(true)
|
||||
expect(canGoForward(second)).toBe(false)
|
||||
})
|
||||
|
||||
it('后退之后可以前进,再写入新记录就丢掉了前面的记录', () => {
|
||||
const back = afterPop({ index: 3, last: 3 }, 1)
|
||||
expect(back).toEqual({ index: 1, last: 3 })
|
||||
expect(canGoForward(back)).toBe(true)
|
||||
expect(afterPush(back)).toEqual({ index: 2, last: 2 })
|
||||
})
|
||||
|
||||
it('刷新之后不知道前面的记录,前进到了才知道', () => {
|
||||
expect(afterPop({ index: 1, last: 1 }, 2)).toEqual({ index: 2, last: 2 })
|
||||
})
|
||||
|
||||
it('打开弹窗的标记记录不占序号,同样丢掉前面的记录', () => {
|
||||
const marked = afterMarker({ index: 1, last: 3 })
|
||||
expect(marked).toEqual({ index: 1, last: 1 })
|
||||
expect(canGoForward(marked)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user