mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-03 14:37:00 +08:00
test(media): cover cards and virtual slides (#558)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user