mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36: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,
|
immediate: boolean = true,
|
||||||
) => {
|
) => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
let timerStopped = false
|
||||||
|
|
||||||
const wrappedLoadData = async () => {
|
const wrappedLoadData = async () => {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
@@ -282,20 +283,26 @@ export function useBackground() {
|
|||||||
await wrappedLoadData()
|
await wrappedLoadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (timerStopped) return
|
||||||
|
|
||||||
addBackgroundTimer(id, wrappedLoadData, interval, {
|
addBackgroundTimer(id, wrappedLoadData, interval, {
|
||||||
runInBackground: false, // 后台不刷新数据
|
runInBackground: false, // 后台不刷新数据
|
||||||
skipInitialRun: true, // 已经手动执行过了
|
skipInitialRun: true, // 已经手动执行过了
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
const stopTimer = () => {
|
||||||
|
// stop 或卸载后的异步 continuation 不得重新注册全局 timer。
|
||||||
|
timerStopped = true
|
||||||
removeBackgroundTimer(id)
|
removeBackgroundTimer(id)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopTimer)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
refresh: wrappedLoadData,
|
refresh: wrappedLoadData,
|
||||||
stop: () => removeBackgroundTimer(id),
|
stop: stopTimer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
'src/composables/useLlmProviderDirectory.ts',
|
'src/composables/useLlmProviderDirectory.ts',
|
||||||
'src/composables/useDashboardMediaGridCapacity.ts',
|
'src/composables/useDashboardMediaGridCapacity.ts',
|
||||||
|
'src/composables/useBackground.ts',
|
||||||
'src/composables/useKeepAliveRefresh.ts',
|
'src/composables/useKeepAliveRefresh.ts',
|
||||||
'src/composables/useOfflineStatus.ts',
|
'src/composables/useOfflineStatus.ts',
|
||||||
'src/composables/useScheduleProgress.ts',
|
'src/composables/useScheduleProgress.ts',
|
||||||
@@ -446,6 +447,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/composables/useBackground.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/composables/useKeepAliveRefresh.ts': {
|
'src/composables/useKeepAliveRefresh.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user