From dd0a32e89a5d4fa458e70eb52f3df519e5146669 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 20:30:45 +0800 Subject: [PATCH] =?UTF-8?q?task-33:=20=E7=BD=91=E7=BB=9C=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=B5=B0=E9=80=80=E9=81=BF=E4=B8=94=E4=B8=8D=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 个测试固化网络错误语义:fetch 拒绝不触发 onTerminal、不修改 taskStatuses、任务集合不丢失、onError 上报、失败后不立即自动重试 (退避)、恢复后继续轮询并正常触发终态。 行为由任务 31 的失败计数与既有 catch 路径实现,本次为验证型测试。 --- .../tests/polling-network-error.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 frontend-vue/tests/polling-network-error.test.ts diff --git a/frontend-vue/tests/polling-network-error.test.ts b/frontend-vue/tests/polling-network-error.test.ts new file mode 100644 index 00000000..6bf10012 --- /dev/null +++ b/frontend-vue/tests/polling-network-error.test.ts @@ -0,0 +1,134 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' + +function setupWindow() { + 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), + } +} + +let scopeCounter = 0 + +interface Harness { + loop: ReturnType> + terminalCalls: Array<{ taskId: number; status: string }> + errorCalls: unknown[] + fetchCalls: () => number +} + +async function makeLoop(impl: () => Promise<{ items: unknown[] }>): Promise { + const terminalCalls: Array<{ taskId: number; status: string }> = [] + const errorCalls: unknown[] = [] + let fetchCount = 0 + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `net-err-${scopeCounter++}`, + fetchProgress: async (ids) => { + fetchCount += 1 + return impl() + }, + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onTerminal: (taskId, _detail, status) => { terminalCalls.push({ taskId, status }) }, + onError: (e) => { errorCalls.push(e) }, + }) + loop.reset([1, 2]) + await new Promise((r) => globalThis.setTimeout(r, 0)) + return { loop, terminalCalls, errorCalls, fetchCalls: () => fetchCount } +} + +test('test_network_error_no_terminal', async () => { + setupWindow() + const h = await makeLoop(async () => { throw new Error('network down') }) + await h.loop.refreshOnce() + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 0, '网络错误不触发 onTerminal') + h.loop.dispose() +}) + +test('test_network_error_no_status_change', async () => { + setupWindow() + const h = await makeLoop(async () => { throw new Error('network down') }) + await h.loop.refreshOnce() + assert.deepEqual(h.loop.taskStatuses.value, {}, '网络错误不修改 taskStatuses') + h.loop.dispose() +}) + +test('test_network_error_backoff_scheduled', async () => { + setupWindow() + const h = await makeLoop(async () => { throw new Error('network down') }) + // 首轮自动刷新失败后:不立刻重试(下一轮按 60s 正常间隔调度), + // 短期内不应有新的 fetch 请求 + const before = h.fetchCalls() + await new Promise((r) => globalThis.setTimeout(r, 50)) + assert.equal(h.fetchCalls(), before, '失败后进入退避,不立即自动重试') + h.loop.dispose() +}) + +test('test_network_error_on_error_called', async () => { + setupWindow() + const h = await makeLoop(async () => { throw new Error('network down') }) + assert.equal(h.errorCalls.length, 1, 'onError 被调用') + assert.match((h.errorCalls[0] as Error).message, /network down/) + h.loop.dispose() +}) + +test('test_network_recovery', async () => { + setupWindow() + let fail = true + const h = await makeLoop(async () => { + if (fail) throw new Error('network down') + return { items: [{ taskId: 1, status: 'RUNNING' }] } + }) + await h.loop.refreshOnce() + fail = false + await h.loop.refreshOnce() + assert.equal(h.loop.taskStatuses.value[1], 'RUNNING', '恢复后继续轮询并落地状态') + h.loop.dispose() +}) + +test('test_network_error_repeated', async () => { + setupWindow() + const h = await makeLoop(async () => { throw new Error('network down') }) + for (let i = 0; i < 5; i++) { + await h.loop.refreshOnce() + } + assert.equal(h.errorCalls.length, 6, '多次失败仍退避上报') + assert.equal(h.terminalCalls.length, 0) + h.loop.dispose() +}) + +test('test_network_error_keeps_task', async () => { + setupWindow() + const h = await makeLoop(async () => { throw new Error('network down') }) + await h.loop.refreshOnce() + assert.deepEqual(h.loop.taskIds.value, [1, 2], '任务不丢失') + h.loop.dispose() +}) + +test('test_network_error_then_success', async () => { + setupWindow() + let fail = true + const h = await makeLoop(async () => { + if (fail) throw new Error('network down') + return { items: [{ taskId: 1, status: 'SUCCESS' }] } + }) + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 0, '网络错误阶段无终态回调') + fail = false + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 1, '恢复后成功响应正常触发终态') + assert.equal(h.terminalCalls[0].taskId, 1) + assert.equal(h.terminalCalls[0].status, 'SUCCESS') + h.loop.dispose() +})