import { test } from 'node:test' import assert from 'node:assert/strict' import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' import { getTaskPollBackoffMs } from '../src/shared/task-progress-config.ts' function setupWindow() { 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 tick = (ms = 0) => new Promise((r) => globalThis.setTimeout(r, ms)) async function waitFor(cond: () => boolean, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (!cond() && Date.now() < deadline) { await tick(10) } return cond() } let scopeCounter = 0 interface GatedHarness { loop: ReturnType> fetchCount: () => number maxConcurrent: () => number fetchIdsCalls: () => number[][] release: (index?: number) => void releaseAll: () => void } /** 每个请求都阻塞在 gate 上,直到测试手动释放;统计并发峰值 */ async function makeGatedLoop(): Promise { const gates: Array<() => void> = [] const fetchCalls: number[][] = [] let count = 0 let active = 0 let peak = 0 const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ scope: `inflight-${scopeCounter++}`, fetchProgress: async (ids) => { count += 1 active += 1 peak = Math.max(peak, active) fetchCalls.push(ids.slice()) await new Promise((r) => { gates.push(r) }) active -= 1 return { items: [] } }, extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => 60000, }) return { loop, fetchCount: () => count, maxConcurrent: () => peak, fetchIdsCalls: () => fetchCalls, release: (i = 0) => { gates[i]?.() }, releaseAll: () => { for (const g of gates) g() }, } } test('test_in_flight_no_second_request', async () => { setupWindow() const h = await makeGatedLoop() h.loop.reset([1]) await tick(0) assert.equal(h.fetchCount(), 1, '首轮请求已进入 in-flight') h.loop.ensure(true) await tick(60) assert.equal(h.fetchCount(), 1, 'in-flight 期间不发起第二个请求') h.release(0) assert.ok(await waitFor(() => h.fetchCount() >= 2, 2000), '上一次完成后按退避继续') h.releaseAll() h.loop.dispose() }) test('test_in_flight_queues_after', async () => { setupWindow() let count = 0 let active = 0 let peak = 0 const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ scope: `inflight-seq-${scopeCounter++}`, fetchProgress: async () => { count += 1 active += 1 peak = Math.max(peak, active) await tick(30) active -= 1 return { items: [] } }, extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => 40, }) loop.reset([1]) assert.ok(await waitFor(() => count >= 2, 2000), '请求完成后自动进入下一轮') assert.equal(peak, 1, '全程无并发') loop.dispose() }) test('test_in_flight_backoff_wait', async () => { setupWindow() const h = await makeGatedLoop() h.loop.reset([1]) await tick(0) assert.equal(h.fetchCount(), 1) const t0 = Date.now() h.loop.ensure(true) h.release(0) assert.ok(await waitFor(() => h.fetchCount() >= 2, 3000), 'in-flight 结束后按退避重试') const elapsed = Date.now() - t0 assert.ok( elapsed >= getTaskPollBackoffMs(0) - 100, `重试等待 >= 退避时长 ${getTaskPollBackoffMs(0)}ms(实际 ${elapsed}ms)`, ) h.releaseAll() h.loop.dispose() }) test('test_in_flight_timer_single', async () => { setupWindow() const h = await makeGatedLoop() h.loop.reset([1]) await tick(0) h.loop.ensure(true) h.loop.ensure(true) h.release(0) assert.ok(await waitFor(() => h.fetchCount() >= 2, 2000), '重复 ensure 只保留一个退避 timer') await tick(800) assert.equal(h.fetchCount(), 2, '无叠加 timer 引发的第三次请求') h.releaseAll() h.loop.dispose() }) test('test_in_flight_add_during', async () => { setupWindow() const h = await makeGatedLoop() h.loop.reset([1]) await tick(0) assert.equal(h.fetchCount(), 1) h.loop.add(2) assert.deepEqual(h.loop.taskIds.value, [1, 2], 'in-flight 期间 add 任务入集合') await tick(60) assert.equal(h.fetchCount(), 1, 'add 不触发并发请求') h.release(0) assert.ok(await waitFor(() => h.fetchCount() >= 2, 2000), '退避后轮询新任务集合') assert.deepEqual(h.fetchIdsCalls()[1], [1, 2], '下一轮请求包含新任务') assert.equal(h.maxConcurrent(), 1) h.releaseAll() h.loop.dispose() }) test('test_in_flight_error_releases', async () => { setupWindow() let fail = true const errors: unknown[] = [] const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ scope: `inflight-err-${scopeCounter++}`, fetchProgress: async () => { if (fail) throw new Error('boom') return { items: [{ taskId: 1, status: 'RUNNING' }] } }, extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => 60000, onError: (e) => { errors.push(e) }, }) loop.reset([1]) await tick(0) assert.equal(errors.length, 1, '首轮失败上报 onError') assert.equal(loop.inFlight.value, false, '失败后 in-flight 锁释放') fail = false await loop.refreshOnce() assert.equal(loop.taskStatuses.value[1], 'RUNNING', '锁释放后可正常轮询') loop.dispose() }) test('test_in_flight_immediate_repeat', async () => { setupWindow() let count = 0 let active = 0 let peak = 0 const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ scope: `inflight-rep-${scopeCounter++}`, fetchProgress: async () => { count += 1 active += 1 peak = Math.max(peak, active) await tick(30) active -= 1 return { items: [] } }, extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => 60000, }) loop.reset([1]) await tick(0) assert.equal(count, 1) loop.ensure(true) loop.ensure(true) await tick(40) assert.equal(count, 1, '请求未完成时重复 ensure(true) 不并发') assert.equal(peak, 1) assert.ok(await waitFor(() => count >= 2, 2000), '完成后退避续轮') loop.dispose() }) test('test_in_flight_long_request', async () => { setupWindow() let count = 0 let active = 0 let peak = 0 const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ scope: `inflight-long-${scopeCounter++}`, fetchProgress: async () => { count += 1 active += 1 peak = Math.max(peak, active) await tick(50) active -= 1 return { items: [] } }, extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => 25, }) loop.reset([1]) assert.ok(await waitFor(() => count >= 3, 4000), '长请求期间多次续轮') assert.equal(peak, 1, '长请求期间始终单请求') loop.dispose() })