mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix: preserve background timer lifecycle on replacement (#700)
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
addBackgroundTimer,
|
||||
BackgroundManager,
|
||||
getBackgroundTimerStatus,
|
||||
removeBackgroundTimer,
|
||||
} from '@/utils/backgroundManager'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ActivityState = 'active' | 'idle' | 'passive' | 'suspended'
|
||||
type ActivityStateListener = (state: ActivityState) => void
|
||||
|
||||
const lifecycleMocks = vi.hoisted(() => {
|
||||
let state: ActivityState = 'active'
|
||||
let lastActivityAt = 0
|
||||
const listeners = new Set<ActivityStateListener>()
|
||||
const releaseFns: Array<ReturnType<typeof vi.fn>> = []
|
||||
const stopFns: Array<ReturnType<typeof vi.fn>> = []
|
||||
|
||||
const acquire = vi.fn(() => {
|
||||
const release = vi.fn()
|
||||
releaseFns.push(release)
|
||||
return release
|
||||
})
|
||||
const subscribe = vi.fn((listener: ActivityStateListener) => {
|
||||
listeners.add(listener)
|
||||
listener(state)
|
||||
|
||||
const stop = vi.fn(() => {
|
||||
listeners.delete(listener)
|
||||
})
|
||||
stopFns.push(stop)
|
||||
return stop
|
||||
})
|
||||
const getLastActivityAt = vi.fn(() => lastActivityAt)
|
||||
|
||||
return {
|
||||
appActivityLifecycle: {
|
||||
acquire,
|
||||
subscribe,
|
||||
getLastActivityAt,
|
||||
},
|
||||
acquire,
|
||||
subscribe,
|
||||
getLastActivityAt,
|
||||
releaseFns,
|
||||
stopFns,
|
||||
triggerState(nextState: ActivityState) {
|
||||
state = nextState
|
||||
Array.from(listeners).forEach(listener => listener(nextState))
|
||||
},
|
||||
setLastActivityAt(value: number) {
|
||||
lastActivityAt = value
|
||||
},
|
||||
reset() {
|
||||
state = 'active'
|
||||
lastActivityAt = Date.now()
|
||||
listeners.clear()
|
||||
releaseFns.length = 0
|
||||
stopFns.length = 0
|
||||
acquire.mockClear()
|
||||
subscribe.mockClear()
|
||||
getLastActivityAt.mockClear()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/appActivityLifecycle', () => ({
|
||||
appActivityLifecycle: lifecycleMocks.appActivityLifecycle,
|
||||
}))
|
||||
|
||||
describe('BackgroundManager', () => {
|
||||
let manager: BackgroundManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-23T12:00:00.000Z'))
|
||||
lifecycleMocks.reset()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
manager = new BackgroundManager()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('replaces a timer by id without allowing the old callback to run', () => {
|
||||
const oldCallback = vi.fn()
|
||||
const newCallback = vi.fn()
|
||||
|
||||
manager.addTimer('refresh', oldCallback, 100, { skipInitialRun: true })
|
||||
manager.addTimer('refresh', newCallback, 100, { skipInitialRun: true })
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(oldCallback).not.toHaveBeenCalled()
|
||||
expect(newCallback).toHaveBeenCalledOnce()
|
||||
expect(manager.getTimersInfo()).toEqual([
|
||||
{
|
||||
id: 'refresh',
|
||||
interval: 100,
|
||||
status: 'running',
|
||||
runInBackground: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps lifecycle ownership when the only timer is replaced', () => {
|
||||
manager.addTimer('refresh', vi.fn(), 100, { skipInitialRun: true })
|
||||
manager.addTimer('refresh', vi.fn(), 100, { skipInitialRun: true })
|
||||
|
||||
lifecycleMocks.triggerState('suspended')
|
||||
|
||||
expect(manager.getTimerStatus('refresh')).toBe('paused')
|
||||
|
||||
lifecycleMocks.triggerState('active')
|
||||
|
||||
expect(manager.getTimerStatus('refresh')).toBe('running')
|
||||
})
|
||||
|
||||
it('keeps a replacement foreground timer paused while already suspended', () => {
|
||||
const oldCallback = vi.fn()
|
||||
const newCallback = vi.fn()
|
||||
manager.addTimer('refresh', oldCallback, 100, { skipInitialRun: true })
|
||||
lifecycleMocks.triggerState('suspended')
|
||||
|
||||
manager.addTimer('refresh', newCallback, 100, { skipInitialRun: true })
|
||||
|
||||
expect(manager.getTimerStatus('refresh')).toBe('paused')
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(oldCallback).not.toHaveBeenCalled()
|
||||
expect(newCallback).not.toHaveBeenCalled()
|
||||
|
||||
lifecycleMocks.triggerState('active')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(manager.getTimerStatus('refresh')).toBe('running')
|
||||
expect(newCallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs immediately by default and honors skipInitialRun', () => {
|
||||
const immediateCallback = vi.fn()
|
||||
const delayedCallback = vi.fn()
|
||||
|
||||
manager.addTimer('immediate', immediateCallback, 100)
|
||||
manager.addTimer('delayed', delayedCallback, 100, { skipInitialRun: true })
|
||||
|
||||
expect(immediateCallback).toHaveBeenCalledOnce()
|
||||
expect(delayedCallback).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(immediateCallback).toHaveBeenCalledTimes(2)
|
||||
expect(delayedCallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('isolates synchronous callback failures from timer execution', () => {
|
||||
const error = new Error('refresh failed')
|
||||
const failingCallback = vi.fn(() => {
|
||||
throw error
|
||||
})
|
||||
const healthyCallback = vi.fn()
|
||||
|
||||
manager.addTimer('failing', failingCallback, 100, { skipInitialRun: true })
|
||||
manager.addTimer('healthy', healthyCallback, 100, { skipInitialRun: true })
|
||||
|
||||
expect(() => vi.advanceTimersByTime(200)).not.toThrow()
|
||||
|
||||
expect(failingCallback).toHaveBeenCalledTimes(2)
|
||||
expect(healthyCallback).toHaveBeenCalledTimes(2)
|
||||
expect(console.error).toHaveBeenCalledWith('Background: 定时器 failing 执行错误:', error)
|
||||
})
|
||||
|
||||
it('pauses foreground timers while suspended and keeps background timers running', () => {
|
||||
const foregroundCallback = vi.fn()
|
||||
const backgroundCallback = vi.fn()
|
||||
|
||||
manager.addTimer('foreground', foregroundCallback, 100, { skipInitialRun: true })
|
||||
manager.addTimer('background', backgroundCallback, 100, {
|
||||
runInBackground: true,
|
||||
skipInitialRun: true,
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
lifecycleMocks.triggerState('suspended')
|
||||
|
||||
expect(manager.getTimerStatus('foreground')).toBe('paused')
|
||||
expect(manager.getTimerStatus('background')).toBe('running')
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(foregroundCallback).toHaveBeenCalledOnce()
|
||||
expect(backgroundCallback).toHaveBeenCalledTimes(4)
|
||||
|
||||
lifecycleMocks.triggerState('active')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(manager.getTimerStatus('foreground')).toBe('running')
|
||||
expect(foregroundCallback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases lifecycle resources after removing the final timer', () => {
|
||||
const addEventListener = vi.spyOn(window, 'addEventListener')
|
||||
const removeEventListener = vi.spyOn(window, 'removeEventListener')
|
||||
|
||||
manager.addTimer('first', vi.fn(), 100, { skipInitialRun: true })
|
||||
manager.addTimer('second', vi.fn(), 100, { skipInitialRun: true })
|
||||
|
||||
expect(lifecycleMocks.acquire).toHaveBeenCalledOnce()
|
||||
expect(lifecycleMocks.subscribe).toHaveBeenCalledOnce()
|
||||
expect(addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
|
||||
manager.removeTimer('first')
|
||||
|
||||
expect(lifecycleMocks.releaseFns[0]).not.toHaveBeenCalled()
|
||||
expect(lifecycleMocks.stopFns[0]).not.toHaveBeenCalled()
|
||||
|
||||
manager.removeTimer('second')
|
||||
|
||||
expect(lifecycleMocks.releaseFns[0]).toHaveBeenCalledOnce()
|
||||
expect(lifecycleMocks.stopFns[0]).toHaveBeenCalledOnce()
|
||||
expect(removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
expect(manager.getTimersInfo()).toEqual([])
|
||||
})
|
||||
|
||||
it('reports activity time, timer status and active state', () => {
|
||||
const lastActivityAt = Date.now() - 1_000
|
||||
lifecycleMocks.setLastActivityAt(lastActivityAt)
|
||||
manager.addTimer('refresh', vi.fn(), 100, { skipInitialRun: true })
|
||||
|
||||
expect(manager.getLastActivityTime()).toBe(lastActivityAt)
|
||||
expect(manager.isUserActive(2_000)).toBe(true)
|
||||
expect(manager.isUserActive(500)).toBe(false)
|
||||
expect(manager.getTimerStatus('refresh')).toBe('running')
|
||||
expect(manager.getStatus()).toEqual({
|
||||
isBackground: false,
|
||||
isDestroyed: false,
|
||||
timerCount: 1,
|
||||
lastActivityTime: lastActivityAt,
|
||||
isUserActive: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('destroys timers and lifecycle resources, then rejects new timers', () => {
|
||||
const callback = vi.fn()
|
||||
const removeEventListener = vi.spyOn(window, 'removeEventListener')
|
||||
|
||||
manager.addTimer('refresh', callback, 100, { skipInitialRun: true })
|
||||
manager.destroy()
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
isBackground: false,
|
||||
isDestroyed: true,
|
||||
timerCount: 0,
|
||||
})
|
||||
expect(manager.getTimerStatus('refresh')).toBe('not-found')
|
||||
expect(lifecycleMocks.releaseFns[0]).toHaveBeenCalledOnce()
|
||||
expect(lifecycleMocks.stopFns[0]).toHaveBeenCalledOnce()
|
||||
expect(removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
|
||||
const newCallback = vi.fn()
|
||||
manager.addTimer('new', newCallback, 100)
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(newCallback).not.toHaveBeenCalled()
|
||||
expect(manager.getTimerStatus('new')).toBe('not-found')
|
||||
expect(lifecycleMocks.acquire).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exposes the global timer helpers through the public API', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
addBackgroundTimer('global-refresh', callback, 100, { skipInitialRun: true })
|
||||
|
||||
expect(getBackgroundTimerStatus('global-refresh')).toBe('running')
|
||||
|
||||
removeBackgroundTimer('global-refresh')
|
||||
|
||||
expect(getBackgroundTimerStatus('global-refresh')).toBe('not-found')
|
||||
})
|
||||
})
|
||||
@@ -73,9 +73,10 @@ export class BackgroundManager {
|
||||
const { runInBackground = false, skipInitialRun = false } = options
|
||||
|
||||
if (this.isDestroyed) return
|
||||
this.ensureInitialized()
|
||||
|
||||
// 同名 timer 必须先释放;若它是唯一任务,随后重新获取生命周期,确保替换后的 timer 仍接收活动状态。
|
||||
this.removeTimer(id)
|
||||
this.ensureInitialized()
|
||||
|
||||
const timerConfig = {
|
||||
callback,
|
||||
@@ -101,6 +102,10 @@ export class BackgroundManager {
|
||||
timerConfig.timer = setInterval(wrappedCallback, interval)
|
||||
this.timers.set(id, timerConfig)
|
||||
|
||||
if (this.isBackground && !runInBackground) {
|
||||
this.pauseAllTimers()
|
||||
}
|
||||
|
||||
// 如果不跳过初始运行,立即执行一次
|
||||
if (!skipInitialRun) {
|
||||
wrappedCallback()
|
||||
|
||||
@@ -309,6 +309,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/utils/permission.ts',
|
||||
'src/utils/pluginSidebarNav.ts',
|
||||
'src/utils/requestOptimizer.ts',
|
||||
'src/utils/backgroundManager.ts',
|
||||
'src/utils/sseManager.ts',
|
||||
'src/utils/federationLoader.ts',
|
||||
'src/utils/federationRuntime.ts',
|
||||
@@ -907,6 +908,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/utils/backgroundManager.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/utils/sseManager.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
|
||||
Reference in New Issue
Block a user