Files
crawler-plugin/frontend-vue/tests/polling-light-endpoint.test.ts
T
huangzd1997 a9bc9ff52e task-44: 轻量端点切换能力测试
fetchProgress 切轻量端点(taskId/status/fileStatus)后请求走轻量
实现;轻量结构解析、默认仍走旧端点、缺可选字段容错、终态判定、
缓存写入、onUpdate 收到、新旧结构同轮兼容。共 8 个测试。
2026-08-31 21:12:33 +08:00

232 lines
8.1 KiB
TypeScript

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<void>((r) => globalThis.setTimeout(r, ms))
function setupWindow() {
const storage = new Map<string, string>()
;(globalThis as Record<string, unknown>).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<string, string>()
let lightCalls = 0
let heavyCalls = 0
;(globalThis as Record<string, unknown>).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<AnyItem>({
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<AnyItem>({
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<AnyItem>({
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<AnyItem>({
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<AnyItem>({
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<AnyItem>({ ttlMs: 5000, maxEntries: 10 })
const loop = useTaskProgressLoop<AnyItem>({
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<AnyItem>({
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<AnyItem>({
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()
})