Something went wrong. Try again.
A WebGPU retro game engine for TypeScript, inspired by RetroBlit. Build pixel-perfect 2D games with a clean, fantasy-console-style API.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167/** * 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<typeof vi.fn>;};
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<typeof gamepad>, '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<typeof renderer>, '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<typeof renderer>, 'addEffect'); const clearSpy = vi.spyOn(renderer as NonNullable<typeof renderer>, '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<typeof renderer>, '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<typeof renderer>, 'drawRectFill'); const drawBitmapTextSpy = vi.spyOn(renderer as NonNullable<typeof renderer>, '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<typeof renderer>, '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});