/** * Verification test for SpriteRenderer GPU resource lifecycle, deleteAtlas, * dispose, and WebGL context loss handling (Issue #9). */ import { SpriteRenderer, RendererError } from '../src/render/renderer.ts' interface MockResource { id: number type: string } function createMockCanvas(): { canvas: any stats: { createdTextures: Set deletedTextures: Set createdBuffers: Set deletedBuffers: Set createdVAOs: Set deletedVAOs: Set createdPrograms: Set deletedPrograms: Set createdShaders: Set deletedShaders: Set eventListeners: Map> } setContextLost: (lost: boolean) => void dispatchMockEvent: (type: string) => { defaultPrevented: boolean } } { let nextId = 1 let contextLost = false const stats = { createdTextures: new Set(), deletedTextures: new Set(), createdBuffers: new Set(), deletedBuffers: new Set(), createdVAOs: new Set(), deletedVAOs: new Set(), createdPrograms: new Set(), deletedPrograms: new Set(), createdShaders: new Set(), deletedShaders: new Set(), eventListeners: new Map>(), } const gl: any = { VERTEX_SHADER: 35633, FRAGMENT_SHADER: 35632, COMPILE_STATUS: 35713, LINK_STATUS: 35714, MAX_TEXTURE_SIZE: 3379, TEXTURE_2D: 3553, RGBA: 6408, UNSIGNED_BYTE: 5121, FLOAT: 5126, ARRAY_BUFFER: 34962, DYNAMIC_DRAW: 35048, TRIANGLES: 4, TEXTURE_MIN_FILTER: 10241, TEXTURE_MAG_FILTER: 10240, TEXTURE_WRAP_S: 10242, TEXTURE_WRAP_T: 10243, CLAMP_TO_EDGE: 33071, NEAREST: 9728, UNPACK_ALIGNMENT: 3317, BLEND: 3042, SRC_ALPHA: 770, ONE_MINUS_SRC_ALPHA: 771, DEPTH_TEST: 2929, COLOR_BUFFER_BIT: 16384, TEXTURE0: 33984, drawingBufferWidth: 800, drawingBufferHeight: 600, isContextLost: () => contextLost, createProgram: () => { const res: MockResource = { id: nextId++, type: 'program' } stats.createdPrograms.add(res) return res }, deleteProgram: (p: MockResource) => { stats.deletedPrograms.add(p) }, attachShader: () => {}, detachShader: () => {}, linkProgram: () => {}, getProgramParameter: () => true, getProgramInfoLog: () => '', useProgram: () => {}, createShader: (type: number) => { const res: MockResource = { id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' } stats.createdShaders.add(res) return res }, shaderSource: () => {}, compileShader: () => {}, getShaderParameter: () => true, getShaderInfoLog: () => '', deleteShader: (s: MockResource) => { stats.deletedShaders.add(s) }, createVertexArray: () => { const res: MockResource = { id: nextId++, type: 'vao' } stats.createdVAOs.add(res) return res }, deleteVertexArray: (vao: MockResource) => { stats.deletedVAOs.add(vao) }, bindVertexArray: () => {}, createBuffer: () => { const res: MockResource = { id: nextId++, type: 'buffer' } stats.createdBuffers.add(res) return res }, deleteBuffer: (b: MockResource) => { stats.deletedBuffers.add(b) }, bindBuffer: () => {}, bufferData: () => {}, bufferSubData: () => {}, getAttribLocation: (_p: any, name: string) => (name === 'a_position' ? 0 : name === 'a_uv' ? 1 : 2), enableVertexAttribArray: () => {}, vertexAttribPointer: () => {}, createTexture: () => { const res: MockResource = { id: nextId++, type: 'texture' } stats.createdTextures.add(res) return res }, deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) }, bindTexture: () => {}, texParameteri: () => {}, texImage2D: () => {}, pixelStorei: () => {}, getParameter: (param: number) => (param === 3379 ? 4096 : 0), getUniformLocation: (_p: any, name: string) => ({ name }), uniform2f: () => {}, uniform1f: () => {}, uniform1i: () => {}, enable: () => {}, disable: () => {}, blendFunc: () => {}, viewport: () => {}, clearColor: () => {}, clear: () => {}, activeTexture: () => {}, drawArrays: () => {}, } const eventListeners = stats.eventListeners const canvas: any = { getContext: (type: string) => (type === 'webgl2' ? gl : null), addEventListener: (type: string, listener: EventListener) => { if (!eventListeners.has(type)) eventListeners.set(type, new Set()) eventListeners.get(type)!.add(listener) }, removeEventListener: (type: string, listener: EventListener) => { eventListeners.get(type)?.delete(listener) }, drawingBufferWidth: 800, drawingBufferHeight: 600, } return { canvas, stats, setContextLost: (lost: boolean) => { contextLost = lost }, dispatchMockEvent: (type: string) => { let defaultPrevented = false const event: any = { type, preventDefault: () => { defaultPrevented = true }, } const set = eventListeners.get(type) if (set) { for (const listener of set) { listener(event) } } return { defaultPrevented } }, } } function runTests(): void { console.log('== Running SpriteRenderer Lifecycle Verification ==\n') let passed = 0 let failed = 0 function assert(condition: boolean, name: string): void { if (condition) { console.log(` ✓ ${name}`) passed += 1 } else { console.error(` ✗ ${name}`) failed += 1 } } // Test 1: Shader cleanup after compilation { const mock = createMockCanvas() const renderer = new SpriteRenderer(mock.canvas) assert(mock.stats.createdShaders.size === 2, 'Two shaders created (VS & FS)') assert(mock.stats.deletedShaders.size === 2, 'Both shaders detached and deleted right after program link') assert(mock.stats.createdPrograms.size === 1, 'Program created') assert(mock.stats.createdVAOs.size === 1, 'VAO created') assert(mock.stats.createdBuffers.size === 1, 'VBO buffer created') assert(mock.stats.createdTextures.size === 2, 'Initial textures created (atlas + white)') assert(renderer.isDisposed === false, 'Renderer is active and not disposed') renderer.dispose() } // Test 2: addAtlas & deleteAtlas { const mock = createMockCanvas() const renderer = new SpriteRenderer(mock.canvas) const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(64 * 64 * 4), width: 64, height: 64 }) assert(mock.stats.createdTextures.size === 3, 'addAtlas creates a new GPU texture (3 total)') // Draw with this extra atlas renderer.begin({ x: 0, y: 0, zoom: 1 }) renderer.draw({ x: 0, y: 0, width: 32, height: 32 }, 0, 0, { atlas: extra }) // Now delete this atlas const deleted = renderer.deleteAtlas(extra) assert(deleted === true, 'deleteAtlas returned true for managed handle') assert(mock.stats.deletedTextures.has(extra.texture as any), 'GPU texture was deleted via gl.deleteTexture') // Deleting again should be safe and return false const deletedAgain = renderer.deleteAtlas(extra) assert(deletedAgain === false, 'deleteAtlas returned false on already deleted handle') renderer.dispose() } // Test 3: dispose() cleans up all resources { const mock = createMockCanvas() const renderer = new SpriteRenderer(mock.canvas) const extra1 = renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 }) const extra2 = renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 }) void extra1 void extra2 assert(mock.stats.createdTextures.size === 4, '4 total textures created before dispose') renderer.dispose() assert(renderer.isDisposed === true, 'isDisposed is true after dispose()') assert(mock.stats.deletedTextures.size === 4, 'All 4 textures were deleted on dispose()') assert(mock.stats.deletedBuffers.size === 1, 'VBO was deleted on dispose()') assert(mock.stats.deletedVAOs.size === 1, 'VAO was deleted on dispose()') assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()') // Idempotent dispose renderer.dispose() assert(mock.stats.deletedPrograms.size === 1, 'Second dispose() is safe and idempotent') // Calling methods after dispose throws RendererError let threwOnBegin = false try { renderer.begin({ x: 0, y: 0, zoom: 1 }) } catch (err) { if (err instanceof RendererError) threwOnBegin = true } assert(threwOnBegin, 'begin() throws RendererError when called on disposed renderer') let threwOnAdd = false try { renderer.addAtlas({ pixels: new Uint8ClampedArray(16), width: 2, height: 2 }) } catch (err) { if (err instanceof RendererError) threwOnAdd = true } assert(threwOnAdd, 'addAtlas() throws RendererError when called on disposed renderer') } // Test 4: WebGL Context Lost & Restored { const mock = createMockCanvas() const tracker = { lostFired: false, restoredFired: false } const renderer = new SpriteRenderer(mock.canvas, { onContextLost: () => { tracker.lostFired = true }, onContextRestored: () => { tracker.restoredFired = true }, }) const lostEvent = mock.dispatchMockEvent('webglcontextlost') assert(lostEvent.defaultPrevented === true, 'webglcontextlost calls event.preventDefault() to allow recovery') assert(tracker.lostFired === true, 'onContextLost callback fired') mock.dispatchMockEvent('webglcontextrestored') assert(tracker.restoredFired === true, 'onContextRestored callback fired') // Dispose cleans up event listeners renderer.dispose() assert(mock.stats.eventListeners.get('webglcontextlost')?.size === 0, 'Context lost listener removed on dispose') assert(mock.stats.eventListeners.get('webglcontextrestored')?.size === 0, 'Context restored listener removed on dispose') } // Test 5: Dispose when context is already lost { const mock = createMockCanvas() const renderer = new SpriteRenderer(mock.canvas) mock.setContextLost(true) // Dispose should not attempt invalid GL deletes when context is lost renderer.dispose() assert(renderer.isDisposed === true, 'dispose() safely completes when context is already lost') } console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`) if (failed > 0) process.exit(1) } runTests()