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, getTaskPollBackoffMs, } from '../src/shared/task-progress-config.ts' const tick = (ms = 0) => new Promise((r) => globalThis.setTimeout(r, ms)) interface SinkEntry { fn: () => void ms: number id: unknown } function setupWindow(sink?: SinkEntry[]) { const storage = new Map() ;(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) => { const id = globalThis.setTimeout(fn, ms) sink?.push({ fn, ms, id }) return id }, 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), } } interface FakeDoc { setVisibility: (s: string) => void dispatch: () => void } function installFakeDocument(): FakeDoc { let state = 'visible' 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: () => { for (const fn of listeners.get('visibilitychange') ?? []) fn() }, } } let scopeCounter = 0 type Item = { taskId?: number; status?: string } interface SeqHarness { loop: ReturnType> attempts: number[] terminalCalls: Array<{ taskId: number; status: string }> } /** 顺序驱动的轮询循环:fetchImpl 依状态返回成功/失败;自动首轮会被吸收 */ function makeSequenceLoop( fetchImpl: () => Promise<{ items: Item[] }>, sink?: SinkEntry[], ): SeqHarness { setupWindow(sink) const attempts: number[] = [] const terminalCalls: Array<{ taskId: number; status: string }> = [] const loop = useTaskProgressLoop({ scope: `recover-${scopeCounter++}`, fetchProgress: async () => fetchImpl(), extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => TASK_POLL_VISIBLE_INTERVAL_MS, onTerminal: (taskId, _d, status) => { terminalCalls.push({ taskId, status }) }, onError: (_e, attempt) => { attempts.push(attempt ?? -1) }, }) loop.reset([1]) return { loop, attempts, terminalCalls } } test('test_recovery_after_failure', async () => { let fail = true const h = makeSequenceLoop(async () => { if (fail) throw new Error('boom') return { items: [{ taskId: 1, status: 'SUCCESS' }] } }) await tick(10) assert.equal(h.attempts.length, 1, '首轮自动轮询失败计入 attempt 0') fail = false await h.loop.refreshOnce() assert.equal(h.terminalCalls.length, 1, '失败后成功恢复触发终态') assert.equal(h.terminalCalls[0].status, 'SUCCESS') assert.deepEqual(h.loop.taskIds.value, [], '终态任务移除') h.loop.dispose() }) test('test_backoff_reset_after_success', async () => { let mode: 'fail' | 'ok' = 'fail' const h = makeSequenceLoop(async () => { if (mode === 'fail') throw new Error('x') return { items: [] } }) await tick(10) await h.loop.refreshOnce() mode = 'ok' await h.loop.refreshOnce() mode = 'fail' await h.loop.refreshOnce() assert.deepEqual(h.attempts, [0, 1, 0], '成功清零后下一次失败从 attempt 0 起') h.loop.dispose() }) test('test_interval_returns_to_base', async () => { const sink: SinkEntry[] = [] let fail = true const h = makeSequenceLoop(async () => { if (fail) throw new Error('x') return { items: [] } }, sink) await tick(10) assert.equal(h.attempts.length, 1, '首轮失败') assert.equal(sink.at(-1)?.ms, getTaskPollBackoffMs(1), '失败后按退避间隔续轮(后端故障时不再高频撞库)') fail = false // 手动触发下一轮自动轮询(模拟定时器到点;先清掉真实定时器避免僵尸 timer) const last = sink.at(-1) as SinkEntry globalThis.clearTimeout(last.id) last.fn() await tick(10) assert.equal(sink.at(-1)?.ms, TASK_POLL_VISIBLE_INTERVAL_MS, '成功后回到正常间隔') assert.equal(sink.some((e) => e.ms === getTaskPollBackoffMs(0)), false, '自动轮询阶段无 in-flight 重试定时器残留') h.loop.dispose() }) test('test_recovery_cycle_repeat', async () => { let fail = true const h = makeSequenceLoop(async () => { if (fail) throw new Error('x') return { items: [] } }) await tick(10) // 失败-成功 × 2,再失败 fail = false await h.loop.refreshOnce() fail = true await h.loop.refreshOnce() fail = false await h.loop.refreshOnce() fail = true await h.loop.refreshOnce() assert.deepEqual(h.attempts, [0, 0, 0], '多次失败-成功循环每次从 attempt 0 重新起') h.loop.dispose() }) test('test_recovery_partial_items', async () => { let fail = true const h = makeSequenceLoop(async () => { if (fail) throw new Error('x') return { items: [{ taskId: 1, status: 'RUNNING' }] } }) await tick(10) fail = false await h.loop.refreshOnce() fail = true await h.loop.refreshOnce() assert.equal(h.loop.taskStatuses.value[1], 'RUNNING', '部分成功落地状态') assert.deepEqual(h.attempts, [0, 0], '有 items 的响应视为成功,计数归零') h.loop.dispose() }) test('test_recovery_with_foreground', async () => { const doc = installFakeDocument() setupWindow() let fail = true const attempts: number[] = [] const loop = useTaskProgressLoop({ scope: `recover-fore-${scopeCounter++}`, fetchProgress: async () => { if (fail) throw new Error('x') return { items: [{ taskId: 1, status: 'RUNNING' }] } }, extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => TASK_POLL_VISIBLE_INTERVAL_MS, onError: (_e, attempt) => { attempts.push(attempt ?? -1) }, }) loop.reset([1]) await tick(10) assert.equal(attempts.length, 1, '可见状态下首轮失败') doc.setVisibility('hidden') fail = false doc.setVisibility('visible') doc.dispatch() await tick(20) assert.equal(loop.taskStatuses.value[1], 'RUNNING', '切回前台立即刷新并恢复成功') fail = true await loop.refreshOnce() assert.deepEqual(attempts, [0, 0], '前台恢复成功后计数归零') loop.dispose() delete (globalThis as Record).document }) test('test_recovery_after_many_failures', async () => { let fail = true const h = makeSequenceLoop(async () => { if (fail) throw new Error('x') return { items: [{ taskId: 1, status: 'SUCCESS' }] } }) await tick(10) for (let i = 0; i < 7; i++) { await h.loop.refreshOnce() } assert.equal(h.attempts.length, 8, '连续失败累计 attempt') assert.deepEqual(h.attempts, [0, 1, 2, 3, 4, 5, 6, 7]) fail = false await h.loop.refreshOnce() assert.equal(h.terminalCalls.length, 1, '长失败链后仍可恢复') assert.equal(h.terminalCalls[0].status, 'SUCCESS') // SUCCESS 后任务已移除;重新 add 再制造失败,验证计数重新从 0 起 fail = true h.loop.add(1) await tick(10) assert.equal(h.attempts[8], 0, '恢复后重新从 0 起') h.loop.dispose() }) test('test_recovery_timing_assert', async () => { // attempt → 退避延迟映射:500,1000,2000,4000,8000,封顶 10000 const expected = [500, 1000, 2000, 4000, 8000, 10000, 10000] for (let i = 0; i < expected.length; i++) { assert.equal(getTaskPollBackoffMs(i), expected[i], `attempt ${i} 退避延迟`) } let fail = true const h = makeSequenceLoop(async () => { if (fail) throw new Error('x') return { items: [] } }) await tick(10) await h.loop.refreshOnce() fail = false await h.loop.refreshOnce() fail = true await h.loop.refreshOnce() assert.deepEqual(h.attempts, [0, 1, 0], '观察到的 attempt 序列') assert.equal(getTaskPollBackoffMs(h.attempts[0]), 500, '失败从 500ms 起') assert.equal(getTaskPollBackoffMs(h.attempts[1]), 1000, '二次失败 1000ms') assert.equal(getTaskPollBackoffMs(h.attempts[2]), 500, '恢复后重新从 500ms 起') h.loop.dispose() })