diff --git a/frontend-vue/tests/polling-light-endpoint.test.ts b/frontend-vue/tests/polling-light-endpoint.test.ts new file mode 100644 index 00000000..0b1df974 --- /dev/null +++ b/frontend-vue/tests/polling-light-endpoint.test.ts @@ -0,0 +1,231 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' +import { createProgressResponseCache } from '../src/shared/progress-response-cache.ts' + +const tick = (ms = 0) => new Promise((r) => globalThis.setTimeout(r, ms)) + +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 + +/** 轻量端点条目:只含 taskId/status/fileStatus 三字段 */ +interface LightItem { + taskId?: number + status?: string + fileStatus?: string +} + +/** 旧端点完整条目:带明细字段 */ +interface FullItem { + taskId?: number + status?: string + fileStatus?: string + fileName?: string + progress?: number + shopName?: string +} + +type AnyItem = LightItem | FullItem + +test('test_light_fetch_used', async () => { + setupWindow() + // 切换:把 fetchProgress 指向轻量端点(模拟页面替换 fetch 实现) + const storage = new Map() + let lightCalls = 0 + let heavyCalls = 0 + ;(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 loop = useTaskProgressLoop({ + scope: `light-switch-${scopeCounter++}`, + fetchProgress: async (ids) => { + lightCalls += 1 + return { items: ids.map((id) => ({ taskId: id, status: 'RUNNING', fileStatus: 'PROCESSING' })) } + }, + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + loop.reset([1]) + await tick(10) + assert.equal(lightCalls, 1, '切换到轻量端点后请求走轻量实现') + assert.equal(heavyCalls, 0) + assert.equal(loop.taskStatuses.value[1], 'RUNNING', '轻量响应正常落地') + loop.dispose() +}) + +test('test_light_detail_shape_parsed', async () => { + setupWindow() + const updates: AnyItem[] = [] + const loop = useTaskProgressLoop({ + scope: `light-shape-${scopeCounter++}`, + fetchProgress: async () => ({ + items: [{ taskId: 3, status: 'QUEUED', fileStatus: 'PENDING' }], + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onUpdate: (_id, detail) => { updates.push(detail) }, + }) + loop.reset([3]) + await tick(10) + await loop.refreshOnce() + assert.equal(loop.taskStatuses.value[3], 'QUEUED', '轻量结构提取 taskId/status') + assert.equal(updates[0]?.fileStatus, 'PENDING', 'onUpdate 收到完整轻量条目') + loop.dispose() +}) + +test('test_light_fallback_to_old', async () => { + setupWindow() + let heavyCalls = 0 + let lightCalls = 0 + const loop = useTaskProgressLoop({ + scope: `light-old-${scopeCounter++}`, + // 默认 fetchProgress 是旧端点(完整明细) + fetchProgress: async () => { + heavyCalls += 1 + return { + items: [{ taskId: 1, status: 'RUNNING', fileName: 'a.xlsx', progress: 50, shopName: 's1' }], + } + }, + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + loop.reset([1]) + await tick(10) + assert.equal(heavyCalls, 1, '默认仍走旧端点') + assert.equal(lightCalls, 0, '未切换不调用轻量端点') + assert.equal(loop.taskStatuses.value[1], 'RUNNING') + loop.dispose() +}) + +test('test_light_missing_optional', async () => { + setupWindow() + const loop = useTaskProgressLoop({ + scope: `light-opt-${scopeCounter++}`, + // 轻量条目缺 fileStatus 等可选字段 + fetchProgress: async () => ({ items: [{ taskId: 5, status: 'RUNNING' }] }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + loop.reset([5]) + await tick(10) + await loop.refreshOnce() + assert.equal(loop.taskStatuses.value[5], 'RUNNING', '缺可选字段不影响解析') + assert.deepEqual(loop.taskIds.value, [5], '任务仍在轮询集合') + loop.dispose() +}) + +test('test_light_terminal_detection', async () => { + setupWindow() + const terminalCalls: Array<{ taskId: number; status: string }> = [] + const loop = useTaskProgressLoop({ + scope: `light-term-${scopeCounter++}`, + fetchProgress: async () => ({ + items: [{ taskId: 7, status: 'SUCCESS', fileStatus: 'COMPLETED' }], + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onTerminal: (taskId, _d, status) => { terminalCalls.push({ taskId, status }) }, + }) + loop.reset([7]) + await tick(10) + await loop.refreshOnce() + assert.equal(terminalCalls.length, 1, '轻量 status 正常触发终态') + assert.equal(terminalCalls[0].status, 'SUCCESS') + assert.deepEqual(loop.taskIds.value, [], '终态任务移除') + loop.dispose() +}) + +test('test_light_progress_cache', async () => { + setupWindow() + const cache = createProgressResponseCache({ ttlMs: 5000, maxEntries: 10 }) + const loop = useTaskProgressLoop({ + scope: `light-cache-${scopeCounter++}`, + fetchProgress: async () => ({ + items: [{ taskId: 9, status: 'RUNNING', fileStatus: 'PROCESSING' }], + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + progressCache: cache, + }) + loop.reset([9]) + await tick(10) + await loop.refreshOnce() + assert.deepEqual(cache.get(9), { taskId: 9, status: 'RUNNING', fileStatus: 'PROCESSING' }, '轻量条目写入缓存') + loop.dispose() +}) + +test('test_light_on_update_called', async () => { + setupWindow() + const updates: Array<{ id: number; item: AnyItem }> = [] + const loop = useTaskProgressLoop({ + scope: `light-update-${scopeCounter++}`, + fetchProgress: async () => ({ + items: [ + { taskId: 2, status: 'RUNNING', fileStatus: 'PROCESSING' }, + { taskId: 4, status: 'PENDING', fileStatus: 'PENDING' }, + ], + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onUpdate: (taskId, detail) => { updates.push({ id: taskId, item: detail }) }, + }) + loop.reset([2, 4]) + // reset 触发的自动轮询一轮即覆盖全部条目(勿再手动 refreshOnce 双发) + await tick(10) + assert.deepEqual(updates.map((u) => u.id), [2, 4], '每条轻量条目都触发 onUpdate') + assert.equal(updates[1].item.fileStatus, 'PENDING') + loop.dispose() +}) + +test('test_light_snapshot_compat', async () => { + setupWindow() + const loop = useTaskProgressLoop({ + scope: `light-mix-${scopeCounter++}`, + // 混合响应:轻量条目 + 旧端点完整条目 + fetchProgress: async () => ({ + items: [ + { taskId: 1, status: 'SUCCESS', fileStatus: 'COMPLETED' }, + { taskId: 2, status: 'RUNNING', fileName: 'b.xlsx', progress: 20, shopName: 's2' }, + { taskId: 3, status: 'FAILED', fileStatus: 'FAILED', fileName: 'c.xlsx' }, + ], + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + loop.reset([1, 2, 3]) + await tick(10) + await loop.refreshOnce() + assert.deepEqual(loop.taskStatuses.value, { 1: 'SUCCESS', 2: 'RUNNING', 3: 'FAILED' }, '新旧结构同轮兼容') + assert.deepEqual(loop.taskIds.value, [2], '仅非终态任务保留') + loop.dispose() +})