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() })