diff --git a/frontend-vue/tests/backoff-cap.test.ts b/frontend-vue/tests/backoff-cap.test.ts new file mode 100644 index 00000000..478fa95c --- /dev/null +++ b/frontend-vue/tests/backoff-cap.test.ts @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + getTaskPollBackoffMs, + configureTaskPolling, + resetTaskPollingConfig, +} from '../src/shared/task-progress-config.ts' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' + +test('test_backoff_cap_reached', () => { + resetTaskPollingConfig() + assert.equal(getTaskPollBackoffMs(4), 8000) + assert.equal(getTaskPollBackoffMs(5), 10000, 'attempt 5 达到封顶') +}) + +test('test_backoff_stays_capped', () => { + resetTaskPollingConfig() + for (const attempt of [5, 6, 7, 10, 50]) { + assert.equal(getTaskPollBackoffMs(attempt), 10000, `attempt ${attempt} 保持 maxMs`) + } +}) + +test('test_cap_with_low_max', () => { + resetTaskPollingConfig() + configureTaskPolling({ backoffMaxMs: 1500 }) + assert.equal(getTaskPollBackoffMs(0), 500) + assert.equal(getTaskPollBackoffMs(1), 1000) + assert.equal(getTaskPollBackoffMs(2), 1500, '低 maxMs 提前封顶') + assert.equal(getTaskPollBackoffMs(3), 1500) + resetTaskPollingConfig() +}) + +test('test_cap_with_high_factor', () => { + resetTaskPollingConfig() + configureTaskPolling({ backoffFactor: 5 }) + assert.equal(getTaskPollBackoffMs(0), 500) + assert.equal(getTaskPollBackoffMs(1), 2500) + assert.equal(getTaskPollBackoffMs(2), 10000, '高 factor 快速封顶') + assert.equal(getTaskPollBackoffMs(3), 10000) + resetTaskPollingConfig() +}) + +test('test_cap_resets_after_success', () => { + resetTaskPollingConfig() + // 封顶只由 attempt 驱动:attempt 归 0 即从基数重新开始 + assert.equal(getTaskPollBackoffMs(0), 500) + assert.equal(getTaskPollBackoffMs(50), 10000) + assert.equal(getTaskPollBackoffMs(0), 500, '成功后从 500 起') + assert.equal(getTaskPollBackoffMs(1), 1000) +}) + +test('test_cap_attempt_boundary', () => { + resetTaskPollingConfig() + configureTaskPolling({ backoffMaxMs: 2000 }) + // 恰好等于封顶那一档:base × factor^1 = 1000 < 2000;^2 = 2000 = 封顶 + assert.equal(getTaskPollBackoffMs(1), 1000) + assert.equal(getTaskPollBackoffMs(2), 2000, '恰等于 maxMs 不溢出') + assert.equal(getTaskPollBackoffMs(3), 2000) + resetTaskPollingConfig() +}) + +test('test_cap_integration_loop', async () => { + resetTaskPollingConfig() + 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) => 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 attempts: number[] = [] + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: 'cap-integration', + fetchProgress: async () => { throw new Error('net') }, + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onError: (_e, attempt) => attempts.push(attempt ?? -1), + }) + loop.reset([1]) + await new Promise((r) => globalThis.setTimeout(r, 0)) + for (let i = 0; i < 12; i++) { + await loop.refreshOnce() + } + const intervals = attempts.map((a) => getTaskPollBackoffMs(a)) + assert.deepEqual( + intervals.slice(0, 7), + [500, 1000, 2000, 4000, 8000, 10000, 10000], + '失败计数映射到退避间隔,达到 maxMs 后保持', + ) + const tail = intervals.slice(7) + assert.ok(tail.every((v) => v === 10000), '封顶后间隔恒等于 maxMs') + loop.dispose() + resetTaskPollingConfig() +}) + +test('test_cap_config_negative', () => { + resetTaskPollingConfig() + assert.throws(() => configureTaskPolling({ backoffMaxMs: 0 }), /退避上限必须为正数/) + assert.throws(() => configureTaskPolling({ backoffMaxMs: -5 }), /退避上限必须为正数/) + // 拒绝后退避仍按默认值工作,不产生非法间隔 + assert.equal(getTaskPollBackoffMs(0), 500) + assert.equal(getTaskPollBackoffMs(50), 10000) + resetTaskPollingConfig() +})