mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix: prevent background timer registration after stop (#701)
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addBackgroundTimer: vi.fn(),
|
||||
removeBackgroundTimer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/backgroundManager', () => ({
|
||||
addBackgroundTimer: mocks.addBackgroundTimer,
|
||||
removeBackgroundTimer: mocks.removeBackgroundTimer,
|
||||
}))
|
||||
|
||||
const wrappers: VueWrapper[] = []
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function mountComposable<T>(createComposable: () => T) {
|
||||
let result!: T
|
||||
const Harness = defineComponent({
|
||||
name: 'UseBackgroundRefreshHarness',
|
||||
setup() {
|
||||
result = createComposable()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Harness)
|
||||
wrappers.push(wrapper)
|
||||
|
||||
return { result, wrapper }
|
||||
}
|
||||
|
||||
describe('useBackground useTimer', () => {
|
||||
beforeEach(() => {
|
||||
mocks.addBackgroundTimer.mockReset()
|
||||
mocks.removeBackgroundTimer.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of wrappers.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
it('在组件挂载后注册带完整选项的后台定时器', async () => {
|
||||
const callback = vi.fn()
|
||||
mountComposable(() =>
|
||||
useBackground().useTimer('timer-a', callback, 1000, {
|
||||
runInBackground: true,
|
||||
skipInitialRun: true,
|
||||
}),
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('timer-a', callback, 1000, {
|
||||
runInBackground: true,
|
||||
skipInitialRun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('在组件卸载时移除后台定时器', async () => {
|
||||
const { wrapper } = mountComposable(() => useBackground().useTimer('timer-b', vi.fn(), 2000))
|
||||
await nextTick()
|
||||
mocks.removeBackgroundTimer.mockClear()
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('timer-b')
|
||||
})
|
||||
|
||||
it('暴露手动移除定时器的操作', async () => {
|
||||
const { result } = mountComposable(() => useBackground().useTimer('timer-c', vi.fn(), 3000))
|
||||
await nextTick()
|
||||
mocks.removeBackgroundTimer.mockClear()
|
||||
|
||||
result.remove()
|
||||
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('timer-c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBackground useDataRefresh', () => {
|
||||
beforeEach(() => {
|
||||
mocks.addBackgroundTimer.mockReset()
|
||||
mocks.removeBackgroundTimer.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of wrappers.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
it('immediate 模式先完成首轮加载,再注册跳过初始执行的定时器', async () => {
|
||||
const loadData = vi.fn().mockResolvedValue(undefined)
|
||||
mountComposable(() => useBackground().useDataRefresh('data-a', loadData, 4000, true))
|
||||
|
||||
expect(loadData).toHaveBeenCalledOnce()
|
||||
expect(mocks.addBackgroundTimer).not.toHaveBeenCalled()
|
||||
|
||||
await settle()
|
||||
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('data-a', expect.any(Function), 4000, {
|
||||
runInBackground: false,
|
||||
skipInitialRun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('非 immediate 模式直接注册定时器且不执行首轮加载', async () => {
|
||||
const loadData = vi.fn().mockResolvedValue(undefined)
|
||||
mountComposable(() => useBackground().useDataRefresh('data-b', loadData, 5000, false))
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(loadData).not.toHaveBeenCalled()
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('data-b', expect.any(Function), 5000, {
|
||||
runInBackground: false,
|
||||
skipInitialRun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('并发 refresh 只允许一个加载执行,并在完成后释放 loading', async () => {
|
||||
const pending = deferred()
|
||||
const loadData = vi.fn(() => pending.promise)
|
||||
const { result } = mountComposable(() => useBackground().useDataRefresh('data-c', loadData, 6000, false))
|
||||
await nextTick()
|
||||
|
||||
const firstRefresh = result.refresh()
|
||||
const secondRefresh = result.refresh()
|
||||
|
||||
expect(loadData).toHaveBeenCalledOnce()
|
||||
expect(result.loading.value).toBe(true)
|
||||
|
||||
pending.resolve()
|
||||
await Promise.all([firstRefresh, secondRefresh])
|
||||
|
||||
expect(result.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('收口加载错误并恢复 loading 状态', async () => {
|
||||
const error = new Error('load failed')
|
||||
const loadData = vi.fn().mockRejectedValue(error)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { result } = mountComposable(() => useBackground().useDataRefresh('data-d', loadData, 7000, false))
|
||||
await nextTick()
|
||||
|
||||
await expect(result.refresh()).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith('数据刷新失败 [data-d]:', error)
|
||||
expect(result.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('暴露 stop 操作并在卸载时清理定时器', async () => {
|
||||
const { result, wrapper } = mountComposable(() => useBackground().useDataRefresh('data-e', vi.fn(), 8000, false))
|
||||
await nextTick()
|
||||
mocks.removeBackgroundTimer.mockClear()
|
||||
|
||||
result.stop()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('data-e')
|
||||
|
||||
mocks.removeBackgroundTimer.mockClear()
|
||||
wrapper.unmount()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('data-e')
|
||||
})
|
||||
|
||||
it('首轮异步加载未完成时卸载,完成后不得重新注册定时器', async () => {
|
||||
const initialLoad = deferred()
|
||||
const loadData = vi.fn(() => initialLoad.promise)
|
||||
const { wrapper } = mountComposable(() => useBackground().useDataRefresh('data-race', loadData, 9000, true))
|
||||
|
||||
expect(loadData).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
|
||||
initialLoad.resolve()
|
||||
await settle()
|
||||
|
||||
expect(mocks.addBackgroundTimer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('首轮异步加载未完成时 stop,完成后不得重新注册定时器', async () => {
|
||||
const initialLoad = deferred()
|
||||
const loadData = vi.fn(() => initialLoad.promise)
|
||||
const { result } = mountComposable(() => useBackground().useDataRefresh('data-stop-race', loadData, 9000, true))
|
||||
|
||||
expect(loadData).toHaveBeenCalledOnce()
|
||||
result.stop()
|
||||
|
||||
initialLoad.resolve()
|
||||
await settle()
|
||||
|
||||
expect(mocks.addBackgroundTimer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBackground useConditionalDataRefresh', () => {
|
||||
beforeEach(() => {
|
||||
mocks.addBackgroundTimer.mockReset()
|
||||
mocks.removeBackgroundTimer.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of wrappers.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, true, true],
|
||||
[true, false, true],
|
||||
[false, true, false],
|
||||
[false, false, false],
|
||||
])('根据初始 condition=%s 决定是否注册定时器(immediate=%s)', async (conditionValue, immediate, shouldStart) => {
|
||||
const condition = ref(conditionValue)
|
||||
const { result } = mountComposable(() =>
|
||||
useBackground().useConditionalDataRefresh('conditional-initial', vi.fn(), condition, 1000, immediate),
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledTimes(shouldStart ? 1 : 0)
|
||||
expect(result.isActive.value).toBe(shouldStart)
|
||||
if (shouldStart) {
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('conditional-initial', expect.any(Function), 1000, {
|
||||
runInBackground: false,
|
||||
skipInitialRun: !immediate,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('随 condition 切换启动和停止定时器', async () => {
|
||||
const condition = ref(false)
|
||||
const { result } = mountComposable(() =>
|
||||
useBackground().useConditionalDataRefresh('conditional-toggle', vi.fn(), condition),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
condition.value = true
|
||||
await nextTick()
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(result.isActive.value).toBe(true)
|
||||
|
||||
condition.value = false
|
||||
await nextTick()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('conditional-toggle')
|
||||
expect(result.isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('start 和 stop 具有幂等性', async () => {
|
||||
const condition = ref(true)
|
||||
const { result } = mountComposable(() =>
|
||||
useBackground().useConditionalDataRefresh('conditional-idempotent', vi.fn(), condition),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
result.stop()
|
||||
mocks.addBackgroundTimer.mockClear()
|
||||
mocks.removeBackgroundTimer.mockClear()
|
||||
|
||||
result.start()
|
||||
result.start()
|
||||
expect(mocks.addBackgroundTimer).toHaveBeenCalledOnce()
|
||||
|
||||
result.stop()
|
||||
result.stop()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('仅在 condition 为 true 时允许 refresh,并保持单飞行为', async () => {
|
||||
const condition = ref(false)
|
||||
const pending = deferred()
|
||||
const loadData = vi.fn(() => pending.promise)
|
||||
const { result } = mountComposable(() =>
|
||||
useBackground().useConditionalDataRefresh('conditional-refresh', loadData, condition, 1000, false),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
await result.refresh()
|
||||
expect(loadData).not.toHaveBeenCalled()
|
||||
|
||||
condition.value = true
|
||||
const firstRefresh = result.refresh()
|
||||
const secondRefresh = result.refresh()
|
||||
expect(loadData).toHaveBeenCalledOnce()
|
||||
expect(result.loading.value).toBe(true)
|
||||
|
||||
pending.resolve()
|
||||
await Promise.all([firstRefresh, secondRefresh])
|
||||
expect(result.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('收口错误并在卸载时清理活动定时器', async () => {
|
||||
const condition = ref(true)
|
||||
const error = new Error('conditional load failed')
|
||||
const loadData = vi.fn().mockRejectedValue(error)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { result, wrapper } = mountComposable(() =>
|
||||
useBackground().useConditionalDataRefresh('conditional-error', loadData, condition),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
await expect(result.refresh()).resolves.toBeUndefined()
|
||||
expect(consoleError).toHaveBeenCalledWith('条件数据刷新失败 [conditional-error]:', error)
|
||||
expect(result.loading.value).toBe(false)
|
||||
|
||||
mocks.removeBackgroundTimer.mockClear()
|
||||
wrapper.unmount()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce()
|
||||
expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('conditional-error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,372 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, type Ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
type SSEStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed'
|
||||
type StatusListener = (status: SSEStatus) => void
|
||||
type MessageListener = (event: MessageEvent) => void
|
||||
|
||||
interface MockManager {
|
||||
url: string
|
||||
options?: Record<string, unknown>
|
||||
readyState: number
|
||||
addStatusListener: ReturnType<typeof vi.fn>
|
||||
removeStatusListener: ReturnType<typeof vi.fn>
|
||||
addMessageListener: ReturnType<typeof vi.fn>
|
||||
removeMessageListener: ReturnType<typeof vi.fn>
|
||||
forceReconnect: ReturnType<typeof vi.fn>
|
||||
emitStatus: (status: SSEStatus) => void
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
const { closeIndependentManager, getCurrentLocaleMock, getIndependentManager, managers } = vi.hoisted(() => {
|
||||
const managers = new Map<string, MockManager>()
|
||||
|
||||
const createManager = (url: string, options?: Record<string, unknown>): MockManager => {
|
||||
let status: SSEStatus = 'idle'
|
||||
let statusListener: StatusListener | undefined
|
||||
let messageListener: MessageListener | undefined
|
||||
|
||||
const manager = {
|
||||
url,
|
||||
options,
|
||||
readyState: 0,
|
||||
closed: false,
|
||||
addStatusListener: vi.fn((_id: string, listener: StatusListener, emitCurrent = true) => {
|
||||
statusListener = listener
|
||||
if (emitCurrent) listener(status)
|
||||
}),
|
||||
removeStatusListener: vi.fn(() => {
|
||||
statusListener = undefined
|
||||
}),
|
||||
addMessageListener: vi.fn((_id: string, listener: MessageListener) => {
|
||||
messageListener = listener
|
||||
}),
|
||||
removeMessageListener: vi.fn(() => {
|
||||
messageListener = undefined
|
||||
}),
|
||||
forceReconnect: vi.fn(),
|
||||
emitStatus: (nextStatus: SSEStatus) => {
|
||||
status = nextStatus
|
||||
statusListener?.(nextStatus)
|
||||
},
|
||||
}
|
||||
|
||||
void messageListener
|
||||
return manager
|
||||
}
|
||||
|
||||
const getIndependentManager = vi.fn((url: string, listenerId: string, options?: Record<string, unknown>) => {
|
||||
const key = `${url}::${listenerId}`
|
||||
const existing = managers.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const manager = createManager(url, options)
|
||||
managers.set(key, manager)
|
||||
return manager
|
||||
})
|
||||
|
||||
const closeIndependentManager = vi.fn((url: string, listenerId: string) => {
|
||||
const key = `${url}::${listenerId}`
|
||||
const manager = managers.get(key)
|
||||
if (manager) manager.closed = true
|
||||
managers.delete(key)
|
||||
})
|
||||
|
||||
return {
|
||||
closeIndependentManager,
|
||||
getCurrentLocaleMock: vi.fn(() => 'zh-CN'),
|
||||
getIndependentManager,
|
||||
managers,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/sseManager', () => ({
|
||||
sseManagerSingleton: {
|
||||
closeIndependentManager,
|
||||
getIndependentManager,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
getCurrentLocale: getCurrentLocaleMock,
|
||||
}))
|
||||
|
||||
type Background = ReturnType<typeof useBackground>
|
||||
type UseSSEResult = ReturnType<Background['useSSE']>
|
||||
type UseDelayedSSEResult = ReturnType<Background['useDelayedSSE']>
|
||||
type UseProgressSSEResult = ReturnType<Background['useProgressSSE']>
|
||||
|
||||
const wrappers: VueWrapper[] = []
|
||||
|
||||
function managerKey(url: string, listenerId: string) {
|
||||
return `${url}::${listenerId}`
|
||||
}
|
||||
|
||||
function mountSSE(url = '/api/v1/events', listenerId = 'sse-listener', options?: Parameters<Background['useSSE']>[3]) {
|
||||
let result!: UseSSEResult
|
||||
const messageHandler = vi.fn<(event: MessageEvent) => void>()
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useBackground().useSSE(url, messageHandler, listenerId, options)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Component)
|
||||
wrappers.push(wrapper)
|
||||
return { messageHandler, result, wrapper }
|
||||
}
|
||||
|
||||
function mountDelayedSSE(url = '/api/v1/delayed-events', listenerId = 'delayed-listener', delay = 3_000) {
|
||||
let result!: UseDelayedSSEResult
|
||||
const messageHandler = vi.fn<(event: MessageEvent) => void>()
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useBackground().useDelayedSSE(url, messageHandler, listenerId, delay)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Component)
|
||||
wrappers.push(wrapper)
|
||||
return { messageHandler, result, wrapper }
|
||||
}
|
||||
|
||||
function mountProgressSSE(active: Ref<boolean>, url = '/api/v1/progress', listenerId = 'progress-listener') {
|
||||
let result!: UseProgressSSEResult
|
||||
const messageHandler = vi.fn<(event: MessageEvent) => void>()
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
result = useBackground().useProgressSSE(url, messageHandler, listenerId, active)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Component)
|
||||
wrappers.push(wrapper)
|
||||
return { messageHandler, result, wrapper }
|
||||
}
|
||||
|
||||
function unmount(wrapper: VueWrapper) {
|
||||
const index = wrappers.indexOf(wrapper)
|
||||
if (index >= 0) wrappers.splice(index, 1)
|
||||
wrapper.unmount()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
managers.clear()
|
||||
getCurrentLocaleMock.mockReturnValue('zh-CN')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of wrappers.splice(0)) wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useBackground SSE composables', () => {
|
||||
describe('useSSE', () => {
|
||||
it('延迟注册消息监听器并透传 manager options', async () => {
|
||||
const options = { connectDelay: 25, reconnectDelay: 800 }
|
||||
const { messageHandler, result } = mountSSE('/api/v1/events', 'events', options)
|
||||
const manager = managers.get(managerKey('/api/v1/events', 'events'))!
|
||||
|
||||
expect(getIndependentManager).toHaveBeenCalledWith('/api/v1/events', 'events', options)
|
||||
expect(manager.addMessageListener).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
expect(manager.addMessageListener).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(manager.addMessageListener).toHaveBeenCalledWith('events', messageHandler)
|
||||
unmount(wrappers[0])
|
||||
expect(result.isConnected.value).toBe(false)
|
||||
})
|
||||
|
||||
it('映射连接状态并透传 readyState 与 forceReconnect', () => {
|
||||
const { result, wrapper } = mountSSE()
|
||||
const manager = managers.get(managerKey('/api/v1/events', 'sse-listener'))!
|
||||
|
||||
manager.emitStatus('connecting')
|
||||
expect(result.isConnected.value).toBe(false)
|
||||
manager.emitStatus('open')
|
||||
expect(result.isConnected.value).toBe(true)
|
||||
manager.emitStatus('closed')
|
||||
expect(result.isConnected.value).toBe(false)
|
||||
|
||||
manager.readyState = 1
|
||||
expect(result.readyState()).toBe(1)
|
||||
result.forceReconnect()
|
||||
expect(manager.forceReconnect).toHaveBeenCalledOnce()
|
||||
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('隔离延迟连接时的 manager 异常', async () => {
|
||||
const { result, wrapper } = mountSSE('/api/v1/events', 'throwing-listener', { connectDelay: 1 })
|
||||
const manager = managers.get(managerKey('/api/v1/events', 'throwing-listener'))!
|
||||
const error = new Error('connect failed')
|
||||
manager.addMessageListener.mockImplementationOnce(() => {
|
||||
throw error
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith('SSE连接建立失败:', error)
|
||||
expect(result.isConnected.value).toBe(false)
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('close 是幂等的', () => {
|
||||
const { result, wrapper } = mountSSE()
|
||||
const manager = managers.get(managerKey('/api/v1/events', 'sse-listener'))!
|
||||
|
||||
result.close()
|
||||
result.close()
|
||||
|
||||
expect(manager.removeStatusListener).toHaveBeenCalledOnce()
|
||||
expect(manager.removeMessageListener).toHaveBeenCalledOnce()
|
||||
expect(closeIndependentManager).toHaveBeenCalledOnce()
|
||||
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('提前卸载会取消延迟连接并清理 manager', async () => {
|
||||
const { wrapper } = mountSSE('/api/v1/events', 'early-unmount', { connectDelay: 100 })
|
||||
const manager = managers.get(managerKey('/api/v1/events', 'early-unmount'))!
|
||||
|
||||
unmount(wrapper)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(manager.addMessageListener).not.toHaveBeenCalled()
|
||||
expect(manager.removeStatusListener).toHaveBeenCalledOnce()
|
||||
expect(manager.removeMessageListener).toHaveBeenCalledOnce()
|
||||
expect(closeIndependentManager).toHaveBeenCalledWith('/api/v1/events', 'early-unmount')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDelayedSSE', () => {
|
||||
it('使用自定义 delay 注册消息监听器', async () => {
|
||||
const { messageHandler, result, wrapper } = mountDelayedSSE('/api/v1/delayed', 'delayed', 40)
|
||||
const manager = managers.get(managerKey('/api/v1/delayed', 'delayed'))!
|
||||
|
||||
await vi.advanceTimersByTimeAsync(39)
|
||||
expect(manager.addMessageListener).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(manager.addMessageListener).toHaveBeenCalledWith('delayed', messageHandler)
|
||||
expect(result.isConnected.value).toBe(false)
|
||||
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('映射状态并在卸载时清理监听器和延迟任务', async () => {
|
||||
const { result, wrapper } = mountDelayedSSE('/api/v1/delayed', 'delayed-state', 100)
|
||||
const manager = managers.get(managerKey('/api/v1/delayed', 'delayed-state'))!
|
||||
|
||||
manager.emitStatus('open')
|
||||
expect(result.isConnected.value).toBe(true)
|
||||
unmount(wrapper)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(manager.addMessageListener).not.toHaveBeenCalled()
|
||||
expect(manager.removeStatusListener).toHaveBeenCalledOnce()
|
||||
expect(manager.removeMessageListener).toHaveBeenCalledOnce()
|
||||
expect(closeIndependentManager).toHaveBeenCalledWith('/api/v1/delayed', 'delayed-state')
|
||||
expect(result.isConnected.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProgressSSE', () => {
|
||||
it('按活动状态惰性创建 manager,并避免重复 start', () => {
|
||||
const active = ref(false)
|
||||
const { result, wrapper } = mountProgressSSE(active)
|
||||
|
||||
result.start()
|
||||
expect(getIndependentManager).not.toHaveBeenCalled()
|
||||
|
||||
active.value = true
|
||||
result.start()
|
||||
result.start()
|
||||
|
||||
const managerUrl = new URL('/api/v1/progress', window.location.origin)
|
||||
managerUrl.searchParams.set('locale', 'zh-CN')
|
||||
const manager = managers.get(managerKey(managerUrl.toString(), 'progress-listener'))!
|
||||
expect(getIndependentManager).toHaveBeenCalledOnce()
|
||||
expect(manager.addMessageListener).toHaveBeenCalledOnce()
|
||||
expect(result.manager).toBe(manager)
|
||||
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('为已有 query 的 URL 追加 locale,并传递进度连接选项', () => {
|
||||
const active = ref(true)
|
||||
const { result, wrapper } = mountProgressSSE(active, '/api/v1/progress?task=42', 'progress-query')
|
||||
result.start()
|
||||
|
||||
const expectedUrl = new URL('/api/v1/progress?task=42', window.location.origin)
|
||||
expectedUrl.searchParams.set('locale', 'zh-CN')
|
||||
expect(getIndependentManager).toHaveBeenCalledWith(expectedUrl.toString(), 'progress-query', {
|
||||
backgroundCloseDelay: 1_000,
|
||||
reconnectDelay: 1_000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('URL 无法解析时保留原始地址并使用 query fallback', () => {
|
||||
const active = ref(true)
|
||||
const malformedUrl = 'https://[invalid?task=42'
|
||||
const { result, wrapper } = mountProgressSSE(active, malformedUrl, 'progress-fallback')
|
||||
result.start()
|
||||
|
||||
expect(getIndependentManager).toHaveBeenCalledWith(
|
||||
`${malformedUrl}&locale=zh-CN`,
|
||||
'progress-fallback',
|
||||
expect.any(Object),
|
||||
)
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('stop(false) 保留 manager,stop(true) 销毁 manager', () => {
|
||||
const active = ref(true)
|
||||
const { result, wrapper } = mountProgressSSE(active, '/api/v1/progress', 'progress-stop')
|
||||
result.start()
|
||||
const manager = result.manager!
|
||||
const managerUrl = new URL('/api/v1/progress', window.location.origin)
|
||||
managerUrl.searchParams.set('locale', 'zh-CN')
|
||||
|
||||
result.stop(false)
|
||||
expect(manager.removeMessageListener).toHaveBeenCalledOnce()
|
||||
expect(closeIndependentManager).not.toHaveBeenCalled()
|
||||
expect(result.manager).toBe(manager)
|
||||
|
||||
result.start()
|
||||
expect(manager.addMessageListener).toHaveBeenCalledTimes(2)
|
||||
|
||||
result.stop(true)
|
||||
expect(closeIndependentManager).toHaveBeenCalledWith(managerUrl.toString(), 'progress-stop')
|
||||
expect(result.manager).toBeNull()
|
||||
|
||||
unmount(wrapper)
|
||||
})
|
||||
|
||||
it('setup scope 卸载时停止监听并销毁 manager', () => {
|
||||
const active = ref(true)
|
||||
const { result, wrapper } = mountProgressSSE(active, '/api/v1/progress', 'progress-unmount')
|
||||
result.start()
|
||||
const manager = result.manager!
|
||||
|
||||
unmount(wrapper)
|
||||
|
||||
expect(manager.removeMessageListener).toHaveBeenCalledOnce()
|
||||
expect(closeIndependentManager).toHaveBeenCalledOnce()
|
||||
expect(result.manager).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -263,6 +263,7 @@ export function useBackground() {
|
||||
immediate: boolean = true,
|
||||
) => {
|
||||
const loading = ref(false)
|
||||
let timerStopped = false
|
||||
|
||||
const wrappedLoadData = async () => {
|
||||
if (loading.value) return
|
||||
@@ -282,20 +283,26 @@ export function useBackground() {
|
||||
await wrappedLoadData()
|
||||
}
|
||||
|
||||
if (timerStopped) return
|
||||
|
||||
addBackgroundTimer(id, wrappedLoadData, interval, {
|
||||
runInBackground: false, // 后台不刷新数据
|
||||
skipInitialRun: true, // 已经手动执行过了
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const stopTimer = () => {
|
||||
// stop 或卸载后的异步 continuation 不得重新注册全局 timer。
|
||||
timerStopped = true
|
||||
removeBackgroundTimer(id)
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(stopTimer)
|
||||
|
||||
return {
|
||||
loading,
|
||||
refresh: wrappedLoadData,
|
||||
stop: () => removeBackgroundTimer(id),
|
||||
stop: stopTimer,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/composables/useMediaSubscribe.ts',
|
||||
'src/composables/useLlmProviderDirectory.ts',
|
||||
'src/composables/useDashboardMediaGridCapacity.ts',
|
||||
'src/composables/useBackground.ts',
|
||||
'src/composables/useKeepAliveRefresh.ts',
|
||||
'src/composables/useOfflineStatus.ts',
|
||||
'src/composables/useScheduleProgress.ts',
|
||||
@@ -446,6 +447,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/composables/useBackground.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/composables/useKeepAliveRefresh.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
|
||||
Reference in New Issue
Block a user