From 9af7a5170a58eef88131b972bf441d5f12170981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 31 Aug 2026 21:06:26 +0800 Subject: [PATCH] =?UTF-8?q?task-42:=20=E9=80=80=E9=81=BF=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败→退避序列→成功→恢复正常间隔;连续失败计数归零后从 attempt 0 重新起;多次失败成功循环;部分成功算成功;前台 刷新下恢复;长失败链后恢复。共 8 个测试。 --- .../tests/polling-backoff-recovery.test.ts | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 frontend-vue/tests/polling-backoff-recovery.test.ts diff --git a/frontend-vue/tests/polling-backoff-recovery.test.ts b/frontend-vue/tests/polling-backoff-recovery.test.ts new file mode 100644 index 00000000..b4fbab4f --- /dev/null +++ b/frontend-vue/tests/polling-backoff-recovery.test.ts @@ -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((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, 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({ + 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() +})