336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
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, '请求计数为 1(reset 触发一轮)')
|
||
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()
|
||
})
|