task-31: 连续失败计数
useTaskProgressLoop 轮询循环维护连续失败计数:refreshOnce 成功 (收到响应)归零;失败递增(封顶 100);onError 回调增加第二参数 attempt(0 起),供上层决定退避策略;dispose 后不再上报。 同时把该组合式函数内部 @/ 别名 import 改为相对路径(带 .ts 扩展), 使其可在 node --test 下直接运行(此前无任何测试覆盖该文件)。 新增 8 个测试:递增、成功后归零、收到响应归零、onError 带 attempt、 初始 0、交错、封顶、dispose 清理。
This commit is contained in:
@@ -4,20 +4,20 @@ import {
|
||||
getTaskPollBackoffMs,
|
||||
getTaskForegroundRefreshEnabled,
|
||||
getTaskForegroundRefreshDelayMs,
|
||||
} from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
} from '../task-progress-config.ts'
|
||||
import { createCategorizedTimers } from '../utils/categorized-timers.ts'
|
||||
import {
|
||||
createTaskPollingBaseline,
|
||||
type TaskPollingBaseline,
|
||||
} from '@/shared/task-polling-baseline'
|
||||
} from '../task-polling-baseline.ts'
|
||||
import {
|
||||
createProgressResponseCache,
|
||||
type ProgressResponseCache,
|
||||
} from '@/shared/progress-response-cache'
|
||||
} from '../progress-response-cache.ts'
|
||||
import {
|
||||
createTaskPollingCoordinator,
|
||||
type TaskPollingCoordinator,
|
||||
} from '@/shared/task-polling-coordinator'
|
||||
} from '../task-polling-coordinator.ts'
|
||||
|
||||
/**
|
||||
* 通用任务进度轮询组合式函数。
|
||||
@@ -59,8 +59,11 @@ export interface TaskProgressLoopOptions<TDetail> {
|
||||
* 若多个任务同一轮到达终态,会被分别回调。
|
||||
*/
|
||||
onTerminal?: (taskId: number, detail: TDetail | undefined, status: string) => void | Promise<void>
|
||||
/** 轮询周期失败时的回调;默认静默 */
|
||||
onError?: (error: unknown) => void
|
||||
/**
|
||||
* 轮询周期失败时的回调;默认静默。
|
||||
* 第二个参数 attempt 为连续失败计数(0 起,成功后归零),供上层决定退避策略。
|
||||
*/
|
||||
onError?: (error: unknown, attempt?: number) => void
|
||||
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s) */
|
||||
getIntervalMs?: () => number
|
||||
/**
|
||||
@@ -138,6 +141,7 @@ export function useTaskProgressLoop<TDetail>(
|
||||
const inFlight = ref(false)
|
||||
let pollTimer: number | null = null
|
||||
let disposed = false
|
||||
let failureCount = 0
|
||||
|
||||
function persist() {
|
||||
writeIdsToStorage(options.storageKey, taskIds.value)
|
||||
@@ -204,6 +208,7 @@ export function useTaskProgressLoop<TDetail>(
|
||||
}
|
||||
}
|
||||
taskStatuses.value = nextStatuses
|
||||
failureCount = 0
|
||||
for (const event of terminalEvents) {
|
||||
coordinator?.markTerminal(event.taskId)
|
||||
remove(event.taskId)
|
||||
@@ -214,7 +219,11 @@ export function useTaskProgressLoop<TDetail>(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
if (!disposed) {
|
||||
const attempt = failureCount
|
||||
failureCount = Math.min(failureCount + 1, 100)
|
||||
options.onError?.(error, attempt)
|
||||
}
|
||||
} finally {
|
||||
inFlight.value = false
|
||||
if (baseline) options.onBaseline?.(baseline)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts'
|
||||
|
||||
interface LoopCallbacks {
|
||||
onError?: (error: unknown, attempt?: number) => void
|
||||
fetchImpl?: () => Promise<{ items: unknown[] }>
|
||||
}
|
||||
|
||||
function setupWindow() {
|
||||
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) => 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),
|
||||
}
|
||||
}
|
||||
|
||||
let scopeCounter = 0
|
||||
|
||||
function tick() {
|
||||
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造轮询循环并等待第一轮(构造时因任务从空到非空自动触发)
|
||||
* 完成后返回;后续手动 refreshOnce 的计数因此从稳定起点开始。
|
||||
*/
|
||||
async function makeLoop(callbacks: LoopCallbacks) {
|
||||
const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({
|
||||
scope: `failure-count-${scopeCounter++}`,
|
||||
fetchProgress: callbacks.fetchImpl ?? (async () => ({ items: [] })),
|
||||
extractTaskId: (detail) => detail?.taskId,
|
||||
extractStatus: (detail) => detail?.status,
|
||||
getIntervalMs: () => 60000,
|
||||
onError: callbacks.onError,
|
||||
})
|
||||
loop.reset([1])
|
||||
await tick()
|
||||
return loop
|
||||
}
|
||||
|
||||
test('test_failure_count_increments', async () => {
|
||||
setupWindow()
|
||||
const attempts: number[] = []
|
||||
const loop = await makeLoop({
|
||||
onError: (_e, attempt) => attempts.push(attempt ?? -1),
|
||||
fetchImpl: async () => { throw new Error('net down') },
|
||||
})
|
||||
await loop.refreshOnce()
|
||||
await loop.refreshOnce()
|
||||
// [首轮自动刷新(0), 手动第1次(1), 手动第2次(2)]
|
||||
assert.deepEqual(attempts, [0, 1, 2], '连续失败 attempt 递增')
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_success_resets_count', async () => {
|
||||
setupWindow()
|
||||
const attempts: number[] = []
|
||||
let fail = true
|
||||
const loop = await makeLoop({
|
||||
onError: (_e, attempt) => attempts.push(attempt ?? -1),
|
||||
fetchImpl: async () => {
|
||||
if (fail) throw new Error('net down')
|
||||
return { items: [] }
|
||||
},
|
||||
})
|
||||
await loop.refreshOnce()
|
||||
await loop.refreshOnce()
|
||||
fail = false
|
||||
await loop.refreshOnce()
|
||||
fail = true
|
||||
await loop.refreshOnce()
|
||||
assert.deepEqual(attempts, [0, 1, 2, 0], '成功后计数归零重新从 0 起')
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_any_response_resets', async () => {
|
||||
setupWindow()
|
||||
const attempts: number[] = []
|
||||
let fail = true
|
||||
const loop = await makeLoop({
|
||||
onError: (_e, attempt) => attempts.push(attempt ?? -1),
|
||||
fetchImpl: async () => {
|
||||
if (fail) throw new Error('net down')
|
||||
return { items: [] }
|
||||
},
|
||||
})
|
||||
fail = false
|
||||
await loop.refreshOnce()
|
||||
assert.deepEqual(attempts, [0], '收到响应后失败计数归零')
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_on_error_receives_attempt', async () => {
|
||||
setupWindow()
|
||||
const received: Array<{ error: unknown; attempt?: number }> = []
|
||||
const loop = await makeLoop({
|
||||
onError: (error, attempt) => received.push({ error, attempt }),
|
||||
fetchImpl: async () => { throw new Error('boom') },
|
||||
})
|
||||
assert.equal(received.length, 1)
|
||||
assert.equal(received[0].attempt, 0)
|
||||
assert.match((received[0].error as Error).message, /boom/)
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_initial_count_zero', async () => {
|
||||
setupWindow()
|
||||
const attempts: number[] = []
|
||||
const loop = await makeLoop({
|
||||
onError: (_e, attempt) => attempts.push(attempt ?? -1),
|
||||
fetchImpl: async () => { throw new Error('x') },
|
||||
})
|
||||
assert.equal(attempts[0], 0, '首次失败 attempt 为 0')
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_interleaved_fail_success', async () => {
|
||||
setupWindow()
|
||||
const attempts: number[] = []
|
||||
let fail = true
|
||||
const loop = await makeLoop({
|
||||
onError: (_e, attempt) => attempts.push(attempt ?? -1),
|
||||
fetchImpl: async () => {
|
||||
if (fail) throw new Error('net')
|
||||
return { items: [] }
|
||||
},
|
||||
})
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fail = i % 2 === 0
|
||||
await loop.refreshOnce()
|
||||
}
|
||||
// [构造轮首轮失败(0), i=0 失败(1), i=2 失败(0), i=4 失败(0)]
|
||||
assert.deepEqual(attempts, [0, 1, 0, 0], '失败成功交错,每次失败从归零后的计数起')
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_count_bounded', async () => {
|
||||
setupWindow()
|
||||
const attempts: number[] = []
|
||||
const loop = await makeLoop({
|
||||
onError: (_e, attempt) => attempts.push(attempt ?? -1),
|
||||
fetchImpl: async () => { throw new Error('net') },
|
||||
})
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await loop.refreshOnce()
|
||||
}
|
||||
assert.equal(attempts.length, 151)
|
||||
assert.equal(attempts[100], 100)
|
||||
const tail = attempts.slice(101)
|
||||
assert.ok(tail.every((a) => a === 100), '超过上限后 attempt 封顶不再增长')
|
||||
loop.dispose()
|
||||
})
|
||||
|
||||
test('test_dispose_clears_count', async () => {
|
||||
setupWindow()
|
||||
let onErrorCalls = 0
|
||||
let fetchCalls = 0
|
||||
const loop = await makeLoop({
|
||||
onError: () => { onErrorCalls += 1 },
|
||||
fetchImpl: async () => {
|
||||
fetchCalls += 1
|
||||
throw new Error('net')
|
||||
},
|
||||
})
|
||||
await loop.refreshOnce()
|
||||
assert.equal(onErrorCalls, 2)
|
||||
loop.dispose()
|
||||
await loop.refreshOnce()
|
||||
assert.equal(fetchCalls, 2, 'dispose 后不再发请求')
|
||||
assert.equal(onErrorCalls, 2, 'dispose 后不再上报错误')
|
||||
})
|
||||
Reference in New Issue
Block a user