diff --git a/frontend/src/game/__tests__/tts-cache.test.ts b/frontend/src/game/__tests__/tts-cache.test.ts new file mode 100644 index 0000000..7894ce5 --- /dev/null +++ b/frontend/src/game/__tests__/tts-cache.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +// We test the LRU cache logic directly using the test-only exports. +// The audioCache is a Map. +// We fake AudioBuffer with a plain object since we only test eviction logic. + +import { _testAudioCache as audioCache, _testMaxCache as MAX_CACHE } from '../tts' + +function fakeBuf(): AudioBuffer { + return {} as AudioBuffer +} + +describe('TTS audio cache — LRU eviction', () => { + beforeEach(() => { + audioCache.clear() + }) + + it('MAX_CACHE is 50', () => { + expect(MAX_CACHE).toBe(50) + }) + + it('cache stores entries with lastAccess timestamp', () => { + const before = Date.now() + audioCache.set('key1', { buf: fakeBuf(), lastAccess: Date.now() }) + const entry = audioCache.get('key1')! + expect(entry.lastAccess).toBeGreaterThanOrEqual(before) + }) + + it('accessing a cached entry updates lastAccess', () => { + audioCache.set('key1', { buf: fakeBuf(), lastAccess: 1000 }) + // Simulate access (as done in _generateAndCache / kokoroSpeak) + const entry = audioCache.get('key1')! + entry.lastAccess = Date.now() + expect(entry.lastAccess).toBeGreaterThan(1000) + }) + + it('LRU eviction removes oldest entry when cache is full', () => { + // Fill cache to MAX_CACHE + for (let i = 0; i < MAX_CACHE; i++) { + audioCache.set(`key_${i}`, { buf: fakeBuf(), lastAccess: 1000 + i }) + } + expect(audioCache.size).toBe(MAX_CACHE) + + // Simulate the eviction logic from _doGenerate (lines 428-434 of tts.ts) + if (audioCache.size >= MAX_CACHE) { + let lruKey: string | undefined + let lruTime = Infinity + for (const [k, v] of audioCache) { + if (v.lastAccess < lruTime) { lruTime = v.lastAccess; lruKey = k } + } + if (lruKey) audioCache.delete(lruKey) + } + + // key_0 had the lowest lastAccess (1000), should be evicted + expect(audioCache.size).toBe(MAX_CACHE - 1) + expect(audioCache.has('key_0')).toBe(false) + expect(audioCache.has('key_1')).toBe(true) + }) + + it('recently accessed entries survive eviction', () => { + // Fill cache + for (let i = 0; i < MAX_CACHE; i++) { + audioCache.set(`key_${i}`, { buf: fakeBuf(), lastAccess: 1000 + i }) + } + + // "Access" key_0 to make it recent + audioCache.get('key_0')!.lastAccess = Date.now() + + // Evict — should remove key_1 (now oldest) + let lruKey: string | undefined + let lruTime = Infinity + for (const [k, v] of audioCache) { + if (v.lastAccess < lruTime) { lruTime = v.lastAccess; lruKey = k } + } + if (lruKey) audioCache.delete(lruKey) + + expect(audioCache.has('key_0')).toBe(true) // recently accessed, survived + expect(audioCache.has('key_1')).toBe(false) // oldest, evicted + }) + + it('eviction is not FIFO — insertion order does not matter', () => { + // Insert in reverse order but with ascending timestamps + for (let i = MAX_CACHE - 1; i >= 0; i--) { + audioCache.set(`key_${i}`, { buf: fakeBuf(), lastAccess: 2000 + i }) + } + + // key_0 has lastAccess=2000, inserted LAST but should be evicted first (oldest access) + let lruKey: string | undefined + let lruTime = Infinity + for (const [k, v] of audioCache) { + if (v.lastAccess < lruTime) { lruTime = v.lastAccess; lruKey = k } + } + if (lruKey) audioCache.delete(lruKey) + + expect(lruKey).toBe('key_0') // evicted by access time, not insertion order + expect(audioCache.has('key_0')).toBe(false) + expect(audioCache.has(`key_${MAX_CACHE - 1}`)).toBe(true) + }) +}) diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts index d8b3f6d..662f0be 100644 --- a/frontend/src/game/tts.ts +++ b/frontend/src/game/tts.ts @@ -590,3 +590,7 @@ export function getVoiceProfileNames(): string[] { export function getVoiceMap(): Record { return VOICE_MAP } + +// Test-only exports for verifying LRU cache behavior +export const _testAudioCache = audioCache +export const _testMaxCache = MAX_CACHE