/** * Unit tests for {@link BTAPI}. * * Covers the public engine facade exposed to demos: * - singleton lifecycle and version constants * - safe accessor behavior before initialization * - no-op drawing and camera APIs before renderer setup * - initialization failure cases for invalid hardware settings and WebGPU setup * - successful initialization, accessor population, and loop startup * * The suite isolates global browser and singleton state with WebGPU mocks, * stubbed animation-frame scheduling, and per-test singleton reset helpers. */ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockGPUCanvasContext, createMockGPUDevice, installMockNavigatorGPU, uninstallMockNavigatorGPU, } from '../__test__/webgpu-mock'; import type { BitmapFont } from '../assets/BitmapFont'; import { Palette } from '../assets/Palette'; import type { SpriteSheet } from '../assets/SpriteSheet'; import type { Effect } from '../render/effects/Effect'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { BTAPI } from './BTAPI'; import type { IBlitTechDemo } from './IBlitTechDemo'; // #region Helpers function resetSingleton(): void { // BTAPI._instance is private; the cast is intentional - there is no public // reset API, and this is the least-invasive way to isolate singleton state // between tests without modifying production code. (BTAPI as unknown as { _instance: BTAPI | null })._instance = null; } function makeMockDemo(targetFPS = 60, initResult = true): IBlitTechDemo { return { configure: vi.fn().mockReturnValue({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(640, 480), targetFPS, }), init: vi.fn().mockResolvedValue(initResult), update: vi.fn(), render: vi.fn(), }; } function makeMockCanvas(): HTMLCanvasElement { return { width: 0, height: 0, style: { width: '', height: '', touchAction: '', setProperty: vi.fn(), getPropertyValue: vi.fn(() => ''), }, getContext: (type: string) => (type === 'webgpu' ? createMockGPUCanvasContext() : null), addEventListener: vi.fn(), removeEventListener: vi.fn(), } as unknown as HTMLCanvasElement; } function makeMock2DCanvas(): HTMLCanvasElement { return { ...makeMockCanvas(), getContext: (type: string) => { if (type === '2d') { return { imageSmoothingEnabled: false, createImageData: (w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h, }) as ImageData, putImageData: vi.fn(), clearRect: vi.fn(), drawImage: vi.fn(), }; } return null; }, toBlob: (callback: (blob: Blob | null) => void) => callback(new Blob(['x'], { type: 'image/png' })), } as unknown as HTMLCanvasElement; } /** Minimal 2D context shape for {@link OffscreenCanvas#getContext} mocks; rejects non-`2d` types. */ type OffscreenCanvas2DMock = { imageSmoothingEnabled: boolean; createImageData: (w: number, h: number) => ImageData; putImageData: ReturnType; }; function makeOffscreenCanvas2dContext(): OffscreenCanvas2DMock { return { imageSmoothingEnabled: false, createImageData: (w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h, }) as ImageData, putImageData: vi.fn(), }; } // #endregion describe('BTAPI', () => { beforeEach(() => { resetSingleton(); vi.resetAllMocks(); vi.stubGlobal('requestAnimationFrame', vi.fn()); installMockNavigatorGPU(); }); afterEach(() => { resetSingleton(); uninstallMockNavigatorGPU(); vi.unstubAllGlobals(); }); // #region Version constants describe('version constants', () => { it('should expose VERSION_MAJOR as a number', () => { expect(typeof BTAPI.VERSION_MAJOR).toBe('number'); }); it('should expose VERSION_MINOR as a number', () => { expect(typeof BTAPI.VERSION_MINOR).toBe('number'); }); it('should expose VERSION_PATCH as a number', () => { expect(typeof BTAPI.VERSION_PATCH).toBe('number'); }); it('should match package.json version', () => { const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json'); const { version } = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version: string }; const btapiVersion = `${BTAPI.VERSION_MAJOR}.${BTAPI.VERSION_MINOR}.${BTAPI.VERSION_PATCH}`; expect(btapiVersion).toBe(version); }); }); // #endregion // #region Singleton describe('singleton', () => { it('should return the same instance on multiple accesses', () => { const a = BTAPI.instance; const b = BTAPI.instance; expect(a).toBe(b); }); it('should create a new instance after singleton reset', () => { const a = BTAPI.instance; resetSingleton(); const b = BTAPI.instance; expect(a).not.toBe(b); }); }); // #endregion // #region Pre-init null-state accessors describe('pre-initialization accessors', () => { it('getTicks should return 0 before init', () => { expect(BTAPI.instance.getTicks()).toBe(0); }); it('resetTicks should not throw before init', () => { expect(() => BTAPI.instance.resetTicks()).not.toThrow(); }); it('getDevice should return null before init', () => { expect(BTAPI.instance.getDevice()).toBeNull(); }); it('getContext should return null before init', () => { expect(BTAPI.instance.getContext()).toBeNull(); }); it('getCanvas should return null before init', () => { expect(BTAPI.instance.getCanvas()).toBeNull(); }); it('getRenderer should return null before init', () => { expect(BTAPI.instance.getRenderer()).toBeNull(); }); it('getPointer should return null before init', () => { expect(BTAPI.instance.getPointer()).toBeNull(); }); it('getKeyboard should return null before init', () => { expect(BTAPI.instance.getKeyboard()).toBeNull(); }); it('getGamepad should return null before init', () => { expect(BTAPI.instance.getGamepad()).toBeNull(); }); it('getHardwareSettings should return null before init', () => { expect(BTAPI.instance.getHardwareSettings()).toBeNull(); }); it('getPalette should return null before palette is set', () => { expect(BTAPI.instance.getPalette()).toBeNull(); }); it('getCameraOffset should return a zero vector before init', () => { const offset = BTAPI.instance.getCameraOffset(); expect(offset.x).toBe(0); expect(offset.y).toBe(0); }); }); // #endregion // #region Pre-init no-ops describe('pre-initialization drawing no-ops', () => { it('stop should not throw before init', () => { expect(() => BTAPI.instance.stop()).not.toThrow(); }); it('setClearColor should not throw before init', () => { expect(() => BTAPI.instance.setClearColor(1)).not.toThrow(); }); it('clearRect should not throw before init', () => { expect(() => BTAPI.instance.clearRect(new Rect2i(0, 0, 10, 10), 1)).not.toThrow(); }); it('drawPixel should not throw before init', () => { expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), 2)).not.toThrow(); }); it('drawLine should not throw before init', () => { expect(() => BTAPI.instance.drawLine(new Vector2i(0, 0), new Vector2i(10, 10), 3)).not.toThrow(); }); it('drawRect should not throw before init', () => { expect(() => BTAPI.instance.drawRect(new Rect2i(0, 0, 10, 10), 4)).not.toThrow(); }); it('drawRectFill should not throw before init', () => { expect(() => BTAPI.instance.drawRectFill(new Rect2i(0, 0, 10, 10), 5)).not.toThrow(); }); it('drawSystemText should not throw before init', () => { expect(() => BTAPI.instance.drawSystemText(new Vector2i(0, 0), 8, 'test')).not.toThrow(); }); it('drawSprite should not throw before init', () => { const mockSheet = { isIndexized: () => true } as unknown as SpriteSheet; expect(() => BTAPI.instance.drawSprite(mockSheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)), ).not.toThrow(); }); it('drawSprite should throw when sprite sheet is not indexized', () => { const mockSheet = { isIndexized: () => false } as unknown as SpriteSheet; expect(() => BTAPI.instance.drawSprite(mockSheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0))).toThrow( "This sprite sheet hasn't been prepared yet.", ); }); it('drawSprite should register the sheet for spritesRefresh tracking', () => { const palette = new Palette(16); const reindexize = vi.fn(); const mockSheet = { isIndexized: () => true, reindexize } as unknown as SpriteSheet; BTAPI.instance.setPalette(palette); BTAPI.instance.drawSprite(mockSheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0)); BTAPI.instance.spritesRefresh(); expect(reindexize).toHaveBeenCalledWith(palette); }); it('drawBitmapText should not throw before init', () => { const mockSheet = { isIndexized: () => true } as unknown as SpriteSheet; const mockFont = { getSpriteSheet: () => mockSheet } as unknown as BitmapFont; expect(() => BTAPI.instance.drawBitmapText(mockFont, new Vector2i(0, 0), 'hi')).not.toThrow(); }); it('drawBitmapText should throw when font sprite sheet is not indexized', () => { const mockSheet = { isIndexized: () => false } as unknown as SpriteSheet; const mockFont = { getSpriteSheet: () => mockSheet } as unknown as BitmapFont; expect(() => BTAPI.instance.drawBitmapText(mockFont, new Vector2i(0, 0), 'hi')).toThrow( "This sprite sheet hasn't been prepared yet.", ); }); it('drawBitmapText should register the font sheet for spritesRefresh tracking', () => { const palette = new Palette(16); const reindexize = vi.fn(); const mockSheet = { isIndexized: () => true, reindexize } as unknown as SpriteSheet; const mockFont = { getSpriteSheet: () => mockSheet } as unknown as BitmapFont; BTAPI.instance.setPalette(palette); BTAPI.instance.drawBitmapText(mockFont, new Vector2i(0, 0), 'hi'); BTAPI.instance.spritesRefresh(); expect(reindexize).toHaveBeenCalledWith(palette); }); it('setCameraOffset should not throw before init', () => { expect(() => BTAPI.instance.setCameraOffset(new Vector2i(10, 20))).not.toThrow(); }); it('resetCamera should not throw before init', () => { expect(() => BTAPI.instance.resetCamera()).not.toThrow(); }); it('setPalette should store the provided palette before init', () => { const palette = new Palette(16); BTAPI.instance.setPalette(palette); expect(BTAPI.instance.getPalette()).toBe(palette); }); }); // #endregion // #region init() describe('init', () => { it('should return false for NaN targetFPS', async () => { const result = await BTAPI.instance.init(makeMockDemo(NaN), makeMockCanvas()); expect(result).toBe(false); }); it('should return false for zero targetFPS', async () => { const result = await BTAPI.instance.init(makeMockDemo(0), makeMockCanvas()); expect(result).toBe(false); }); it('should return false for negative targetFPS', async () => { const result = await BTAPI.instance.init(makeMockDemo(-30), makeMockCanvas()); expect(result).toBe(false); }); it('rejects invalid displaySize before layout or renderer setup', async () => { const demo: IBlitTechDemo = { configure: vi.fn().mockReturnValue({ displaySize: { x: 0, y: 240 } as Vector2i, targetFPS: 60, }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMockCanvas(); const getContext = vi.fn(canvas.getContext.bind(canvas)); (canvas as unknown as { getContext: typeof getContext }).getContext = getContext; const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(false); expect(canvas.width).toBe(0); expect(canvas.height).toBe(0); expect(canvas.style.setProperty).not.toHaveBeenCalled(); expect(getContext).not.toHaveBeenCalled(); expect(demo.init).not.toHaveBeenCalled(); }); it('rejects invalid software canvasDisplaySize before software renderer allocation', async () => { const demo: IBlitTechDemo = { configure: vi.fn().mockReturnValue({ displaySize: new Vector2i(320, 240), canvasDisplaySize: { x: 8193, y: 480 } as Vector2i, targetFPS: 60, renderer: 'software', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMock2DCanvas(); const getContext = vi.fn(canvas.getContext.bind(canvas)); (canvas as unknown as { getContext: typeof getContext }).getContext = getContext; const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(false); expect(canvas.width).toBe(0); expect(canvas.height).toBe(0); expect(getContext).not.toHaveBeenCalled(); }); it('rejects invalid maxCanvasDisplaySize before layout or renderer setup', async () => { const demo: IBlitTechDemo = { configure: vi.fn().mockReturnValue({ displaySize: new Vector2i(320, 240), maxCanvasDisplaySize: { x: Number.NaN, y: 720 } as Vector2i, targetFPS: 60, }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMockCanvas(); const getContext = vi.fn(canvas.getContext.bind(canvas)); (canvas as unknown as { getContext: typeof getContext }).getContext = getContext; const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(false); expect(canvas.width).toBe(0); expect(canvas.height).toBe(0); expect(canvas.style.setProperty).not.toHaveBeenCalled(); expect(getContext).not.toHaveBeenCalled(); expect(demo.init).not.toHaveBeenCalled(); }); it('rejects WebGPU dimensions above adapter texture limits before canvas allocation', async () => { const requestDevice = vi.fn(async () => createMockGPUDevice()); Object.defineProperty(globalThis, 'navigator', { value: { gpu: { requestAdapter: async () => ({ requestDevice, features: new Set(), limits: { maxTextureDimension2D: 1024 } as GPUSupportedLimits, }), getPreferredCanvasFormat: () => 'bgra8unorm' as GPUTextureFormat, }, userAgent: 'test', }, writable: true, configurable: true, }); const demo: IBlitTechDemo = { configure: vi.fn().mockReturnValue({ displaySize: new Vector2i(2048, 1024), targetFPS: 60, renderer: 'webgpu', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMockCanvas(); const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(false); expect(canvas.width).toBe(0); expect(canvas.height).toBe(0); expect(requestDevice).not.toHaveBeenCalled(); expect(demo.init).not.toHaveBeenCalled(); }); it('rejects WebGPU canvasDisplaySize above adapter texture limits before canvas allocation', async () => { const requestDevice = vi.fn(async () => createMockGPUDevice()); Object.defineProperty(globalThis, 'navigator', { value: { gpu: { requestAdapter: async () => ({ requestDevice, features: new Set(), limits: { maxTextureDimension2D: 1024 } as GPUSupportedLimits, }), getPreferredCanvasFormat: () => 'bgra8unorm' as GPUTextureFormat, }, userAgent: 'test', }, writable: true, configurable: true, }); const demo: IBlitTechDemo = { configure: vi.fn().mockReturnValue({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(2048, 1024), targetFPS: 60, renderer: 'webgpu', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMockCanvas(); const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(false); expect(canvas.width).toBe(0); expect(canvas.height).toBe(0); expect(requestDevice).not.toHaveBeenCalled(); expect(demo.init).not.toHaveBeenCalled(); }); it('rejects WebGPU dimensions above device texture limits without software fallback', async () => { const requestDevice = vi.fn(async () => ({ ...createMockGPUDevice(), limits: { maxTextureDimension2D: 512 } as GPUSupportedLimits, })); Object.defineProperty(globalThis, 'navigator', { value: { gpu: { requestAdapter: async () => ({ requestDevice, features: new Set(), limits: { maxTextureDimension2D: 2048 } as GPUSupportedLimits, }), getPreferredCanvasFormat: () => 'bgra8unorm' as GPUTextureFormat, }, userAgent: 'test', }, writable: true, configurable: true, }); const demo: IBlitTechDemo = { configure: vi.fn().mockReturnValue({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(1024, 768), targetFPS: 60, renderer: 'webgpu', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMock2DCanvas(); const getContext = vi.fn(canvas.getContext.bind(canvas)); (canvas as unknown as { getContext: typeof getContext }).getContext = getContext; const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(false); expect(canvas.width).toBe(0); expect(canvas.height).toBe(0); expect(requestDevice).toHaveBeenCalled(); expect(getContext).not.toHaveBeenCalledWith('2d'); expect(BTAPI.instance.getActiveBackend()).toBeNull(); expect(demo.init).not.toHaveBeenCalled(); }); it('returns false when both WebGPU and software renderer init fail', async () => { uninstallMockNavigatorGPU(); // makeMockCanvas() returns null for getContext('2d'), so the auto-fallback // SoftwareRenderer also fails to initialize. const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(false); }); it('initializes successfully in software mode when WebGPU is unavailable', async () => { uninstallMockNavigatorGPU(); vi.stubGlobal( 'OffscreenCanvas', class MockOffscreenCanvas { constructor( public width: number, public height: number, ) {} getContext(contextType?: string): OffscreenCanvas2DMock | null { return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); const demo: IBlitTechDemo = { configure: () => ({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(640, 480), targetFPS: 60, renderer: 'software', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMock2DCanvas(); const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(true); expect(BTAPI.instance.getDevice()).toBeNull(); expect(BTAPI.instance.getContext()).toBeNull(); expect(BTAPI.instance.getRenderer()).not.toBeNull(); }); it('URL override ?renderer=software wins over configure().renderer=webgpu', async () => { vi.stubGlobal('location', { search: '?renderer=software' }); vi.stubGlobal( 'OffscreenCanvas', class MockOffscreenCanvas { constructor( public width: number, public height: number, ) {} getContext(contextType?: string): OffscreenCanvas2DMock | null { return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); uninstallMockNavigatorGPU(); const demo: IBlitTechDemo = { configure: () => ({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(640, 480), targetFPS: 60, renderer: 'webgpu', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMock2DCanvas(); const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(true); expect(BTAPI.instance.getHardwareSettings()?.renderer).toBe('software'); expect(BTAPI.instance.getDevice()).toBeNull(); }); it('ignores unknown renderer query values and keeps configure renderer', async () => { vi.stubGlobal('location', { search: '?renderer=banana' }); const demo: IBlitTechDemo = { configure: () => ({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(640, 480), targetFPS: 60, renderer: 'webgpu', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const result = await BTAPI.instance.init(demo, makeMockCanvas()); expect(result).toBe(true); expect(BTAPI.instance.getHardwareSettings()?.renderer).toBe('webgpu'); expect(BTAPI.instance.getDevice()).not.toBeNull(); }); it('falls back to software (and fails cleanly) when WebGPU adapter is unavailable', async () => { Object.defineProperty(globalThis, 'navigator', { value: { gpu: { requestAdapter: async () => null, getPreferredCanvasFormat: () => 'bgra8unorm' as GPUTextureFormat, }, userAgent: 'test', }, writable: true, configurable: true, }); // BTAPI catches the adapter throw and falls back to software. // makeMockCanvas() has no 2D context, so software init also fails -> false. const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(false); }); it('should return false when renderer initialization fails', async () => { // Provide an adapter that returns a device broken for shader compilation. Object.defineProperty(globalThis, 'navigator', { value: { gpu: { requestAdapter: async () => ({ requestDevice: async () => ({ createShaderModule: () => { throw new Error("GPU isn't available"); }, }), }), getPreferredCanvasFormat: () => 'bgra8unorm' as GPUTextureFormat, }, userAgent: 'test', }, writable: true, configurable: true, }); const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(false); }); it('should return false when demo.init() returns false', async () => { const result = await BTAPI.instance.init(makeMockDemo(60, false), makeMockCanvas()); expect(result).toBe(false); }); it('should return true and populate accessors on success', async () => { const canvas = makeMockCanvas(); const result = await BTAPI.instance.init(makeMockDemo(), canvas); expect(result).toBe(true); expect(BTAPI.instance.getDevice()).not.toBeNull(); expect(BTAPI.instance.getContext()).not.toBeNull(); expect(BTAPI.instance.getCanvas()).toBe(canvas); expect(BTAPI.instance.getRenderer()).not.toBeNull(); expect(BTAPI.instance.getHardwareSettings()).not.toBeNull(); expect(BTAPI.instance.getPointer()).not.toBeNull(); expect(BTAPI.instance.getKeyboard()).not.toBeNull(); expect(BTAPI.instance.getGamepad()).not.toBeNull(); }); it('should merge partial configure with defaultConfig', async () => { const demo: IBlitTechDemo = { configure: () => ({ targetFPS: 30 }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const result = await BTAPI.instance.init(demo, makeMockCanvas()); expect(result).toBe(true); const hw = BTAPI.instance.getHardwareSettings(); expect(hw).not.toBeNull(); expect(hw?.displaySize.x).toBe(320); expect(hw?.canvasDisplaySize?.x).toBe(640); expect(hw?.targetFPS).toBe(30); }); it('should use defaultConfig when configure is omitted', async () => { const demo: IBlitTechDemo = { init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const result = await BTAPI.instance.init(demo, makeMockCanvas()); expect(result).toBe(true); const hw = BTAPI.instance.getHardwareSettings(); expect(hw).not.toBeNull(); expect(hw?.displaySize.x).toBe(320); expect(hw?.displaySize.y).toBe(240); expect(hw?.canvasDisplaySize?.x).toBe(640); expect(hw?.canvasDisplaySize?.y).toBe(480); expect(hw?.outputUpscaleFilter).toBe('nearest'); expect(hw?.targetFPS).toBe(60); }); it('stop detaches pointer and keyboard input so subsequent accessors return null', async () => { await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(BTAPI.instance.getPointer()).not.toBeNull(); expect(BTAPI.instance.getKeyboard()).not.toBeNull(); expect(BTAPI.instance.getGamepad()).not.toBeNull(); BTAPI.instance.stop(); expect(BTAPI.instance.getPointer()).toBeNull(); expect(BTAPI.instance.getKeyboard()).toBeNull(); expect(BTAPI.instance.getGamepad()).toBeNull(); }); it('double init without stop detaches prior pointer and keyboard before reattaching', async () => { const canvas = makeMockCanvas(); await BTAPI.instance.init(makeMockDemo(), canvas); const pointerBefore = BTAPI.instance.getPointer(); const keyboardBefore = BTAPI.instance.getKeyboard(); expect(pointerBefore).not.toBeNull(); expect(keyboardBefore).not.toBeNull(); vi.mocked(canvas.removeEventListener).mockClear(); await BTAPI.instance.init(makeMockDemo(), canvas); expect(BTAPI.instance.getPointer()).not.toBe(pointerBefore); expect(BTAPI.instance.getKeyboard()).not.toBe(keyboardBefore); const removedKinds = vi.mocked(canvas.removeEventListener).mock.calls.map((call) => call[0]); expect(removedKinds).toContain('pointermove'); expect(removedKinds).toContain('keydown'); }); it('should start the game loop on success', async () => { await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(requestAnimationFrame).toHaveBeenCalled(); }); it('calls gamepad.endFrame during render-phase input flush', async () => { const rafCallbacks: FrameRequestCallback[] = []; vi.stubGlobal( 'requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { rafCallbacks.push(callback); return rafCallbacks.length; }), ); await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const gamepad = BTAPI.instance.getGamepad(); expect(gamepad).not.toBeNull(); BTAPI.instance.setPalette(new Palette(16)); const endFrameSpy = vi.spyOn(gamepad as NonNullable, 'endFrame'); // GameLoop.start uses a double-rAF bootstrap before the first tick. const maxIterations = 1000; let iterations = 0; while (rafCallbacks.length > 0) { iterations++; if (iterations > maxIterations) { throw new Error( 'Exceeded max rAF callback drain iterations before gamepad.endFrame was called; possible loop stall.', ); } const cb = rafCallbacks.shift(); if (cb) { cb(16); } if (endFrameSpy.mock.calls.length > 0) { break; } } expect(endFrameSpy).toHaveBeenCalled(); }); it('stop should not throw after successful initialization', async () => { await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(() => BTAPI.instance.stop()).not.toThrow(); }); it('captureFrame returns a blob after successful initialization', async () => { await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const renderer = BTAPI.instance.getRenderer(); expect(renderer).not.toBeNull(); const mockBlob = new Blob(['test'], { type: 'image/png' }); vi.spyOn(renderer as NonNullable, 'captureFrame').mockResolvedValue(mockBlob); const result = await BTAPI.instance.captureFrame(); expect(result).toBe(mockBlob); }); it('captureFrame works in software mode after a rendered frame', async () => { vi.stubGlobal('location', { search: '?renderer=software' }); vi.stubGlobal( 'OffscreenCanvas', class MockOffscreenCanvas { constructor( public width: number, public height: number, ) {} getContext(contextType?: string): OffscreenCanvas2DMock | null { return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); uninstallMockNavigatorGPU(); const demo: IBlitTechDemo = { configure: () => ({ displaySize: new Vector2i(320, 240), canvasDisplaySize: new Vector2i(640, 480), targetFPS: 60, renderer: 'software', }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMock2DCanvas(); await BTAPI.instance.init(demo, canvas); BTAPI.instance.setPalette(new Palette(16)); const capturePromise = BTAPI.instance.captureFrame(); const renderer = BTAPI.instance.getRenderer(); expect(renderer).not.toBeNull(); renderer?.beginFrame(); renderer?.endFrame(); const blob = await capturePromise; expect(blob.type).toBe('image/png'); }); it('auto-falls back to software when WebGPU is unavailable and 2D canvas is available', async () => { uninstallMockNavigatorGPU(); vi.stubGlobal( 'OffscreenCanvas', class MockOffscreenCanvas { constructor( public width: number, public height: number, ) {} getContext(contextType?: string): OffscreenCanvas2DMock | null { return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); const demo: IBlitTechDemo = { configure: () => ({ displaySize: new Vector2i(320, 240), targetFPS: 60, // No renderer field - defaults to 'webgpu', should auto-fallback }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; const canvas = makeMock2DCanvas(); const result = await BTAPI.instance.init(demo, canvas); expect(result).toBe(true); expect(BTAPI.instance.getDevice()).toBeNull(); expect(BTAPI.instance.getContext()).toBeNull(); expect(BTAPI.instance.getRenderer()).not.toBeNull(); expect(BTAPI.instance.getActiveBackend()).toBe('software'); }); it('reports webgpu as active backend when WebGPU succeeds', async () => { const result = await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); expect(result).toBe(true); expect(BTAPI.instance.getActiveBackend()).toBe('webgpu'); }); it('reports null active backend before init', () => { expect(BTAPI.instance.getActiveBackend()).toBeNull(); }); }); // #endregion // #region assertPaletteIndex describe('assertPaletteIndex', () => { it('throws when index is negative (no palette set)', () => { expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), -1)).toThrow('0 or higher'); }); it('throws when index is out of range for the active palette', () => { const palette = new Palette(16); BTAPI.instance.setPalette(palette); expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), 20)).toThrow('The color number 20 is too big'); }); }); // #endregion // #region Post-Process Effects API describe('post-process effects', () => { function makeStubEffect(): Effect { return { tier: 'pixel', init: vi.fn(), updateUniforms: vi.fn(), encodePass: vi.fn(), dispose: vi.fn(), }; } it('effectAdd throws before init', () => { expect(() => BTAPI.instance.effectAdd(makeStubEffect())).toThrow('renderer not initialized'); }); it('effectRemove throws before init', () => { expect(() => BTAPI.instance.effectRemove(makeStubEffect())).toThrow('renderer not initialized'); }); it('effectClear throws before init', () => { expect(() => BTAPI.instance.effectClear()).toThrow('renderer not initialized'); }); it('effectAdd / effectClear delegate to the renderer after init', async () => { await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const renderer = BTAPI.instance.getRenderer(); expect(renderer).not.toBeNull(); const addSpy = vi.spyOn(renderer as NonNullable, 'addEffect'); const clearSpy = vi.spyOn(renderer as NonNullable, 'clearEffects'); const effect = makeStubEffect(); BTAPI.instance.effectAdd(effect); expect(addSpy).toHaveBeenCalledWith(effect); BTAPI.instance.effectClear(); expect(clearSpy).toHaveBeenCalled(); }); it('effectRemove delegates to the renderer after init', async () => { await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); const renderer = BTAPI.instance.getRenderer(); const removeSpy = vi.spyOn(renderer as NonNullable, 'removeEffect'); const effect = makeStubEffect(); BTAPI.instance.effectAdd(effect); BTAPI.instance.effectRemove(effect); expect(removeSpy).toHaveBeenCalledWith(effect); }); }); // #endregion // #region Software ticker describe('software ticker', () => { it('renders ticker when software backend is active', async () => { const rafCallbacks: FrameRequestCallback[] = []; vi.stubGlobal( 'requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => { rafCallbacks.push(cb); return rafCallbacks.length; }), ); vi.stubGlobal( 'OffscreenCanvas', class MockOffscreenCanvas { constructor( public width: number, public height: number, ) {} getContext(contextType?: string): OffscreenCanvas2DMock | null { return contextType === '2d' ? makeOffscreenCanvas2dContext() : null; } }, ); uninstallMockNavigatorGPU(); const demo: IBlitTechDemo = { configure: () => ({ displaySize: new Vector2i(320, 240), targetFPS: 60, renderer: 'software' as const, }), init: vi.fn().mockResolvedValue(true), update: vi.fn(), render: vi.fn(), }; await BTAPI.instance.init(demo, makeMock2DCanvas()); BTAPI.instance.setPalette(new Palette(16)); const renderer = BTAPI.instance.getRenderer(); expect(renderer).not.toBeNull(); const drawRectFillSpy = vi.spyOn(renderer as NonNullable, 'drawRectFill'); const drawBitmapTextSpy = vi.spyOn(renderer as NonNullable, 'drawBitmapText'); // Drain rAF queue (double-rAF bootstrap + first tick). const maxIterations = 1000; let iterations = 0; while (rafCallbacks.length > 0) { iterations++; if (iterations > maxIterations) { throw new Error('rAF drain exceeded max iterations'); } const cb = rafCallbacks.shift(); cb?.(16); if (drawRectFillSpy.mock.calls.length > 0) break; } expect(drawRectFillSpy).toHaveBeenCalled(); expect(drawBitmapTextSpy).toHaveBeenCalled(); // Background rect should start at (0, 0) and span the full display width. const firstRectCall = drawRectFillSpy.mock.calls[0]; expect(firstRectCall).toBeDefined(); if (firstRectCall) { const [bgRect] = firstRectCall; expect(bgRect.x).toBe(0); expect(bgRect.y).toBe(0); expect(bgRect.width).toBe(320); } }); it('does not render ticker when WebGPU backend is active', async () => { const rafCallbacks: FrameRequestCallback[] = []; vi.stubGlobal( 'requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => { rafCallbacks.push(cb); return rafCallbacks.length; }), ); // navigator.gpu is installed by beforeEach; WebGPU succeeds. await BTAPI.instance.init(makeMockDemo(), makeMockCanvas()); BTAPI.instance.setPalette(new Palette(16)); expect(BTAPI.instance.getActiveBackend()).toBe('webgpu'); const renderer = BTAPI.instance.getRenderer(); const drawRectFillSpy = vi.spyOn(renderer as NonNullable, 'drawRectFill'); // Drain a few rAF ticks. let drained = 0; while (rafCallbacks.length > 0 && drained < 10) { drained++; const cb = rafCallbacks.shift(); cb?.(16); } // The demo's render() is a vi.fn() no-op, so any drawRectFill would be from the ticker. expect(drawRectFillSpy).not.toHaveBeenCalled(); }); }); // #endregion });