Files
crawler-plugin/frontend-vue/tests/task-polling-baseline.test.ts

336 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
createTaskPollingBaseline,
type TaskPollingBaseline,
} from '../src/shared/task-polling-baseline.ts'
import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts'
interface FakeDetail {
id: number
name: string
status: string
}
function detail(id: number): FakeDetail {
return { id, name: `task-${id}`, status: id % 2 === 0 ? 'SUCCESS' : 'RUNNING' }
}
const byId = (d: FakeDetail) => d.id
const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)).byteLength
const tick = (ms = 0) => new Promise<void>((r) => globalThis.setTimeout(r, ms))
let scopeCounter = 0
type Item = { taskId?: number; status?: string }
interface BaselineLoopHarness {
loop: ReturnType<typeof useTaskProgressLoop<Item>>
}
function makeLoop(
options: Pick<Parameters<typeof useTaskProgressLoop<Item>>[0], 'onBaseline'>,
): BaselineLoopHarness {
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),
}
const loop = useTaskProgressLoop<Item>({
scope: `baseline-loop-${scopeCounter++}`,
fetchProgress: async (ids) => ({
items: ids.map((id) => ({ taskId: id, status: 'RUNNING' })),
}),
extractTaskId: (d) => d?.taskId,
extractStatus: (d) => d?.status,
getIntervalMs: () => 60000,
...options,
})
return { loop }
}
test('test_task_081_polling_frontend_normal_default_path', () => {
const baseline = createTaskPollingBaseline()
baseline.recordRequest()
baseline.recordRequest()
baseline.recordRequest()
const inserted = baseline.recordResponseItems([detail(1), detail(2)], byId)
assert.equal(inserted, 2)
const stats = baseline.stats()
assert.equal(stats.requestCount, 3)
assert.equal(stats.entryCount, 2)
assert.equal(stats.droppedCount, 0)
assert.ok(stats.cachedBytes > 0)
assert.ok(stats.cachedBytes <= 1024 * 1024)
assert.equal(stats.totalResponseBytes, enc([detail(1), detail(2)]))
assert.equal(stats.largestResponseBytes, stats.totalResponseBytes)
assert.deepEqual(stats.entryKeys, [1, 2])
})
test('test_task_081_polling_frontend_normal_multiple_items', () => {
const baseline = createTaskPollingBaseline()
const items = Array.from({ length: 10 }, (_, i) => detail(i + 1))
const inserted = baseline.recordResponseItems(items, byId)
assert.equal(inserted, 10)
const stats = baseline.stats()
assert.equal(stats.entryCount, 10)
assert.deepEqual(stats.entryKeys, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// 覆盖更新不改变首次到达顺序
baseline.recordResponseItems([detail(5)], byId)
const after = baseline.stats()
assert.equal(after.entryCount, 10)
assert.deepEqual(after.entryKeys, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
assert.equal(after.totalResponseBytes, enc(items) + enc([detail(5)]))
assert.equal(after.largestResponseBytes, enc(items))
})
test('test_task_081_polling_frontend_normal_repeated_operation_is_idempotent', () => {
const baseline = createTaskPollingBaseline()
const items = [detail(1), detail(2), detail(3)]
baseline.recordResponseItems(items, byId)
baseline.recordResponseItems(items, byId)
const stats = baseline.stats()
assert.equal(stats.entryCount, 3)
assert.equal(stats.droppedCount, 0)
const single = createTaskPollingBaseline()
single.recordResponseItems(items, byId)
assert.equal(stats.cachedBytes, single.stats().cachedBytes)
assert.equal(stats.totalResponseBytes, single.stats().totalResponseBytes * 2)
})
test('test_task_081_polling_frontend_boundary_empty_input', () => {
const baseline = createTaskPollingBaseline()
assert.equal(baseline.recordResponseItems([], byId), 0)
assert.equal(baseline.recordResponseItems([], byId), 0)
const stats = baseline.stats()
assert.equal(stats.requestCount, 0)
assert.equal(stats.totalResponseBytes, 0)
assert.equal(stats.largestResponseBytes, 0)
assert.equal(stats.entryCount, 0)
assert.equal(stats.cachedBytes, 0)
assert.equal(stats.droppedCount, 0)
assert.deepEqual(stats.entryKeys, [])
})
test('test_task_081_polling_frontend_boundary_single_item', () => {
const baseline = createTaskPollingBaseline()
const inserted = baseline.recordResponseItems([detail(7)], byId)
assert.equal(inserted, 1)
const stats = baseline.stats()
assert.equal(stats.entryCount, 1)
assert.deepEqual(stats.entryKeys, [7])
assert.equal(stats.cachedBytes, enc(detail(7)) + 64)
assert.equal(stats.totalResponseBytes, enc([detail(7)]))
})
test('test_task_081_polling_frontend_boundary_limit_and_overflow', () => {
// 条目数超限:驱逐最旧,缓存有界
const byEntries = createTaskPollingBaseline({ maxEntries: 3, maxCacheBytes: 1_000_000 })
byEntries.recordResponseItems([detail(1), detail(2), detail(3), detail(4), detail(5)], byId)
const s1 = byEntries.stats()
assert.equal(s1.entryCount, 3)
assert.deepEqual(s1.entryKeys, [3, 4, 5])
assert.equal(s1.droppedCount, 2)
// 单条超过字节上限:直接拒绝
const small = createTaskPollingBaseline({ maxCacheBytes: 200 })
const big = { id: 9, name: 'x'.repeat(1000), status: 'RUNNING' }
const inserted = small.recordResponseItems([big], byId)
assert.equal(inserted, 0)
const s2 = small.stats()
assert.equal(s2.entryCount, 0)
assert.equal(s2.droppedCount, 1)
assert.ok(s2.cachedBytes <= 200)
// 批量累积超限:逐条驱逐最旧直到有界
const tiny = createTaskPollingBaseline({ maxCacheBytes: 300 })
tiny.recordResponseItems([detail(1), detail(2), detail(3)], byId)
const s3 = tiny.stats()
assert.equal(s3.entryCount, 2)
assert.deepEqual(s3.entryKeys, [2, 3])
assert.equal(s3.droppedCount, 1)
assert.ok(s3.cachedBytes <= 300)
})
test('test_task_081_polling_frontend_invalid_input_rejected', () => {
const baseline = createTaskPollingBaseline()
assert.throws(() => baseline.recordResponseItems(null as never, byId), /items 必须是数组/)
assert.throws(() => baseline.recordResponseItems([detail(1)], null as never), /extractKey 必须是函数/)
assert.throws(() => createTaskPollingBaseline({ maxEntries: 0 }), /上限配置必须为正数/)
assert.throws(() => createTaskPollingBaseline({ maxCacheBytes: -1 }), /上限配置必须为正数/)
// 非法 key 跳过而不是抛错(与轮询层行为一致)
const inserted = baseline.recordResponseItems(
[{ id: 0, name: 'x', status: 'y' }, detail(2)],
byId,
)
assert.equal(inserted, 1)
})
test('test_task_081_polling_frontend_dependency_failure_releases_resources', () => {
const baseline = createTaskPollingBaseline()
baseline.recordRequest()
assert.throws(
() =>
baseline.recordResponseItems([detail(1)], () => {
throw new Error('boom')
}),
/boom/,
)
const after = baseline.stats()
assert.equal(after.entryCount, 0)
assert.equal(after.totalResponseBytes, 0)
assert.equal(after.requestCount, 1)
assert.equal(after.droppedCount, 0)
baseline.reset()
assert.deepEqual(baseline.stats(), {
requestCount: 0,
totalResponseBytes: 0,
largestResponseBytes: 0,
entryCount: 0,
cachedBytes: 0,
droppedCount: 0,
entryKeys: [],
failureCount: 0,
})
// 错误可恢复:修复后同一实例继续工作
baseline.recordResponseItems([detail(1)], byId)
assert.equal(baseline.stats().entryCount, 1)
})
test('test_baseline_request_count', async () => {
const calls: TaskPollingBaseline[] = []
const { loop } = makeLoop({ onBaseline: (b) => { calls.push(b) } })
loop.reset([1])
await tick(10)
assert.ok(calls.length >= 1, '每轮请求后回传基线')
assert.equal(calls.at(-1)!.stats().requestCount, 1, '请求计数为 1reset 触发一轮)')
await loop.refreshOnce()
assert.equal(calls.at(-1)!.stats().requestCount, 2, '手动 refreshOnce 再记一次')
loop.dispose()
})
test('test_baseline_response_size', async () => {
const calls: TaskPollingBaseline[] = []
const { loop } = makeLoop({ onBaseline: (b) => { calls.push(b) } })
loop.reset([1, 2])
await tick(10)
const stats = calls.at(-1)!.stats()
loop.dispose()
assert.ok(stats.totalResponseBytes > 0, '响应体大小累计')
assert.equal(
stats.totalResponseBytes,
enc([
{ taskId: 1, status: 'RUNNING' },
{ taskId: 2, status: 'RUNNING' },
]),
'总字节 = 整轮响应体序列化字节数',
)
assert.equal(stats.largestResponseBytes, stats.totalResponseBytes, '单次最大 = 当前唯一一轮')
})
test('test_baseline_cache_usage', async () => {
const calls: TaskPollingBaseline[] = []
const { loop } = makeLoop({ onBaseline: (b) => { calls.push(b) } })
loop.reset([1, 2])
await tick(10)
const stats = calls.at(-1)!.stats()
loop.dispose()
assert.equal(stats.entryCount, 2, '每条进度一个缓存条目')
assert.deepEqual(stats.entryKeys, [1, 2])
assert.ok(stats.cachedBytes >= enc({ taskId: 1, status: 'RUNNING' }), '缓存字节含条目编码')
})
test('test_baseline_failure_count', async () => {
const calls: TaskPollingBaseline[] = []
let fails = 0
const loop = useTaskProgressLoop<Item>({
scope: `baseline-fail-${scopeCounter++}`,
fetchProgress: async () => {
if (fails < 2) {
fails += 1
throw new Error('network down')
}
return { items: [{ taskId: 1, status: 'RUNNING' }] }
},
extractTaskId: (d) => d?.taskId,
extractStatus: (d) => d?.status,
getIntervalMs: () => 60000,
onBaseline: (b) => { calls.push(b) },
})
loop.reset([1])
await tick(10)
const first = calls.at(-1)!.stats()
assert.equal(first.failureCount, 1, '首轮失败计数 1')
await loop.refreshOnce()
const second = calls.at(-1)!.stats()
assert.equal(second.failureCount, 2, '连续失败累计到 2')
await loop.refreshOnce()
const third = calls.at(-1)!.stats()
loop.dispose()
assert.equal(third.failureCount, 2, '失败计数为累计值(清零仅经 reset;连续计数走 onError attempt')
})
test('test_baseline_callback_after_round', async () => {
const calls: TaskPollingBaseline[] = []
const { loop } = makeLoop({ onBaseline: (b) => { calls.push(b) } })
loop.reset([1])
await tick(10)
await loop.refreshOnce()
assert.equal(calls.length, 2, '每轮 refreshOnce 结束后各回传一次基线')
assert.equal(calls[1].stats().requestCount, 2, '回传的是同一实例(累计统计)')
assert.strictEqual(calls[0], calls[1], '回传的是同一基线实例')
loop.dispose()
})
test('test_baseline_round_accuracy', async () => {
const calls: TaskPollingBaseline[] = []
const { loop } = makeLoop({ onBaseline: (b) => { calls.push(b) } })
loop.reset([1])
await tick(10)
// 重置后:请求量、条目数与请求一一对应,无跨轮串账
assert.equal(calls.at(-1)!.stats().requestCount, 1)
loop.add(2)
await tick(10)
const second = calls.at(-1)!.stats()
loop.dispose()
assert.equal(second.requestCount, 2, 'add 触发新一轮请求')
assert.equal(second.entryCount, 2, '第二轮响应写入新条目')
})
test('test_baseline_reset', async () => {
const calls: TaskPollingBaseline[] = []
const { loop } = makeLoop({ onBaseline: (b) => { calls.push(b) } })
loop.reset([1])
await tick(10)
const baseline = calls.at(-1)!
assert.equal(baseline.stats().requestCount, 1)
baseline.reset()
assert.deepEqual(baseline.stats(), {
requestCount: 0,
totalResponseBytes: 0,
largestResponseBytes: 0,
entryCount: 0,
cachedBytes: 0,
droppedCount: 0,
entryKeys: [],
failureCount: 0,
}, '重置后基线清零')
loop.dispose()
})
test('test_baseline_optional_off', async () => {
const { loop } = makeLoop({})
loop.reset([1])
await tick(10)
// 不传 onBaseline:不创建基线,不抛错
loop.refreshOnce()
await tick(10)
assert.equal(loop.taskStatuses.value[1], 'RUNNING', '无基线时轮询照常工作')
loop.dispose()
})