6bf87aff13
命中返回、过期读取即清理、超上限淘汰最旧、空缓存 get undefined、 同 key 覆盖并刷新 TTL、TTL 边界立即过期、惰性清理不主动扫描、 轮询周期内缓存可用。共 8 个测试。
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
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<FakeDetail>({
|
|
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<FakeDetail>({
|
|
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<FakeDetail>({
|
|
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<FakeDetail>({
|
|
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<FakeDetail>({
|
|
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<FakeDetail>({
|
|
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<FakeDetail>({
|
|
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<FakeDetail>({ 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<FakeDetail>({
|
|
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<FakeDetail>({ 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<FakeDetail>({ 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<FakeDetail>({ 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<FakeDetail>({ 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<FakeDetail>({ 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<FakeDetail>({ 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<FakeDetail>({ 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<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 { 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<void>((r) => globalThis.setTimeout(r, 10))
|
|
assert.equal(cache.get(1)?.status, 'RUNNING', '轮询周期内缓存可用')
|
|
c.tick(6000)
|
|
assert.equal(cache.get(1), undefined, '超 TTL 后读取清理')
|
|
loop.dispose()
|
|
})
|