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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144// @vitest-environment happy-dom
/** * Unit tests for {@link PointerInput}. * * Verifies the slot-allocation rules, button mapping, coordinate conversion, * per-frame reset, lifecycle (attach / detach), and edge-case safe-default * behavior of the DOM-backed pointer input subsystem. The test file uses * happy-dom for `PointerEvent`, `WheelEvent`, and canvas DOM APIs. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Vector2i } from '../utils/Vector2i';import { POINTER_SLOT_COUNT, PointerInput } from './PointerInput';
const BTN_POINTER_A = 20;const BTN_POINTER_B = 21;const BTN_POINTER_C = 22;const BTN_POINTER_D = 23;
const DISPLAY_WIDTH = 320;const DISPLAY_HEIGHT = 240;
const RECT_LEFT = 10;const RECT_TOP = 20;const RECT_WIDTH = 640;const RECT_HEIGHT = 480;
interface BoundingRect { left: number; top: number; width: number; height: number;}
/** * Mounts a canvas and stubs `getBoundingClientRect` so coordinate conversion * is deterministic. */const createCanvas = ( rect: BoundingRect = { left: RECT_LEFT, top: RECT_TOP, width: RECT_WIDTH, height: RECT_HEIGHT },): HTMLCanvasElement => { const canvas = document.createElement('canvas');
canvas.getBoundingClientRect = vi.fn(() => ({ left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height, width: rect.width, height: rect.height, x: rect.left, y: rect.top, toJSON: () => ({}), })) as unknown as typeof canvas.getBoundingClientRect;
document.body.appendChild(canvas);
return canvas;};
/** * Builds a PointerEvent. happy-dom supports the constructor; we keep this in * a helper so the test cases stay terse. */const pointerEvent = ( type: string, init: { pointerId: number; pointerType?: 'mouse' | 'touch' | 'pen'; button?: number; clientX?: number; clientY?: number; },): PointerEvent => new PointerEvent(type, { pointerId: init.pointerId, pointerType: init.pointerType ?? 'mouse', button: init.button ?? 0, clientX: init.clientX ?? 0, clientY: init.clientY ?? 0, bubbles: true, cancelable: true, });
describe('PointerInput', () => { let canvas: HTMLCanvasElement; let input: PointerInput;
beforeEach(() => { canvas = createCanvas(); input = new PointerInput(); input.attach(canvas, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); });
afterEach(() => { input.detach(); canvas.remove(); });
// #region Construction
describe('construction', () => { it('initializes all slots as inactive', () => { const fresh = new PointerInput();
for (let i = 0; i < POINTER_SLOT_COUNT; i++) { expect(fresh.isValid(i)).toBe(false); }
expect(fresh.getScrollDelta()).toBe(0); }); });
// #endregion
// #region Lifecycle
describe('attach / detach', () => { it('sets canvas.style.touchAction to "none" on attach', () => { const c = createCanvas(); const p = new PointerInput();
expect(c.style.touchAction).toBe(''); p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); expect(c.style.touchAction).toBe('none');
p.detach(); c.remove(); });
it('restores the original touchAction on detach', () => { const c = createCanvas(); c.style.touchAction = 'pan-y'; const p = new PointerInput();
p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); expect(c.style.touchAction).toBe('none');
p.detach(); expect(c.style.touchAction).toBe('pan-y');
c.remove(); });
it('removes listeners on detach so subsequent events are ignored', () => { input.detach();
canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
expect(input.isValid(0)).toBe(false); });
it('safe to call detach before attach', () => { const p = new PointerInput();
expect(() => p.detach()).not.toThrow(); });
it('safe to call attach twice (re-binds listeners cleanly)', () => { const otherCanvas = createCanvas();
input.attach(otherCanvas, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT));
otherCanvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 5, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
expect(input.isValid(0)).toBe(true);
otherCanvas.remove(); });
it('releases active pointer captures on detach', () => { const c = createCanvas(); const p = new PointerInput(); p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT));
// Register a touch contact so a pointerId is tracked in pointerIdToSlot. c.dispatchEvent( pointerEvent('pointerdown', { pointerId: 42, pointerType: 'touch', button: 0, clientX: 50, clientY: 50, }), );
// Stub hasPointerCapture so detach() believes the capture is active. vi.spyOn(c, 'hasPointerCapture').mockReturnValue(true); const releaseSpy = vi.spyOn(c, 'releasePointerCapture');
p.detach();
expect(releaseSpy).toHaveBeenCalledWith(42);
vi.restoreAllMocks(); c.remove(); }); });
describe('cursor management', () => { it('hideCursor sets canvas.style.cursor to "none"', () => { input.hideCursor(); expect(canvas.style.cursor).toBe('none'); });
it('showCursor restores the cursor value captured at attach time', () => { const c = createCanvas(); c.style.cursor = 'crosshair'; const p = new PointerInput();
p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); p.hideCursor(); expect(c.style.cursor).toBe('none');
p.showCursor(); expect(c.style.cursor).toBe('crosshair');
p.detach(); c.remove(); });
it('detach restores the cursor saved at attach time', () => { const c = createCanvas(); c.style.cursor = 'wait'; const p = new PointerInput();
p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); p.hideCursor(); expect(c.style.cursor).toBe('none');
p.detach(); expect(c.style.cursor).toBe('wait');
c.remove(); });
it('hideCursor and showCursor are no-ops before attach', () => { const p = new PointerInput();
expect(() => p.hideCursor()).not.toThrow(); expect(() => p.showCursor()).not.toThrow(); }); });
// #endregion
// #region Mouse Slot 0
describe('mouse routing (slot 0)', () => { it('routes mouse pointerdown button 0 to slot 0 with BTN_POINTER_A held', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
expect(input.isValid(0)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(true); expect(input.isButtonDown(BTN_POINTER_B, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_C, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_D, 0)).toBe(false); });
it.each([ { domButton: 0, btn: BTN_POINTER_A, name: 'A (left)' }, { domButton: 2, btn: BTN_POINTER_B, name: 'B (right)' }, { domButton: 1, btn: BTN_POINTER_C, name: 'C (middle)' }, { domButton: 3, btn: BTN_POINTER_D, name: 'D (back)' }, { domButton: 4, btn: BTN_POINTER_D, name: 'D (forward)' }, ])('maps DOM button $domButton to BTN_POINTER_$name', ({ domButton, btn }) => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: domButton, clientX: 50, clientY: 50, }), );
expect(input.isButtonDown(btn, 0)).toBe(true); });
it('activates slot 0 on a mouse pointermove without a prior pointerdown', () => { canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), );
expect(input.isValid(0)).toBe(true); });
it('clears all four mouse buttons and deactivates slot 0 on pointerleave', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), ); canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 2, clientX: 50, clientY: 50, }), );
canvas.dispatchEvent( pointerEvent('pointerleave', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), );
expect(input.isValid(0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_B, 0)).toBe(false); });
it('keeps slot 0 valid after pointerup (only the button releases)', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50 }), );
expect(input.isValid(0)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); });
it('ignores an unknown DOM mouse button code (default case in setMouseButton)', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 5, clientX: 50, clientY: 50, }), );
expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_B, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_C, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_D, 0)).toBe(false); });
it('deactivates mouse slot on pointercancel', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
expect(input.isValid(0)).toBe(true);
canvas.dispatchEvent( pointerEvent('pointercancel', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), );
expect(input.isValid(0)).toBe(false); });
it('handles pointerleave for mouse when slot was never activated (null pointerId path)', () => { canvas.dispatchEvent( pointerEvent('pointerleave', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), );
expect(input.isValid(0)).toBe(false); }); });
// #endregion
// #region Touch Slot Allocation
describe('touch slot allocation', () => { it('routes three sequential touches to slots 1, 2, 3 in arrival order', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 100, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), ); canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 101, pointerType: 'touch', button: 0, clientX: 20, clientY: 20, }), ); canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 102, pointerType: 'touch', button: 0, clientX: 30, clientY: 30, }), );
expect(input.isValid(1)).toBe(true); expect(input.isValid(2)).toBe(true); expect(input.isValid(3)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 1)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 2)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 3)).toBe(true); });
it('drops a 4th simultaneous touch silently when all slots are full', () => { for (let i = 0; i < 4; i++) { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 200 + i, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), ); }
// Slots 1-3 occupied; 4th touch should not throw or appear anywhere. expect(input.isValid(1)).toBe(true); expect(input.isValid(2)).toBe(true); expect(input.isValid(3)).toBe(true); });
it('frees a touch slot on pointerup; the next touch reuses it', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 300, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), ); canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 300, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), );
expect(input.isValid(1)).toBe(false);
canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 301, pointerType: 'touch', button: 0, clientX: 20, clientY: 20, }), );
expect(input.isValid(1)).toBe(true); });
it('frees a touch slot on pointercancel', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 400, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), );
canvas.dispatchEvent( pointerEvent('pointercancel', { pointerId: 400, pointerType: 'touch', clientX: 10, clientY: 10 }), );
expect(input.isValid(1)).toBe(false); });
it('reports BTN_POINTER_B/C/D as false for touch slots even when valid', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 500, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), );
expect(input.isValid(1)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 1)).toBe(true); expect(input.isButtonDown(BTN_POINTER_B, 1)).toBe(false); expect(input.isButtonDown(BTN_POINTER_C, 1)).toBe(false); expect(input.isButtonDown(BTN_POINTER_D, 1)).toBe(false); });
it('routes pen input to touch slots 1-3 (not slot 0)', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 600, pointerType: 'pen', button: 0, clientX: 10, clientY: 10, }), );
expect(input.isValid(0)).toBe(false); expect(input.isValid(1)).toBe(true); });
it('ignores a duplicate pointerdown for the same touch ID', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 900, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), );
expect(input.isValid(1)).toBe(true);
canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 900, pointerType: 'touch', button: 0, clientX: 20, clientY: 20, }), );
expect(input.isValid(1)).toBe(true); expect(input.isValid(2)).toBe(false); });
it('ignores touch pointermove with no prior pointerdown (unknown slot)', () => { expect(() => { canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 999, pointerType: 'touch', clientX: 50, clientY: 50 }), ); }).not.toThrow();
expect(input.isValid(1)).toBe(false); });
it('ignores touch pointerup with no prior pointerdown (unknown slot)', () => { expect(() => { canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 999, pointerType: 'touch', button: 0, clientX: 50, clientY: 50, }), ); }).not.toThrow(); });
it('ignores touch pointercancel with no prior pointerdown (unknown slot)', () => { expect(() => { canvas.dispatchEvent( pointerEvent('pointercancel', { pointerId: 999, pointerType: 'touch', clientX: 50, clientY: 50 }), ); }).not.toThrow(); });
it('frees a touch slot on pointerleave and ignores leave for an unregistered touch ID', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1001, pointerType: 'touch', button: 0, clientX: 10, clientY: 10, }), );
expect(input.isValid(1)).toBe(true);
canvas.dispatchEvent( pointerEvent('pointerleave', { pointerId: 1001, pointerType: 'touch', clientX: 10, clientY: 10 }), );
expect(input.isValid(1)).toBe(false);
expect(() => { canvas.dispatchEvent( pointerEvent('pointerleave', { pointerId: 9999, pointerType: 'touch', clientX: 10, clientY: 10 }), ); }).not.toThrow(); }); });
// #endregion
// #region Coordinate Conversion
describe('coordinate conversion', () => { it('maps clientX/clientY to display coordinates via getBoundingClientRect', () => { // rect = { left:10, top:20, width:640, height:480 } // clientX 170 -> (170-10)/640 = 0.25 of width -> 0.25 * 320 = 80 // clientY 140 -> (140-20)/480 = 0.25 of height -> 0.25 * 240 = 60 canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 170, clientY: 140 }), );
const pos = input.getPos(0);
expect(pos.x).toBe(80); expect(pos.y).toBe(60); });
it('does not throw on a zero-sized canvas (skips the position update)', () => { const c = createCanvas({ left: 0, top: 0, width: 0, height: 0 }); const p = new PointerInput();
p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT));
expect(() => { c.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), ); }).not.toThrow();
// Position stays at zero since the update is skipped to avoid NaN. const pos = p.getPos(0);
expect(pos.x).toBe(0); expect(pos.y).toBe(0);
p.detach(); c.remove(); }); });
// #endregion
// #region Per-Frame State
// The lifecycle model: each rAF tick is "events arrive (between ticks) -> // update / render read state -> endFrame snapshots current as prev". The // tests simulate one tick by dispatching events (the inter-frame window), // querying state (the update / render phase), then calling endFrame. describe('endFrame and per-frame state', () => { it('reports delta from prev snapshot to current pos', () => { // Tick 1 inter-frame: activation move. Slot 0 becomes valid and // its prevPos is synced to the entry pos so the first delta is // zero (see "reports zero delta on the first frame after mouse // activation"). Closing tick 1 with endFrame leaves prev = pos. canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 170, clientY: 140 }), ); input.endFrame();
// Tick 2 inter-frame: a single further move. canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 186, clientY: 156 }), );
// Tick 2 update: delta = current pos - prev pos snapshotted at // the end of tick 1. // rect = { left:10, top:20, width:640, height:480 }, display 320x240. // (170, 140) -> (80, 60). (186, 156) -> (88, 68). // Expected delta = (88 - 80, 68 - 60) = (8, 8). const delta = input.getDelta(0); expect(delta.x).toBe(8); expect(delta.y).toBe(8); });
it('zeroes delta after endFrame snapshots the new prevPos', () => { canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 60 }), );
input.endFrame();
// Without further movement, the next read sees no delta. const delta = input.getDelta(0); expect(delta.x).toBe(0); expect(delta.y).toBe(0); });
it('zeroes scrollDelta on endFrame', () => { canvas.dispatchEvent( new WheelEvent('wheel', { deltaY: 100, deltaMode: 0, bubbles: true, cancelable: true }), );
expect(input.getScrollDelta()).toBe(100);
input.endFrame();
expect(input.getScrollDelta()).toBe(0); });
it('reports isButtonPressed only on the tick after the button transitions to down', () => { // Inter-frame window: button goes down. canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
// First tick after the down event sees the press edge. expect(input.isButtonPressed(BTN_POINTER_A, 0)).toBe(true);
input.endFrame();
// Subsequent tick: button is still held but no new edge. expect(input.isButtonPressed(BTN_POINTER_A, 0)).toBe(false); expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(true); });
it('reports isButtonReleased only on the tick after the button transitions to up', () => { // Tick 1 inter-frame: button goes down. canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), ); input.endFrame();
// Tick 2 inter-frame: button goes up. canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50 }), );
expect(input.isButtonReleased(BTN_POINTER_A, 0)).toBe(true);
input.endFrame();
expect(input.isButtonReleased(BTN_POINTER_A, 0)).toBe(false); });
it('preserves pos and delta on the touch-release frame', () => { // Simulates a tick lifecycle with a touch landing, dragging, then // releasing - the demo's drag-and-flick pattern. The release-frame // delta must reflect the final movement so the demo can use it as // throw velocity.
// Tick 1 inter-frame: touch lands. canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 700, pointerType: 'touch', button: 0, clientX: 170, clientY: 140, }), ); input.endFrame();
// Tick 2 inter-frame: touch moves a small step. canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 700, pointerType: 'touch', clientX: 178, clientY: 148 }), ); input.endFrame();
// Tick 3 inter-frame: touch moves further, then releases. canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 700, pointerType: 'touch', clientX: 186, clientY: 156 }), ); canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 700, pointerType: 'touch', button: 0, clientX: 186, clientY: 156, }), );
// Tick 3 update: must see the release edge AND a non-zero delta // representing the final movement (so a demo can throw with it). expect(input.isButtonReleased(BTN_POINTER_A, 1)).toBe(true);
const releasePos = input.getPos(1); const releaseDelta = input.getDelta(1);
// clientX 186 in a 640-wide rect at left=10 is (186-10)/640 = 0.275 -> 0.275 * 320 = 88. // clientX 178 -> (178-10)/640 = 0.2625 -> 0.2625 * 320 = 84. // So the final movement of the tick is from (84, 50) to (88, 53). expect(releasePos.x).toBe(88); expect(releaseDelta.x).toBeGreaterThan(0); expect(releaseDelta.y).toBeGreaterThan(0); });
it('preserves pos and delta on the mouse-release frame', () => { // Mirrors the touch-release flick test but uses mouse on slot 0. The // release-frame delta must reflect the final movement so a demo can // use it as throw velocity.
// Tick 1 inter-frame: mouse button pressed. canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 170, clientY: 140, }), ); input.endFrame();
// Tick 2 inter-frame: mouse moves a small step. canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 178, clientY: 148 }), ); input.endFrame();
// Tick 3 inter-frame: mouse moves further, then releases. canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 186, clientY: 156 }), ); canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 186, clientY: 156, }), );
// Tick 3 update: must see the release edge AND a non-zero delta // representing the final movement (so a demo can throw with it). expect(input.isButtonReleased(BTN_POINTER_A, 0)).toBe(true);
const releasePos = input.getPos(0); const releaseDelta = input.getDelta(0);
// clientX 186 in a 640-wide rect at left=10: (186-10)/640 * 320 = 88. // clientX 178 -> (178-10)/640 * 320 = 84. Final movement: (84,64) -> (88,68). expect(releasePos.x).toBe(88); expect(releaseDelta.x).toBeGreaterThan(0); expect(releaseDelta.y).toBeGreaterThan(0); });
it('reports zero delta on the first frame after touch activation', () => { // Without prevPos sync on activation, the very first frame's // delta would be (touchPos - 0) which feeds a phantom velocity // into demos that read pointerDelta on the press frame. canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 800, pointerType: 'touch', button: 0, clientX: 170, clientY: 140, }), );
const delta = input.getDelta(1);
expect(delta.x).toBe(0); expect(delta.y).toBe(0); });
it('reports zero delta on the first frame after mouse activation', () => { canvas.dispatchEvent( pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 170, clientY: 140 }), );
const delta = input.getDelta(0);
expect(delta.x).toBe(0); expect(delta.y).toBe(0); });
it('does not lose the press edge when down events arrive between ticks', () => { // Regression test for the original bug: with prev snapshotted at // start-of-tick, an event between tick N and tick N+1 would set // a=true, then beginFrame N+1 would copy prev=true, hiding the // press from update. With endFrame timing, prev = "a at last // endFrame" = false, so the transition is correctly observed. input.endFrame(); // tick 1 end, no events
canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 2, clientX: 50, clientY: 50, }), );
// Tick 2 update: must see the press edge. expect(input.isButtonPressed(BTN_POINTER_B, 0)).toBe(true); });
it('reports isButtonPressed for BTN_POINTER_C and BTN_POINTER_D on their press frames', () => { // Before any press: covers the short-circuit false path for C and D. expect(input.isButtonPressed(BTN_POINTER_C, 0)).toBe(false); expect(input.isButtonPressed(BTN_POINTER_D, 0)).toBe(false);
// Press C (DOM button 1 = middle). canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 1, clientX: 50, clientY: 50, }), );
expect(input.isButtonPressed(BTN_POINTER_C, 0)).toBe(true);
input.endFrame();
expect(input.isButtonPressed(BTN_POINTER_C, 0)).toBe(false);
// Press D (DOM button 3 = back). canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 3, clientX: 50, clientY: 50, }), );
expect(input.isButtonPressed(BTN_POINTER_D, 0)).toBe(true);
input.endFrame();
expect(input.isButtonPressed(BTN_POINTER_D, 0)).toBe(false); });
it('reports isButtonReleased for BTN_POINTER_B, BTN_POINTER_C, and BTN_POINTER_D on their release frames', () => { // Press B, C, D together. canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 2, clientX: 50, clientY: 50, }), ); canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 1, clientX: 50, clientY: 50, }), ); canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 3, clientX: 50, clientY: 50, }), ); input.endFrame();
// Release B; C and D still held. canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 2, clientX: 50, clientY: 50 }), );
expect(input.isButtonReleased(BTN_POINTER_B, 0)).toBe(true); // C still held: !s.c is false, covers the short-circuit false path for C. expect(input.isButtonReleased(BTN_POINTER_C, 0)).toBe(false); expect(input.isButtonReleased(BTN_POINTER_D, 0)).toBe(false);
input.endFrame();
// Release C. canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 1, clientX: 50, clientY: 50 }), );
expect(input.isButtonReleased(BTN_POINTER_C, 0)).toBe(true);
input.endFrame();
// Release D. canvas.dispatchEvent( pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 3, clientX: 50, clientY: 50 }), );
expect(input.isButtonReleased(BTN_POINTER_D, 0)).toBe(true); }); });
// #endregion
// #region Wheel
describe('wheel handling', () => { it('uses raw deltaY for deltaMode 0 (PIXEL)', () => { canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 5, deltaMode: 0, bubbles: true, cancelable: true }));
expect(input.getScrollDelta()).toBe(5); });
it('multiplies deltaY by 16 for deltaMode 1 (LINE)', () => { canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 5, deltaMode: 1, bubbles: true, cancelable: true }));
expect(input.getScrollDelta()).toBe(80); });
it('multiplies deltaY by window.innerHeight for deltaMode 2 (PAGE)', () => { canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 5, deltaMode: 2, bubbles: true, cancelable: true }));
expect(input.getScrollDelta()).toBe(5 * window.innerHeight); });
it('accumulates multiple wheel events within a frame', () => { canvas.dispatchEvent( new WheelEvent('wheel', { deltaY: 10, deltaMode: 0, bubbles: true, cancelable: true }), ); canvas.dispatchEvent( new WheelEvent('wheel', { deltaY: -3, deltaMode: 0, bubbles: true, cancelable: true }), );
expect(input.getScrollDelta()).toBe(7); });
it('calls preventDefault on wheel events', () => { const wheelEvent = new WheelEvent('wheel', { deltaY: 5, deltaMode: 0, bubbles: true, cancelable: true }); const preventDefault = vi.spyOn(wheelEvent, 'preventDefault');
canvas.dispatchEvent(wheelEvent);
expect(preventDefault).toHaveBeenCalled(); }); });
// #endregion
// #region Context Menu
describe('context menu suppression', () => { it('calls preventDefault on contextmenu events', () => { const event = new Event('contextmenu', { bubbles: true, cancelable: true }); const preventDefault = vi.spyOn(event, 'preventDefault');
canvas.dispatchEvent(event);
expect(preventDefault).toHaveBeenCalled(); }); });
// #endregion
// #region Safe Defaults
describe('out-of-range slot index', () => { it.each([-1, POINTER_SLOT_COUNT, 99, 1.5, Number.NaN])('returns safe defaults for slot %s', (slot) => { const pos = input.getPos(slot); const delta = input.getDelta(slot);
expect(pos.x).toBe(0); expect(pos.y).toBe(0); expect(delta.x).toBe(0); expect(delta.y).toBe(0); expect(input.isValid(slot)).toBe(false); expect(input.isButtonDown(BTN_POINTER_A, slot)).toBe(false); expect(input.isButtonPressed(BTN_POINTER_A, slot)).toBe(false); expect(input.isButtonReleased(BTN_POINTER_A, slot)).toBe(false); });
it('returns false for unknown button codes', () => { canvas.dispatchEvent( pointerEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50, }), );
expect(input.isButtonDown(99, 0)).toBe(false); expect(input.isButtonPressed(99, 0)).toBe(false); expect(input.isButtonReleased(99, 0)).toBe(false); }); });
// #endregion});