diff --git a/frontend-vue/tests/terminal-error-semantics.test.ts b/frontend-vue/tests/terminal-error-semantics.test.ts new file mode 100644 index 00000000..ba89dd56 --- /dev/null +++ b/frontend-vue/tests/terminal-error-semantics.test.ts @@ -0,0 +1,162 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' +import { createTaskPollingCoordinator } from '../src/shared/task-polling-coordinator.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 }> + errors: unknown[] +} + +async function makeLoop( + terminalImpl: (taskId: number, status: string) => void, + items: Array<{ taskId?: number; status?: string }>, +): Promise { + const terminalCalls: Array<{ taskId: number; status: string }> = [] + const errors: unknown[] = [] + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `term-err-${scopeCounter++}`, + fetchProgress: async (ids) => ({ + items: items.filter((item) => ids.includes(item.taskId ?? -1)), + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onTerminal: (taskId, _d, status) => { + terminalCalls.push({ taskId, status }) + terminalImpl(taskId, status) + }, + onError: (e) => { errors.push(e) }, + }) + loop.reset([1, 2]) + await new Promise((r) => globalThis.setTimeout(r, 0)) + return { loop, terminalCalls, errors } +} + +test('test_terminal_error_does_not_break_loop', async () => { + setupWindow() + const h = await makeLoop( + () => { throw new Error('refresh failed') }, + [{ taskId: 1, status: 'SUCCESS' }], + ) + await h.loop.refreshOnce() + await h.loop.refreshOnce() + assert.equal(h.errors.length, 0, 'onTerminal 抛错被吞掉,不进入 onError') + assert.deepEqual(h.loop.taskIds.value, [2], '循环仍运转') + h.loop.dispose() +}) + +test('test_terminal_error_task_removed', async () => { + setupWindow() + const h = await makeLoop( + () => { throw new Error('boom') }, + [{ taskId: 1, status: 'SUCCESS' }], + ) + await h.loop.refreshOnce() + assert.deepEqual(h.loop.taskIds.value, [2], '抛错任务已从轮询集合移除') + h.loop.dispose() +}) + +test('test_terminal_error_re_add_works', async () => { + setupWindow() + const h = await makeLoop( + () => { throw new Error('boom') }, + [{ taskId: 1, status: 'SUCCESS' }], + ) + await h.loop.refreshOnce() + h.loop.add(1) + assert.deepEqual(h.loop.taskIds.value, [2, 1], '页面可自行重新 add() 恢复') + h.loop.dispose() +}) + +test('test_terminal_error_other_tasks', async () => { + setupWindow() + const h = await makeLoop( + (taskId) => { + if (taskId === 1) throw new Error('boom') + }, + [ + { taskId: 1, status: 'SUCCESS' }, + { taskId: 2, status: 'FAILED' }, + ], + ) + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 2, '其他终态仍回调') + assert.equal(h.terminalCalls[1].taskId, 2) + assert.deepEqual(h.loop.taskIds.value, [], '全部终态任务移除') + h.loop.dispose() +}) + +test('test_terminal_error_swallowed', async () => { + setupWindow() + const h = await makeLoop( + () => { throw new Error('boom') }, + [{ taskId: 1, status: 'SUCCESS' }], + ) + await h.loop.refreshOnce() + const result = await h.loop.refreshOnce() + assert.equal(result, undefined, 'refreshOnce 不因 onTerminal 抛错而 reject') + h.loop.dispose() +}) + +test('test_terminal_error_then_success', async () => { + setupWindow() + let fail = true + const h = await makeLoop( + () => { + if (fail) throw new Error('boom') + }, + [{ taskId: 2, status: 'SUCCESS' }], + ) + await h.loop.refreshOnce() + fail = false + h.loop.add(2) + // add() 经 watch 触发一轮即时自动轮询,等待该轮完成(勿再手动 refreshOnce 以免双发) + await new Promise((r) => globalThis.setTimeout(r, 0)) + assert.equal(h.terminalCalls.length, 2, '后一轮正常触发') + assert.equal(h.terminalCalls[1].status, 'SUCCESS') + h.loop.dispose() +}) + +test('test_terminal_error_not_retried', async () => { + setupWindow() + const h = await makeLoop( + () => { throw new Error('boom') }, + [{ taskId: 1, status: 'SUCCESS' }], + ) + await h.loop.refreshOnce() + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 1, '不自动重试回调') + h.loop.dispose() +}) + +test('test_terminal_error_status_kept', async () => { + setupWindow() + const coordinator = createTaskPollingCoordinator({}) + const h = await makeLoop( + () => { throw new Error('boom') }, + [{ taskId: 1, status: 'SUCCESS' }], + ) + // 用协调器包装后,状态保留在 taskStatuses + assert.equal(h.loop.taskStatuses.value[1], undefined, '移除后不再有状态') + assert.equal(coordinator.has(1), false, '协调器无残留') + h.loop.dispose() +})