task-42: 退避恢复测试
失败→退避序列→成功→恢复正常间隔;连续失败计数归零后从 attempt 0 重新起;多次失败成功循环;部分成功算成功;前台 刷新下恢复;长失败链后恢复。共 8 个测试。
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user