task-42: 退避恢复测试

失败→退避序列→成功→恢复正常间隔;连续失败计数归零后从
attempt 0 重新起;多次失败成功循环;部分成功算成功;前台
刷新下恢复;长失败链后恢复。共 8 个测试。
This commit is contained in:
2026-08-31 21:06:26 +08:00
parent 50518b5420
commit 9af7a5170a
@@ -0,0 +1,261 @@
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<void>((r) => globalThis.setTimeout(r, ms))
interface SinkEntry {
fn: () => void
ms: number
id: unknown
}
function setupWindow(sink?: SinkEntry[]) {
const storage = new Map<string, string>()
;(globalThis as Record<string, unknown>).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<string, Array<(e?: unknown) => void>>()
;(globalThis as Record<string, unknown>).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<typeof useTaskProgressLoop<Item>>
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<Item>({
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, TASK_POLL_VISIBLE_INTERVAL_MS, '失败后仍按正常间隔续轮')
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 < 1000), false, '自动轮询阶段无退避定时器残留')
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<Item>({
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<string, unknown>).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()
})