diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index f79fc912..f6a1b5e7 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -312,7 +312,7 @@ export function useTaskProgressLoop( } function scheduleNextDelayed(delayMs: number) { - if (disposed || pollTimer != null) return + if (disposed) return clearPollTimer() const run = () => { pollTimer = null diff --git a/frontend-vue/tests/polling-visibility.test.ts b/frontend-vue/tests/polling-visibility.test.ts new file mode 100644 index 00000000..de066c01 --- /dev/null +++ b/frontend-vue/tests/polling-visibility.test.ts @@ -0,0 +1,189 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' +import { + TASK_POLL_VISIBLE_INTERVAL_MS, + TASK_POLL_HIDDEN_INTERVAL_MS, + configureTaskPolling, + resetTaskPollingConfig, +} from '../src/shared/task-progress-config.ts' + +const tick = (ms = 0) => new Promise((r) => globalThis.setTimeout(r, ms)) + +interface FakeDoc { + setVisibility: (state: string) => void + dispatch: (type: string) => void + listenerCount: (type: string) => number + listeners: (type: string) => Array<(e?: unknown) => void> +} + +function installFakeDocument(initial: string): FakeDoc { + let state = initial + const listeners = new Map void>>() + ;(globalThis as Record).document = { + get visibilityState() { return state }, + get hidden() { return state !== 'visible' }, + addEventListener: (type: string, fn: (e?: unknown) => void) => { + const arr = listeners.get(type) ?? [] + arr.push(fn) + listeners.set(type, arr) + }, + removeEventListener: (type: string, fn: (e?: unknown) => void) => { + const arr = listeners.get(type) ?? [] + listeners.set(type, arr.filter((f) => f !== fn)) + }, + } + return { + setVisibility: (s) => { state = s }, + dispatch: (type) => { for (const fn of listeners.get(type) ?? []) fn() }, + listenerCount: (type) => (listeners.get(type) ?? []).length, + listeners: (type) => listeners.get(type) ?? [], + } +} + +function cleanupDocument() { + delete (globalThis as Record).document + resetTaskPollingConfig() +} + +interface TimerSinkEntry { + fn: () => void + ms: number +} + +let scopeCounter = 0 + +interface VisibilityHarness { + loop: ReturnType> + doc: FakeDoc + sink: TimerSinkEntry[] + fetchCount: () => number +} + +async function makeVisibilityLoop( + visibility: string, + config?: { enabled?: boolean; delay?: number }, +): Promise { + if (config) { + const partial: Parameters[0] = {} + if (config.enabled !== undefined) partial.foregroundRefreshEnabled = config.enabled + if (config.delay !== undefined) partial.foregroundRefreshDelayMs = config.delay + configureTaskPolling(partial) + } + const doc = installFakeDocument(visibility) + const sink: TimerSinkEntry[] = [] + const storage = new Map() + let fetchCount = 0 + ;(globalThis as Record).window = { + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + }, + setTimeout: (fn: () => void, ms: number) => { + sink.push({ fn, ms }) + return globalThis.setTimeout(fn, ms) + }, + clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number), + setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms), + clearInterval: (id: unknown) => globalThis.clearInterval(id as number), + } + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `vis-${scopeCounter++}`, + fetchProgress: async () => { + fetchCount += 1 + return { items: [] } + }, + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + }) + loop.reset([1]) + return { loop, doc, sink, fetchCount: () => fetchCount } +} + +test('test_hidden_long_interval', async () => { + const h = await makeVisibilityLoop('hidden') + await tick(10) + const last = h.sink.at(-1) + assert.equal(last?.ms, TASK_POLL_HIDDEN_INTERVAL_MS, '隐藏时按隐藏间隔排定下一轮') + h.loop.dispose() + cleanupDocument() +}) + +test('test_visible_short_interval', async () => { + const h = await makeVisibilityLoop('visible') + await tick(10) + const last = h.sink.at(-1) + assert.equal(last?.ms, TASK_POLL_VISIBLE_INTERVAL_MS, '可见时按可见间隔排定下一轮') + h.loop.dispose() + cleanupDocument() +}) + +test('test_foreground_refresh_triggered', async () => { + const h = await makeVisibilityLoop('hidden') + await tick(10) + assert.equal(h.fetchCount(), 1, '初始一轮已完成') + h.doc.setVisibility('visible') + h.doc.dispatch('visibilitychange') + await tick(10) + assert.equal(h.fetchCount(), 2, '切回可见立即刷新一轮') + h.loop.dispose() + cleanupDocument() +}) + +test('test_foreground_refresh_disabled', async () => { + const h = await makeVisibilityLoop('hidden', { enabled: false }) + await tick(10) + h.doc.setVisibility('visible') + h.doc.dispatch('visibilitychange') + await tick(10) + assert.equal(h.fetchCount(), 1, '开关关闭时切回可见不刷新') + h.loop.dispose() + cleanupDocument() +}) + +test('test_foreground_refresh_delayed', async () => { + const h = await makeVisibilityLoop('hidden', { delay: 300 }) + await tick(10) + h.doc.setVisibility('visible') + h.doc.dispatch('visibilitychange') + assert.ok(h.sink.some((t) => t.ms === 300), '按配置延迟排定刷新定时器') + await tick(100) + assert.equal(h.fetchCount(), 1, '延迟期内不刷新') + await tick(300) + assert.equal(h.fetchCount(), 2, '延迟到点后刷新一轮') + h.loop.dispose() + cleanupDocument() +}) + +test('test_hidden_polling_continues', async () => { + const h = await makeVisibilityLoop('hidden') + await tick(10) + assert.ok(h.sink.some((t) => t.ms === TASK_POLL_HIDDEN_INTERVAL_MS), '隐藏间隔定时器已排定') + const before = h.fetchCount() + await h.loop.refreshOnce() + assert.equal(h.fetchCount(), before + 1, '隐藏期间手动轮询仍工作') + assert.deepEqual(h.loop.taskIds.value, [1], '降频不改任务集合') + h.loop.dispose() + cleanupDocument() +}) + +test('test_visibility_event_bound_unbound', async () => { + const h = await makeVisibilityLoop('hidden') + assert.equal(h.doc.listenerCount('visibilitychange'), 1, '创建时绑定 visibilitychange') + const handler = h.doc.listeners('visibilitychange')[0] + h.loop.dispose() + assert.equal(h.doc.listenerCount('visibilitychange'), 0, 'dispose 后解绑') + assert.equal(typeof handler, 'function') + cleanupDocument() +}) + +test('test_hidden_no_immediate_refresh', async () => { + const h = await makeVisibilityLoop('hidden') + await tick(10) + h.doc.dispatch('visibilitychange') + await tick(10) + assert.equal(h.fetchCount(), 1, '隐藏状态下的事件不触发即时刷新') + h.loop.dispose() + cleanupDocument() +})