From 08437cdf5ce3ec167dadf9da6267db93ff08c36c Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 10:06:42 +0000 Subject: [PATCH] test: add static analysis tests for memory leak destroy contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/game/__tests__/memory-audit.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/src/game/__tests__/memory-audit.test.ts diff --git a/frontend/src/game/__tests__/memory-audit.test.ts b/frontend/src/game/__tests__/memory-audit.test.ts new file mode 100644 index 0000000..9944f59 --- /dev/null +++ b/frontend/src/game/__tests__/memory-audit.test.ts @@ -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()') + }) + }) +})