test(media): cover cards and virtual slides (#558)

This commit is contained in:
InfinityPacer
2026-07-18 18:03:39 +08:00
committed by GitHub
parent 395401e493
commit c491195b19
8 changed files with 1174 additions and 24 deletions
@@ -0,0 +1,162 @@
import { useAuthStore } from '@/stores/auth'
import {
getCachedMediaExistsStatus,
getCachedMediaSubscribeStatus,
setCachedMediaExistsStatus,
setCachedMediaSubscribeStatus,
} from '@/utils/mediaStatusCache'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let keySequence = 0
function nextKey(label: string) {
keySequence += 1
return `${label}:${keySequence}`
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe('media status cache', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-18T00:00:00Z'))
})
it('isolates exists and subscribe values by cache and key within the TTL', async () => {
const sharedKey = nextKey('isolated')
const otherKey = nextKey('other')
const existsLoader = vi.fn().mockResolvedValue(true)
const subscribeLoader = vi.fn().mockResolvedValue(false)
const otherLoader = vi.fn().mockResolvedValue(true)
await expect(getCachedMediaExistsStatus(sharedKey, existsLoader)).resolves.toBe(true)
await expect(getCachedMediaSubscribeStatus(sharedKey, subscribeLoader)).resolves.toBe(false)
await expect(getCachedMediaSubscribeStatus(otherKey, otherLoader)).resolves.toBe(true)
await expect(getCachedMediaExistsStatus(sharedKey, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
await expect(getCachedMediaSubscribeStatus(sharedKey, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
expect(existsLoader).toHaveBeenCalledOnce()
expect(subscribeLoader).toHaveBeenCalledOnce()
expect(otherLoader).toHaveBeenCalledOnce()
})
it('coalesces concurrent requests and retries after a rejected loader', async () => {
const key = nextKey('concurrent')
const first = deferred<boolean>()
const loader = vi.fn().mockReturnValue(first.promise)
const requestA = getCachedMediaExistsStatus(key, loader)
const requestB = getCachedMediaExistsStatus(key, loader)
expect(loader).toHaveBeenCalledOnce()
first.reject(new Error('temporary failure'))
await expect(requestA).rejects.toThrow('temporary failure')
await expect(requestB).rejects.toThrow('temporary failure')
const retryLoader = vi.fn().mockResolvedValue(true)
await expect(getCachedMediaExistsStatus(key, retryLoader)).resolves.toBe(true)
expect(retryLoader).toHaveBeenCalledOnce()
})
it('reloads expired values and lets explicit values replace cached values', async () => {
const existsKey = nextKey('expired')
const subscribeKey = nextKey('explicit')
await expect(getCachedMediaExistsStatus(existsKey, vi.fn().mockResolvedValue(false))).resolves.toBe(false)
vi.advanceTimersByTime(3 * 60 * 1000)
const expiredLoader = vi.fn().mockResolvedValue(true)
await expect(getCachedMediaExistsStatus(existsKey, expiredLoader)).resolves.toBe(true)
setCachedMediaExistsStatus(existsKey, false)
await expect(getCachedMediaExistsStatus(existsKey, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
setCachedMediaSubscribeStatus(subscribeKey, true)
await expect(getCachedMediaSubscribeStatus(subscribeKey, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
})
it('keeps an explicit mutation result when an older status request resolves later', async () => {
const key = nextKey('mutation-race')
const staleRequest = deferred<boolean>()
const pendingStatus = getCachedMediaSubscribeStatus(key, () => staleRequest.promise)
setCachedMediaSubscribeStatus(key, true)
staleRequest.resolve(false)
await expect(pendingStatus).resolves.toBe(true)
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
})
it('keeps an explicit mutation result after its cache TTL when an older request resolves', async () => {
const key = nextKey('expired-mutation-race')
const staleRequest = deferred<boolean>()
const pendingStatus = getCachedMediaSubscribeStatus(key, () => staleRequest.promise)
setCachedMediaSubscribeStatus(key, false)
vi.advanceTimersByTime(3 * 60 * 1000)
staleRequest.resolve(true)
await expect(pendingStatus).resolves.toBe(false)
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(true)
})
it('reloads a completed subscription value after logout and login', async () => {
const key = nextKey('completed-session')
const authStore = useAuthStore()
authStore.login({ token: 'account-a', remember: false })
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(true)
authStore.logout()
authStore.login({ token: 'account-b', remember: false })
const accountBLoader = vi.fn().mockResolvedValue(false)
await expect(getCachedMediaSubscribeStatus(key, accountBLoader)).resolves.toBe(false)
expect(accountBLoader).toHaveBeenCalledOnce()
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
})
it('does not reuse a previous account subscription request or value after logout and login', async () => {
const key = nextKey('session')
const oldAccountRequest = deferred<boolean>()
const newAccountRequest = deferred<boolean>()
const authStore = useAuthStore()
authStore.login({ token: 'account-a', remember: false })
const accountAStatus = getCachedMediaSubscribeStatus(key, () => oldAccountRequest.promise)
authStore.logout()
authStore.login({ token: 'account-b', remember: false })
const accountBLoader = vi.fn().mockReturnValue(newAccountRequest.promise)
const accountBStatus = getCachedMediaSubscribeStatus(key, accountBLoader)
let accountASettled = false
void accountAStatus.then(() => {
accountASettled = true
})
oldAccountRequest.resolve(true)
await Promise.resolve()
await Promise.resolve()
expect(accountASettled).toBe(false)
const repeatedAccountBStatus = getCachedMediaSubscribeStatus(key, accountBLoader)
expect(accountBLoader).toHaveBeenCalledOnce()
newAccountRequest.resolve(false)
await expect(accountAStatus).resolves.toBe(false)
await expect(accountBStatus).resolves.toBe(false)
await expect(repeatedAccountBStatus).resolves.toBe(false)
expect(accountBLoader).toHaveBeenCalledOnce()
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
})
})
+77 -20
View File
@@ -3,75 +3,132 @@ type StatusCacheEntry = {
value: boolean
}
type StatusCacheState = {
entries: Map<string, StatusCacheEntry>
explicitValues: Map<string, boolean>
generation: number
requests: Map<string, Promise<boolean>>
versions: Map<string, number>
}
const STATUS_CACHE_TTL = 3 * 60 * 1000
const existsStatusCache = new Map<string, StatusCacheEntry>()
const existsStatusRequests = new Map<string, Promise<boolean>>()
const subscribeStatusCache = new Map<string, StatusCacheEntry>()
const subscribeStatusRequests = new Map<string, Promise<boolean>>()
const existsStatusState: StatusCacheState = {
entries: new Map(),
explicitValues: new Map(),
generation: 0,
requests: new Map(),
versions: new Map(),
}
function getCachedValue(cache: Map<string, StatusCacheEntry>, key: string): boolean | undefined {
const entry = cache.get(key)
const subscribeStatusState: StatusCacheState = {
entries: new Map(),
explicitValues: new Map(),
generation: 0,
requests: new Map(),
versions: new Map(),
}
function getCachedValue(state: StatusCacheState, key: string): boolean | undefined {
const entry = state.entries.get(key)
if (!entry) {
return undefined
}
if (entry.expiresAt <= Date.now()) {
cache.delete(key)
state.entries.delete(key)
return undefined
}
return entry.value
}
function setCachedValue(cache: Map<string, StatusCacheEntry>, key: string, value: boolean) {
cache.set(key, {
function writeCachedValue(state: StatusCacheState, key: string, value: boolean) {
state.entries.set(key, {
expiresAt: Date.now() + STATUS_CACHE_TTL,
value,
})
}
function setCachedValue(state: StatusCacheState, key: string, value: boolean) {
if (state.requests.has(key)) {
state.versions.set(key, (state.versions.get(key) ?? 0) + 1)
state.explicitValues.set(key, value)
}
writeCachedValue(state, key, value)
}
async function resolveCachedStatus(
cache: Map<string, StatusCacheEntry>,
requests: Map<string, Promise<boolean>>,
state: StatusCacheState,
key: string,
loader: () => Promise<boolean>,
): Promise<boolean> {
const cachedValue = getCachedValue(cache, key)
const cachedValue = getCachedValue(state, key)
if (cachedValue !== undefined) {
return cachedValue
}
const currentRequest = requests.get(key)
const currentRequest = state.requests.get(key)
if (currentRequest) {
return currentRequest
}
const requestGeneration = state.generation
const requestVersion = state.versions.get(key) ?? 0
const requestRef: { current?: Promise<boolean> } = {}
const request = loader()
.then(value => {
setCachedValue(cache, key, value)
// 显式状态写入或会话切换发生后,旧请求只能返回当前状态,不能回写过期结果。
if (state.generation !== requestGeneration) {
const currentRequest = state.requests.get(key)
if (currentRequest && currentRequest !== requestRef.current) {
return currentRequest
}
return getCachedValue(state, key) ?? false
}
if ((state.versions.get(key) ?? 0) !== requestVersion) {
return state.explicitValues.get(key) ?? value
}
writeCachedValue(state, key, value)
return value
})
.finally(() => {
requests.delete(key)
if (state.requests.get(key) === requestRef.current) {
state.requests.delete(key)
state.versions.delete(key)
state.explicitValues.delete(key)
}
})
requests.set(key, request)
requestRef.current = request
state.requests.set(key, request)
return request
}
export function getCachedMediaExistsStatus(key: string, loader: () => Promise<boolean>) {
return resolveCachedStatus(existsStatusCache, existsStatusRequests, key, loader)
return resolveCachedStatus(existsStatusState, key, loader)
}
export function setCachedMediaExistsStatus(key: string, value: boolean) {
setCachedValue(existsStatusCache, key, value)
setCachedValue(existsStatusState, key, value)
}
export function getCachedMediaSubscribeStatus(key: string, loader: () => Promise<boolean>) {
return resolveCachedStatus(subscribeStatusCache, subscribeStatusRequests, key, loader)
return resolveCachedStatus(subscribeStatusState, key, loader)
}
export function setCachedMediaSubscribeStatus(key: string, value: boolean) {
setCachedValue(subscribeStatusCache, key, value)
setCachedValue(subscribeStatusState, key, value)
}
/** 清理当前登录会话拥有的订阅状态,并隔离仍在执行的旧会话请求。 */
export function clearCachedMediaSubscribeStatuses() {
subscribeStatusState.generation += 1
subscribeStatusState.entries.clear()
subscribeStatusState.explicitValues.clear()
subscribeStatusState.requests.clear()
subscribeStatusState.versions.clear()
}