test: add static analysis tests for memory leak destroy contracts

Verifies FightScene.destroy(), FightViewer.vue onUnmounted, and audio
module cleanup invariants via source-code scanning — ensures future
changes don't silently break resource cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 10:06:42 +00:00
co-authored by Claude Opus 4.6
parent 854b1cd1df
commit 08437cdf5c
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
const gameDir = resolve(dirname(import.meta.url.replace('file://', '')), '..')
const readGame = (rel: string) => readFileSync(resolve(gameDir, rel), 'utf-8')
describe('memory leak audit — static analysis', () => {
describe('FightScene.destroy() cleanup contract', () => {
const src = readGame('FightScene.ts')
it('stops music', () => {
expect(src).toContain('stopMusic()')
})
it('stops talking animations', () => {
expect(src).toContain("stopTalking('a')")
expect(src).toContain("stopTalking('b')")
})
it('clears speech bubble arrays', () => {
expect(src).toContain('activeBubbleA.length = 0')
expect(src).toContain('activeBubbleB.length = 0')
})
it('clears tracked timers', () => {
expect(src).toContain('cleanupTimers.clear()')
})
it('clears audio timers', () => {
expect(src).toContain('clearAllAudioTimers()')
})
it('destroys all Kaplay objects', () => {
expect(src).toMatch(/k\.get\('\*'\)\.forEach/)
})
it('quits Kaplay engine', () => {
expect(src).toContain('k.quit()')
})
})
describe('FightViewer.vue cleanup in onUnmounted', () => {
const src = readGame('../components/FightViewer.vue')
it('calls scene.destroy()', () => {
expect(src).toContain('scene.destroy()')
})
it('nullifies scene reference', () => {
expect(src).toContain('scene = null')
})
it('calls stopAllAudio()', () => {
expect(src).toContain('stopAllAudio()')
})
it('removes canvas webglcontextlost listener', () => {
expect(src).toContain("removeEventListener('webglcontextlost'")
})
it('removes canvas webglcontextrestored listener', () => {
expect(src).toContain("removeEventListener('webglcontextrestored'")
})
it('nullifies canvas handler references', () => {
expect(src).toContain('canvasContextLostHandler = null')
expect(src).toContain('canvasContextRestoredHandler = null')
})
it('clears cleanup timer handles', () => {
expect(src).toMatch(/clearTimeout\(cleanupTimerHandle\)/)
})
it('cancels speechSynthesis', () => {
expect(src).toContain('speechSynthesis.cancel()')
})
})
describe('audio module does not leak per-scene resources', () => {
it('audioTimeout cleanup function exists', () => {
const ctx = readGame('audio/context.ts')
expect(ctx).toContain('export function clearAllAudioTimers()')
})
it('TTS cache has bounded size (MAX_CACHE)', () => {
const tts = readGame('tts.ts')
expect(tts).toMatch(/const MAX_CACHE\s*=\s*\d+/)
})
it('TTS cache uses LRU eviction', () => {
const tts = readGame('tts.ts')
expect(tts).toContain('lastAccess')
expect(tts).toMatch(/lruKey|lruTime/)
})
it('kokoroClearCache export exists', () => {
const tts = readGame('tts.ts')
expect(tts).toContain('export function kokoroClearCache()')
})
it('kokoroStop export exists', () => {
const tts = readGame('tts.ts')
expect(tts).toContain('export function kokoroStop()')
})
})
})