task-47: 轮询 baseline 统计补强测试
This commit is contained in:
@@ -194,6 +194,7 @@ export function useTaskProgressLoop<TDetail>(
|
|||||||
try {
|
try {
|
||||||
const result = await options.fetchProgress(ids)
|
const result = await options.fetchProgress(ids)
|
||||||
const items = result?.items || []
|
const items = result?.items || []
|
||||||
|
baseline?.recordResponseItems(items, (detail) => options.extractTaskId(detail))
|
||||||
const terminalEvents: Array<{ taskId: number; detail: TDetail | undefined; status: string }> = []
|
const terminalEvents: Array<{ taskId: number; detail: TDetail | undefined; status: string }> = []
|
||||||
const nextStatuses = { ...taskStatuses.value }
|
const nextStatuses = { ...taskStatuses.value }
|
||||||
for (const detail of items) {
|
for (const detail of items) {
|
||||||
@@ -226,6 +227,7 @@ export function useTaskProgressLoop<TDetail>(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
|
baseline?.recordFailure()
|
||||||
const attempt = failureCount
|
const attempt = failureCount
|
||||||
failureCount = Math.min(failureCount + 1, 100)
|
failureCount = Math.min(failureCount + 1, 100)
|
||||||
options.onError?.(error, attempt)
|
options.onError?.(error, attempt)
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface TaskPollingBaselineStats {
|
|||||||
droppedCount: number
|
droppedCount: number
|
||||||
/** 缓存条目 key(按首次到达顺序) */
|
/** 缓存条目 key(按首次到达顺序) */
|
||||||
entryKeys: number[]
|
entryKeys: number[]
|
||||||
|
/** 轮询失败次数(网络错误等,不触发终态) */
|
||||||
|
failureCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 每条缓存条目的固定估算开销:key + 记录结构 + 文本编码余量 */
|
/** 每条缓存条目的固定估算开销:key + 记录结构 + 文本编码余量 */
|
||||||
@@ -56,6 +58,7 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions =
|
|||||||
let largestResponseBytes = 0
|
let largestResponseBytes = 0
|
||||||
let cachedBytes = 0
|
let cachedBytes = 0
|
||||||
let droppedCount = 0
|
let droppedCount = 0
|
||||||
|
let failureCount = 0
|
||||||
|
|
||||||
function evictIfNeeded() {
|
function evictIfNeeded() {
|
||||||
while (cache.size > maxEntries || cachedBytes > maxCacheBytes) {
|
while (cache.size > maxEntries || cachedBytes > maxCacheBytes) {
|
||||||
@@ -72,6 +75,11 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions =
|
|||||||
requestCount += 1
|
requestCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 记录一次轮询失败(网络错误/超时等,不影响缓存) */
|
||||||
|
function recordFailure() {
|
||||||
|
failureCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录一次批量进度响应,返回成功写入缓存的条目数。
|
* 记录一次批量进度响应,返回成功写入缓存的条目数。
|
||||||
* items 非数组、extractKey 非函数或 key 提取抛错时整个调用失败,不产生任何状态变更。
|
* items 非数组、extractKey 非函数或 key 提取抛错时整个调用失败,不产生任何状态变更。
|
||||||
@@ -125,6 +133,7 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions =
|
|||||||
largestResponseBytes = 0
|
largestResponseBytes = 0
|
||||||
cachedBytes = 0
|
cachedBytes = 0
|
||||||
droppedCount = 0
|
droppedCount = 0
|
||||||
|
failureCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function stats(): TaskPollingBaselineStats {
|
function stats(): TaskPollingBaselineStats {
|
||||||
@@ -136,10 +145,11 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions =
|
|||||||
cachedBytes,
|
cachedBytes,
|
||||||
droppedCount,
|
droppedCount,
|
||||||
entryKeys: [...cache.keys()],
|
entryKeys: [...cache.keys()],
|
||||||
|
failureCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { recordRequest, recordResponseItems, reset, stats }
|
return { recordRequest, recordResponseItems, recordFailure, reset, stats }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskPollingBaseline = ReturnType<typeof createTaskPollingBaseline>
|
export type TaskPollingBaseline = ReturnType<typeof createTaskPollingBaseline>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { createTaskPollingBaseline } from '../src/shared/task-polling-baseline.ts'
|
import {
|
||||||
|
createTaskPollingBaseline,
|
||||||
|
type TaskPollingBaseline,
|
||||||
|
} from '../src/shared/task-polling-baseline.ts'
|
||||||
|
import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts'
|
||||||
|
|
||||||
interface FakeDetail {
|
interface FakeDetail {
|
||||||
id: number
|
id: number
|
||||||
@@ -15,6 +19,43 @@ function detail(id: number): FakeDetail {
|
|||||||
const byId = (d: FakeDetail) => d.id
|
const byId = (d: FakeDetail) => d.id
|
||||||
const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)).byteLength
|
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', () => {
|
test('test_task_081_polling_frontend_normal_default_path', () => {
|
||||||
const baseline = createTaskPollingBaseline()
|
const baseline = createTaskPollingBaseline()
|
||||||
baseline.recordRequest()
|
baseline.recordRequest()
|
||||||
@@ -154,8 +195,141 @@ test('test_task_081_polling_frontend_dependency_failure_releases_resources', ()
|
|||||||
cachedBytes: 0,
|
cachedBytes: 0,
|
||||||
droppedCount: 0,
|
droppedCount: 0,
|
||||||
entryKeys: [],
|
entryKeys: [],
|
||||||
|
failureCount: 0,
|
||||||
})
|
})
|
||||||
// 错误可恢复:修复后同一实例继续工作
|
// 错误可恢复:修复后同一实例继续工作
|
||||||
baseline.recordResponseItems([detail(1)], byId)
|
baseline.recordResponseItems([detail(1)], byId)
|
||||||
assert.equal(baseline.stats().entryCount, 1)
|
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()
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user