diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index 1ae5ebc9..3a4a71d5 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -194,6 +194,7 @@ export function useTaskProgressLoop( try { const result = await options.fetchProgress(ids) const items = result?.items || [] + baseline?.recordResponseItems(items, (detail) => options.extractTaskId(detail)) const terminalEvents: Array<{ taskId: number; detail: TDetail | undefined; status: string }> = [] const nextStatuses = { ...taskStatuses.value } for (const detail of items) { @@ -226,6 +227,7 @@ export function useTaskProgressLoop( } } catch (error) { if (!disposed) { + baseline?.recordFailure() const attempt = failureCount failureCount = Math.min(failureCount + 1, 100) options.onError?.(error, attempt) diff --git a/frontend-vue/src/shared/task-polling-baseline.ts b/frontend-vue/src/shared/task-polling-baseline.ts index 36c67328..f17fa139 100644 --- a/frontend-vue/src/shared/task-polling-baseline.ts +++ b/frontend-vue/src/shared/task-polling-baseline.ts @@ -32,6 +32,8 @@ export interface TaskPollingBaselineStats { droppedCount: number /** 缓存条目 key(按首次到达顺序) */ entryKeys: number[] + /** 轮询失败次数(网络错误等,不触发终态) */ + failureCount: number } /** 每条缓存条目的固定估算开销:key + 记录结构 + 文本编码余量 */ @@ -56,6 +58,7 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions = let largestResponseBytes = 0 let cachedBytes = 0 let droppedCount = 0 + let failureCount = 0 function evictIfNeeded() { while (cache.size > maxEntries || cachedBytes > maxCacheBytes) { @@ -72,6 +75,11 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions = requestCount += 1 } + /** 记录一次轮询失败(网络错误/超时等,不影响缓存) */ + function recordFailure() { + failureCount += 1 + } + /** * 记录一次批量进度响应,返回成功写入缓存的条目数。 * items 非数组、extractKey 非函数或 key 提取抛错时整个调用失败,不产生任何状态变更。 @@ -125,6 +133,7 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions = largestResponseBytes = 0 cachedBytes = 0 droppedCount = 0 + failureCount = 0 } function stats(): TaskPollingBaselineStats { @@ -136,10 +145,11 @@ export function createTaskPollingBaseline(options: TaskPollingBaselineOptions = cachedBytes, droppedCount, entryKeys: [...cache.keys()], + failureCount, } } - return { recordRequest, recordResponseItems, reset, stats } + return { recordRequest, recordResponseItems, recordFailure, reset, stats } } export type TaskPollingBaseline = ReturnType diff --git a/frontend-vue/tests/task-polling-baseline.test.ts b/frontend-vue/tests/task-polling-baseline.test.ts index f2848a31..0e94c0b5 100644 --- a/frontend-vue/tests/task-polling-baseline.test.ts +++ b/frontend-vue/tests/task-polling-baseline.test.ts @@ -1,6 +1,10 @@ import { test } from 'node:test' 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 { id: number @@ -15,6 +19,43 @@ function detail(id: number): FakeDetail { const byId = (d: FakeDetail) => d.id const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)).byteLength +const tick = (ms = 0) => new Promise((r) => globalThis.setTimeout(r, ms)) +let scopeCounter = 0 + +type Item = { taskId?: number; status?: string } + +interface BaselineLoopHarness { + loop: ReturnType> +} + +function makeLoop( + options: Pick>[0], 'onBaseline'>, +): BaselineLoopHarness { + 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), + } + const loop = useTaskProgressLoop({ + 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() @@ -154,8 +195,141 @@ test('test_task_081_polling_frontend_dependency_failure_releases_resources', () 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({ + 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() +})