import { test } from 'node:test' import assert from 'node:assert/strict' import { createProgressResponseCache } from '../src/shared/progress-response-cache.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' } } function clock(start = 1000) { let now = start return { now: () => now, tick: (ms: number) => { now += ms }, } } test('test_task_082_progress_cleanup_normal_default_path', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 30_000, maxEntries: 500, now: c.now, }) cache.set(1, detail(1)) assert.deepEqual(cache.get(1), detail(1)) assert.equal(cache.has(1), true) assert.equal(cache.size, 1) assert.deepEqual(cache.entries(), [[1, detail(1)]]) }) test('test_task_082_progress_cleanup_normal_multiple_items', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 60_000, maxEntries: 10, now: c.now, }) for (let i = 1; i <= 10; i++) cache.set(i, detail(i)) assert.equal(cache.size, 10) assert.deepEqual( cache.entries().map(([k]) => k), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], ) assert.deepEqual( cache.entries().map(([, v]) => v.id), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], ) }) test('test_task_082_progress_cleanup_normal_repeated_operation_is_idempotent', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 60_000, maxEntries: 10, now: c.now, }) cache.set(1, detail(1)) cache.set(1, { ...detail(1), status: 'SUCCESS' }) assert.equal(cache.size, 1) assert.equal(cache.get(1)?.status, 'SUCCESS') cache.set(1, { ...detail(1), status: 'SUCCESS' }) assert.equal(cache.size, 1) assert.deepEqual( cache.entries().map(([k]) => k), [1], ) // 更新已存在条目不改变插入顺序 cache.set(2, detail(2)) cache.set(1, detail(1)) assert.deepEqual( cache.entries().map(([k]) => k), [1, 2], ) }) test('test_task_082_progress_cleanup_boundary_empty_input', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: c.now, }) assert.equal(cache.size, 0) assert.equal(cache.get(1), undefined) assert.equal(cache.has(1), false) assert.deepEqual(cache.entries(), []) assert.equal(cache.purgeExpired(), 0) cache.clear() assert.equal(cache.size, 0) assert.equal(cache.purgeExpired(), 0) }) test('test_task_082_progress_cleanup_boundary_single_item', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: c.now, }) cache.set(7, detail(7)) assert.deepEqual(cache.get(7), detail(7)) assert.equal(cache.size, 1) assert.equal(cache.has(7), true) c.tick(1001) assert.equal(cache.get(7), undefined) assert.equal(cache.has(7), false) assert.equal(cache.size, 0) assert.equal(cache.purgeExpired(), 0) }) test('test_task_082_progress_cleanup_boundary_limit_and_overflow', () => { // 条目数超限:驱逐最旧,缓存有界 const c1 = clock() const byEntries = createProgressResponseCache({ ttlMs: 60_000, maxEntries: 3, now: c1.now, }) for (let i = 1; i <= 5; i++) byEntries.set(i, detail(i)) assert.equal(byEntries.size, 3) assert.deepEqual( byEntries.entries().map(([k]) => k), [3, 4, 5], ) assert.equal(byEntries.get(1), undefined) assert.equal(byEntries.get(2), undefined) // TTL 清理:到期条目被 purgeExpired 清除 const c2 = clock() const byTtl = createProgressResponseCache({ ttlMs: 10_000, maxEntries: 100, now: c2.now, }) byTtl.set(1, detail(1)) byTtl.set(2, detail(2)) c2.tick(5_000) byTtl.set(3, detail(3)) assert.equal(byTtl.purgeExpired(), 0) c2.tick(5_001) assert.equal(byTtl.purgeExpired(), 2) assert.deepEqual( byTtl.entries().map(([k]) => k), [3], ) assert.equal(byTtl.size, 1) // 读取路径惰性清理 c2.tick(5_000) assert.equal(byTtl.get(3), undefined) assert.equal(byTtl.size, 0) assert.equal(byTtl.purgeExpired(), 0) }) test('test_task_082_progress_cleanup_invalid_input_rejected', () => { const c = clock() assert.throws(() => createProgressResponseCache({ ttlMs: 0, maxEntries: 5 }), /ttlMs 必须为正数/) assert.throws(() => createProgressResponseCache({ ttlMs: -1, maxEntries: 5 }), /ttlMs 必须为正数/) assert.throws(() => createProgressResponseCache({ ttlMs: 1000, maxEntries: 0 }), /maxEntries 必须为正数/) assert.throws(() => createProgressResponseCache({ ttlMs: 1000, maxEntries: -5 }), /maxEntries 必须为正数/) const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: c.now }) assert.throws(() => cache.set(0, detail(0)), /key 必须是正整数/) assert.throws(() => cache.set(-1, detail(-1)), /key 必须是正整数/) assert.throws(() => cache.set(NaN, detail(NaN)), /key 必须是正整数/) assert.throws(() => cache.set(1.5, detail(1.5)), /key 必须是正整数/) // 读取路径宽容:非法 key 不抛错 assert.equal(cache.get(-1), undefined) assert.equal(cache.has(0), false) assert.equal(cache.delete(0), false) }) test('test_task_082_progress_cleanup_dependency_failure_releases_resources', () => { const c = clock() let broken = true const faultyNow = () => { if (broken) throw new Error('clock down') return c.now() } const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: faultyNow, }) // 时钟故障时 set 失败,缓存零状态变更 assert.throws(() => cache.set(1, detail(1)), /clock down/) assert.equal(cache.size, 0) // 故障恢复后同一实例继续工作 broken = false cache.set(1, detail(1)) assert.equal(cache.get(1)?.id, 1) // 时钟故障时读取/清理抛错但不破坏条目 broken = true assert.throws(() => cache.get(1), /clock down/) assert.throws(() => cache.purgeExpired(), /clock down/) assert.throws(() => cache.entries(), /clock down/) broken = false assert.equal(cache.get(1)?.id, 1) assert.equal(cache.size, 1) // clear 释放全部资源 cache.clear() assert.equal(cache.size, 0) }) test('test_cache_get_hit', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 5000, maxEntries: 10, now: c.now }) cache.set(1, detail(1)) assert.deepEqual(cache.get(1), detail(1), 'TTL 内命中返回原值') assert.equal(cache.has(1), true) }) test('test_cache_get_expired_cleared', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 5000, maxEntries: 10, now: c.now }) cache.set(1, detail(1)) c.tick(4999) assert.deepEqual(cache.get(1), detail(1), '恰好未过期仍命中') c.tick(2) assert.equal(cache.get(1), undefined, '过期后读取返回 undefined 并清理') assert.equal(cache.size, 0, '过期条目已从缓存移除') }) test('test_cache_max_entries_evict', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 60000, maxEntries: 2, now: c.now }) cache.set(1, detail(1)) cache.set(2, detail(2)) cache.set(3, detail(3)) assert.equal(cache.size, 2) assert.equal(cache.get(1), undefined, '溢出淘汰最旧条目') assert.equal(cache.get(3)?.id, 3, '最新条目保留') }) test('test_cache_empty_get_undefined', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: c.now }) assert.equal(cache.get(1), undefined, '空缓存 get 返回 undefined') assert.equal(cache.has(1), false) assert.deepEqual(cache.entries(), []) }) test('test_cache_set_overwrites', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 60000, maxEntries: 5, now: c.now }) cache.set(1, detail(1)) c.tick(100) cache.set(1, { ...detail(1), status: 'SUCCESS' }) assert.equal(cache.get(1)?.status, 'SUCCESS', '同 key 覆盖新值') assert.equal(cache.size, 1) // 覆盖刷新 TTL:覆盖后再过一半 TTL 仍命中 c.tick(30000) assert.deepEqual(cache.get(1), { ...detail(1), status: 'SUCCESS' }, '覆盖后 TTL 重新计时') }) test('test_cache_ttl_zero', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 1, maxEntries: 5, now: c.now }) cache.set(1, detail(1)) c.tick(1) assert.equal(cache.get(1), undefined, 'TTL=1ms 过 1ms 即过期') assert.equal(cache.size, 0) }) test('test_cache_lazy_cleanup', () => { const c = clock() const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: c.now }) cache.set(1, detail(1)) cache.set(2, detail(2)) c.tick(2000) // 未读取前缓存条目仍在(惰性:无后台定时清理) assert.equal(cache.size, 2) // 读取触发惰性清理:读 1 只清 1,读 2 再清 2 assert.equal(cache.get(1), undefined) assert.equal(cache.size, 1) assert.equal(cache.get(2), undefined) assert.equal(cache.size, 0) }) test('test_cache_after_polling_cycle', async () => { const c = clock() const cache = createProgressResponseCache<{ taskId?: number; status?: string }>({ ttlMs: 5000, maxEntries: 10, now: c.now, }) 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 { useTaskProgressLoop } = await import('../src/shared/composables/useTaskProgressLoop.ts') const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ scope: 'cache-cycle', fetchProgress: async (ids) => ({ items: ids.map((id) => ({ taskId: id, status: 'RUNNING' })), }), extractTaskId: (d) => d?.taskId, extractStatus: (d) => d?.status, getIntervalMs: () => 60000, progressCache: cache, }) loop.reset([1]) await new Promise((r) => globalThis.setTimeout(r, 10)) assert.equal(cache.get(1)?.status, 'RUNNING', '轮询周期内缓存可用') c.tick(6000) assert.equal(cache.get(1), undefined, '超 TTL 后读取清理') loop.dispose() })