@flighthq/input 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1229 @@
1
+ import { connectSignal } from '@flighthq/signals';
2
+ import type { InputGamepadButtonData, InputPointerData } from '@flighthq/types';
3
+ import { GamepadAxisKind, GamepadButtonKind, KeyCode, KeyModifier } from '@flighthq/types';
4
+
5
+ import {
6
+ applyGamepadAxisDeadZone,
7
+ applyGamepadStickDeadZone,
8
+ attachGamepadInput,
9
+ attachKeyboardInput,
10
+ attachPointerInput,
11
+ attachRelativePointerInput,
12
+ attachTextInput,
13
+ attachWheelInput,
14
+ connectInputStateToInputManager,
15
+ createInputKeyRepeatTimer,
16
+ createInputManager,
17
+ createInputSignals,
18
+ createInputState,
19
+ detachGamepadInput,
20
+ detachKeyboardInput,
21
+ detachPointerInput,
22
+ detachRelativePointerInput,
23
+ detachTextInput,
24
+ detachWheelInput,
25
+ endInputStateFrame,
26
+ exitInputPointerLock,
27
+ getCoalescedInputPointerEvents,
28
+ getGamepadAxisName,
29
+ getGamepadButtonName,
30
+ getInputGamepadAxis,
31
+ getKeyCodeFromDomKeyboardEvent,
32
+ getKeyModifierFromDomKeyboardEvent,
33
+ getMouseWheelModeFromDomWheelEvent,
34
+ hasInputPointerLock,
35
+ isInputGamepadButtonDown,
36
+ isInputKeyDown,
37
+ isInputPointerButtonDown,
38
+ pollGamepadInput,
39
+ releaseInputPointerCapture,
40
+ requestInputPointerLock,
41
+ setInputPointerCapture,
42
+ wasInputGamepadButtonPressed,
43
+ wasInputGamepadButtonReleased,
44
+ wasInputKeyPressed,
45
+ wasInputKeyReleased,
46
+ } from './inputManager';
47
+
48
+ describe('applyGamepadAxisDeadZone', () => {
49
+ it('returns 0 when value is within the dead zone', () => {
50
+ expect(applyGamepadAxisDeadZone(0.1, 0.2)).toBe(0);
51
+ expect(applyGamepadAxisDeadZone(-0.1, 0.2)).toBe(0);
52
+ });
53
+
54
+ it('rescales positive values above the dead zone to (0, 1]', () => {
55
+ const result = applyGamepadAxisDeadZone(1.0, 0.2);
56
+ expect(result).toBeCloseTo(1.0);
57
+ });
58
+
59
+ it('rescales negative values below the dead zone to [-1, 0)', () => {
60
+ const result = applyGamepadAxisDeadZone(-1.0, 0.2);
61
+ expect(result).toBeCloseTo(-1.0);
62
+ });
63
+
64
+ it('returns the raw value when deadZone is 0', () => {
65
+ expect(applyGamepadAxisDeadZone(0.5, 0)).toBe(0.5);
66
+ });
67
+
68
+ it('is alias-safe (result is based on input, not out)', () => {
69
+ // pure function — no mutation concern, but verify correctness for a midpoint value
70
+ const mid = applyGamepadAxisDeadZone(0.6, 0.2);
71
+ expect(mid).toBeGreaterThan(0);
72
+ expect(mid).toBeLessThan(1);
73
+ });
74
+ });
75
+
76
+ describe('applyGamepadStickDeadZone', () => {
77
+ it('outputs (0, 0) when magnitude is within dead zone', () => {
78
+ const out = { x: 0, y: 0 };
79
+ applyGamepadStickDeadZone(out, 0.1, 0.1, 0.2);
80
+ expect(out.x).toBe(0);
81
+ expect(out.y).toBe(0);
82
+ });
83
+
84
+ it('preserves direction and rescales magnitude to 1 at full deflection', () => {
85
+ const out = { x: 0, y: 0 };
86
+ applyGamepadStickDeadZone(out, 1.0, 0.0, 0.2);
87
+ expect(out.x).toBeCloseTo(1.0);
88
+ expect(out.y).toBeCloseTo(0.0);
89
+ });
90
+
91
+ it('is alias-safe when out is the same object as input coords', () => {
92
+ const out = { x: 0.8, y: 0.0 };
93
+ applyGamepadStickDeadZone(out, out.x, out.y, 0.2);
94
+ expect(out.x).toBeGreaterThan(0);
95
+ expect(out.y).toBeCloseTo(0);
96
+ });
97
+
98
+ it('passes through when deadZone is 0', () => {
99
+ const out = { x: 0, y: 0 };
100
+ applyGamepadStickDeadZone(out, 0.3, 0.4, 0);
101
+ expect(out.x).toBe(0.3);
102
+ expect(out.y).toBe(0.4);
103
+ });
104
+ });
105
+
106
+ describe('attachGamepadInput', () => {
107
+ it('emits onGamepadConnect when a gamepad connects', () => {
108
+ const manager = createInputManager();
109
+ attachGamepadInput(manager, window);
110
+
111
+ let received: { gamepad: number; id: string } | null = null;
112
+ connectSignal(manager.onGamepadConnect, (data) => {
113
+ received = { gamepad: data.gamepad, id: data.id };
114
+ });
115
+
116
+ window.dispatchEvent(createGamepadEvent('gamepadconnected', 0, 'Xbox Controller'));
117
+ expect(received).toEqual({ gamepad: 0, id: 'Xbox Controller' });
118
+ });
119
+
120
+ it('emits onGamepadDisconnect when a gamepad disconnects', () => {
121
+ const manager = createInputManager();
122
+ attachGamepadInput(manager, window);
123
+
124
+ let received: { gamepad: number } | null = null;
125
+ connectSignal(manager.onGamepadDisconnect, (data) => {
126
+ received = { gamepad: data.gamepad };
127
+ });
128
+
129
+ window.dispatchEvent(createGamepadEvent('gamepaddisconnected', 1, 'Generic Gamepad'));
130
+ expect(received).toEqual({ gamepad: 1 });
131
+ });
132
+
133
+ it('respects the enabled flag', () => {
134
+ const manager = createInputManager();
135
+ attachGamepadInput(manager, window);
136
+
137
+ let fired = 0;
138
+ connectSignal(manager.onGamepadConnect, () => fired++);
139
+
140
+ manager.enabled = false;
141
+ window.dispatchEvent(createGamepadEvent('gamepadconnected', 0, 'Pad'));
142
+ expect(fired).toBe(0);
143
+ });
144
+ });
145
+
146
+ describe('attachKeyboardInput', () => {
147
+ it('emits keyboard signals from the configured keyboard target', () => {
148
+ const manager = createInputManager();
149
+ const target = document.createElement('input');
150
+ attachKeyboardInput(manager, target);
151
+
152
+ let received = 0;
153
+ connectSignal(manager.onKeyDown, (data) => {
154
+ received = data.keyCode;
155
+ });
156
+
157
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'A' }));
158
+ expect(received).toBe(KeyCode.A);
159
+ });
160
+
161
+ it('populates timeStamp on keyboard data', () => {
162
+ const manager = createInputManager();
163
+ const target = document.createElement('input');
164
+ attachKeyboardInput(manager, target);
165
+
166
+ let receivedTimeStamp = -1;
167
+ connectSignal(manager.onKeyDown, (data) => {
168
+ receivedTimeStamp = data.timeStamp;
169
+ });
170
+
171
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'A' }));
172
+ expect(receivedTimeStamp).toBeGreaterThanOrEqual(0);
173
+ });
174
+ });
175
+
176
+ describe('attachPointerInput', () => {
177
+ it('emits pointer signals from the element', () => {
178
+ const manager = createInputManager();
179
+ const element = document.createElement('div');
180
+ attachPointerInput(manager, element);
181
+
182
+ let receivedX = 0;
183
+ let receivedY = 0;
184
+ let receivedPointerId = 0;
185
+ connectSignal(manager.onPointerDown, (data) => {
186
+ receivedX = data.x;
187
+ receivedY = data.y;
188
+ receivedPointerId = data.pointerId;
189
+ });
190
+
191
+ element.dispatchEvent(createPointerEvent('pointerdown', { clientX: 20, clientY: 30, pointerId: 4 }));
192
+ expect(receivedX).toBe(20);
193
+ expect(receivedY).toBe(30);
194
+ expect(receivedPointerId).toBe(4);
195
+ });
196
+
197
+ it('populates pressure and tilt on pointer data', () => {
198
+ const manager = createInputManager();
199
+ const element = document.createElement('div');
200
+ attachPointerInput(manager, element);
201
+
202
+ let receivedPressure = -1;
203
+ let receivedTiltX = -1;
204
+ connectSignal(manager.onPointerDown, (data) => {
205
+ receivedPressure = data.pressure;
206
+ receivedTiltX = data.tiltX;
207
+ });
208
+
209
+ element.dispatchEvent(createPointerEvent('pointerdown', { pressure: 0.5, tiltX: 10 }));
210
+ expect(receivedPressure).toBe(0.5);
211
+ expect(receivedTiltX).toBe(10);
212
+ });
213
+
214
+ it('respects the enabled flag', () => {
215
+ const manager = createInputManager();
216
+ const element = document.createElement('div');
217
+ attachPointerInput(manager, element);
218
+
219
+ let fired = 0;
220
+ connectSignal(manager.onPointerDown, () => fired++);
221
+
222
+ manager.enabled = false;
223
+ element.dispatchEvent(createPointerEvent('pointerdown'));
224
+ expect(fired).toBe(0);
225
+ });
226
+ });
227
+
228
+ describe('attachRelativePointerInput', () => {
229
+ it('emits onPointerMoveRelative with movement deltas from document mousemove', () => {
230
+ const manager = createInputManager();
231
+ const element = document.createElement('div');
232
+ attachRelativePointerInput(manager, element);
233
+
234
+ let receivedDeltaX = 0;
235
+ let receivedDeltaY = 0;
236
+ connectSignal(manager.onPointerMoveRelative, (data) => {
237
+ receivedDeltaX = data.deltaX;
238
+ receivedDeltaY = data.deltaY;
239
+ });
240
+
241
+ element.ownerDocument.dispatchEvent(new MouseEvent('mousemove', { movementX: 5, movementY: -3 }));
242
+ expect(receivedDeltaX).toBe(5);
243
+ expect(receivedDeltaY).toBe(-3);
244
+ detachRelativePointerInput(manager, element);
245
+ });
246
+
247
+ it('populates the canonical pointer fields routed through the shared writer', () => {
248
+ const manager = createInputManager();
249
+ const element = document.createElement('div');
250
+ attachRelativePointerInput(manager, element);
251
+
252
+ let received: Readonly<InputPointerData> | null = null;
253
+ connectSignal(manager.onPointerMoveRelative, (data) => {
254
+ received = { ...data };
255
+ });
256
+
257
+ element.ownerDocument.dispatchEvent(
258
+ new MouseEvent('mousemove', { clientX: 7, clientY: 9, ctrlKey: true, movementX: 2, movementY: 4 }),
259
+ );
260
+ expect(received).not.toBeNull();
261
+ const data = received!;
262
+ expect(data.x).toBe(7);
263
+ expect(data.y).toBe(9);
264
+ expect(data.ctrlKey).toBe(true);
265
+ expect(data.pointerType).toBe('mouse');
266
+ expect(data.isPrimary).toBe(true);
267
+ expect(data.width).toBe(1);
268
+ expect(data.height).toBe(1);
269
+ expect(data.wheelMode).toBe('unknown');
270
+ detachRelativePointerInput(manager, element);
271
+ });
272
+
273
+ it('honors preventDefault from options', () => {
274
+ const manager = createInputManager();
275
+ const element = document.createElement('div');
276
+ attachRelativePointerInput(manager, element, { preventDefault: true });
277
+
278
+ const event = new MouseEvent('mousemove', { cancelable: true });
279
+ element.ownerDocument.dispatchEvent(event);
280
+ expect(event.defaultPrevented).toBe(true);
281
+ detachRelativePointerInput(manager, element);
282
+ });
283
+
284
+ it('leaves the event un-prevented when preventDefault is false', () => {
285
+ const manager = createInputManager();
286
+ const element = document.createElement('div');
287
+ attachRelativePointerInput(manager, element, { preventDefault: false });
288
+
289
+ const event = new MouseEvent('mousemove', { cancelable: true });
290
+ element.ownerDocument.dispatchEvent(event);
291
+ expect(event.defaultPrevented).toBe(false);
292
+ detachRelativePointerInput(manager, element);
293
+ });
294
+
295
+ it('respects the enabled flag', () => {
296
+ const manager = createInputManager();
297
+ const element = document.createElement('div');
298
+ attachRelativePointerInput(manager, element);
299
+
300
+ let fired = 0;
301
+ connectSignal(manager.onPointerMoveRelative, () => fired++);
302
+
303
+ manager.enabled = false;
304
+ element.ownerDocument.dispatchEvent(new MouseEvent('mousemove'));
305
+ expect(fired).toBe(0);
306
+ detachRelativePointerInput(manager, element);
307
+ });
308
+ });
309
+
310
+ describe('attachTextInput', () => {
311
+ it('emits text input from beforeinput with isComposing false', () => {
312
+ const manager = createInputManager();
313
+ const element = document.createElement('div');
314
+ attachTextInput(manager, element);
315
+
316
+ let received = '';
317
+ let receivedComposing = true;
318
+ connectSignal(manager.onTextInput, (data) => {
319
+ received = data.text;
320
+ receivedComposing = data.isComposing;
321
+ });
322
+
323
+ element.dispatchEvent(createInputEvent('beforeinput', 'x'));
324
+ expect(received).toBe('x');
325
+ expect(receivedComposing).toBe(false);
326
+ });
327
+
328
+ it('emits text edit from compositionupdate with isComposing true', () => {
329
+ const manager = createInputManager();
330
+ const element = document.createElement('div');
331
+ attachTextInput(manager, element);
332
+
333
+ let receivedComposing = false;
334
+ connectSignal(manager.onTextEdit, (data) => {
335
+ receivedComposing = data.isComposing;
336
+ });
337
+
338
+ element.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'hi' }));
339
+ expect(receivedComposing).toBe(true);
340
+ });
341
+ });
342
+
343
+ describe('attachWheelInput', () => {
344
+ it('emits wheel signals with deltas and wheel mode', () => {
345
+ const manager = createInputManager();
346
+ const element = document.createElement('div');
347
+ attachWheelInput(manager, element);
348
+
349
+ let receivedDeltaY = 0;
350
+ let receivedMode = '';
351
+ connectSignal(manager.onWheel, (data) => {
352
+ receivedDeltaY = data.deltaY;
353
+ receivedMode = data.wheelMode;
354
+ });
355
+
356
+ element.dispatchEvent(createWheelEvent({ deltaMode: WheelEvent.DOM_DELTA_LINE, deltaY: -3 }));
357
+ expect(receivedDeltaY).toBe(-3);
358
+ expect(receivedMode).toBe('lines');
359
+ });
360
+ });
361
+
362
+ describe('connectInputStateToInputManager', () => {
363
+ beforeEach(() => {
364
+ Object.defineProperty(navigator, 'getGamepads', {
365
+ configurable: true,
366
+ value: () => [],
367
+ });
368
+ });
369
+
370
+ it('tracks held keys via isInputKeyDown', () => {
371
+ const manager = createInputManager();
372
+ const target = document.createElement('input');
373
+ attachKeyboardInput(manager, target);
374
+ const state = createInputState();
375
+ connectInputStateToInputManager(state, manager);
376
+
377
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
378
+ expect(isInputKeyDown(state, KeyCode.A)).toBe(true);
379
+
380
+ target.dispatchEvent(createKeyboardEvent('keyup', { code: 'KeyA', key: 'a' }));
381
+ expect(isInputKeyDown(state, KeyCode.A)).toBe(false);
382
+ });
383
+
384
+ it('tracks held pointer buttons via isInputPointerButtonDown', () => {
385
+ const manager = createInputManager();
386
+ const element = document.createElement('div');
387
+ attachPointerInput(manager, element);
388
+ const state = createInputState();
389
+ connectInputStateToInputManager(state, manager);
390
+
391
+ element.dispatchEvent(createPointerEvent('pointerdown', { pointerId: 1, button: 0, buttons: 1 }));
392
+ expect(isInputPointerButtonDown(state, 1, 0)).toBe(true);
393
+
394
+ element.dispatchEvent(createPointerEvent('pointerup', { pointerId: 1, button: 0, buttons: 0 }));
395
+ expect(isInputPointerButtonDown(state, 1, 0)).toBe(false);
396
+ });
397
+
398
+ it('clears pointer state on pointercancel', () => {
399
+ const manager = createInputManager();
400
+ const element = document.createElement('div');
401
+ attachPointerInput(manager, element);
402
+ const state = createInputState();
403
+ connectInputStateToInputManager(state, manager);
404
+
405
+ element.dispatchEvent(createPointerEvent('pointerdown', { pointerId: 2, button: 0, buttons: 1 }));
406
+ expect(isInputPointerButtonDown(state, 2, 0)).toBe(true);
407
+
408
+ element.dispatchEvent(createPointerEvent('pointercancel', { pointerId: 2, button: 0, buttons: 0 }));
409
+ expect(isInputPointerButtonDown(state, 2, 0)).toBe(false);
410
+ });
411
+
412
+ it('tracks gamepad button state via isInputGamepadButtonDown', () => {
413
+ const manager = createInputManager();
414
+ const state = createInputState();
415
+ connectInputStateToInputManager(state, manager);
416
+
417
+ const mockPad = {
418
+ axes: [],
419
+ buttons: [{ pressed: true, value: 1, touched: true }],
420
+ index: 0,
421
+ } as unknown as Gamepad;
422
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
423
+
424
+ pollGamepadInput(manager);
425
+ expect(isInputGamepadButtonDown(state, 0, 0)).toBe(true);
426
+
427
+ const mockPadReleased = {
428
+ axes: [],
429
+ buttons: [{ pressed: false, value: 0, touched: false }],
430
+ index: 0,
431
+ } as unknown as Gamepad;
432
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPadReleased, null, null, null]);
433
+
434
+ pollGamepadInput(manager);
435
+ expect(isInputGamepadButtonDown(state, 0, 0)).toBe(false);
436
+ });
437
+
438
+ it('tracks gamepad axis values via getInputGamepadAxis', () => {
439
+ const manager = createInputManager();
440
+ const state = createInputState();
441
+ connectInputStateToInputManager(state, manager);
442
+
443
+ const mockPad = {
444
+ axes: [0.75],
445
+ buttons: [],
446
+ index: 0,
447
+ } as unknown as Gamepad;
448
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
449
+
450
+ pollGamepadInput(manager);
451
+ expect(getInputGamepadAxis(state, 0, 0)).toBe(0.75);
452
+ });
453
+
454
+ it('returns a disposer that stops tracking', () => {
455
+ const manager = createInputManager();
456
+ const target = document.createElement('input');
457
+ attachKeyboardInput(manager, target);
458
+ const state = createInputState();
459
+ const dispose = connectInputStateToInputManager(state, manager);
460
+
461
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
462
+ expect(isInputKeyDown(state, KeyCode.A)).toBe(true);
463
+
464
+ target.dispatchEvent(createKeyboardEvent('keyup', { code: 'KeyA', key: 'a' }));
465
+ dispose();
466
+
467
+ // After dispose, subsequent events should not update state.
468
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
469
+ expect(isInputKeyDown(state, KeyCode.A)).toBe(false);
470
+ });
471
+ });
472
+
473
+ describe('createInputKeyRepeatTimer', () => {
474
+ it('invokes callback immediately on start', () => {
475
+ vi.useFakeTimers();
476
+ const timer = createInputKeyRepeatTimer({ delay: 500, interval: 33 });
477
+ let count = 0;
478
+ timer.start(() => count++);
479
+ expect(count).toBe(1);
480
+ vi.useRealTimers();
481
+ });
482
+
483
+ it('fires repeat after delay and interval', () => {
484
+ vi.useFakeTimers();
485
+ const timer = createInputKeyRepeatTimer({ delay: 500, interval: 100 });
486
+ let count = 0;
487
+ timer.start(() => count++);
488
+ expect(count).toBe(1); // immediate
489
+ vi.advanceTimersByTime(500);
490
+ expect(count).toBe(2); // after delay
491
+ vi.advanceTimersByTime(100);
492
+ expect(count).toBe(3); // after first interval
493
+ vi.advanceTimersByTime(100);
494
+ expect(count).toBe(4); // after second interval
495
+ timer.stop();
496
+ vi.useRealTimers();
497
+ });
498
+
499
+ it('stops repeating after stop()', () => {
500
+ vi.useFakeTimers();
501
+ const timer = createInputKeyRepeatTimer({ delay: 500, interval: 100 });
502
+ let count = 0;
503
+ timer.start(() => count++);
504
+ vi.advanceTimersByTime(500);
505
+ timer.stop();
506
+ const countAfterStop = count;
507
+ vi.advanceTimersByTime(500);
508
+ expect(count).toBe(countAfterStop);
509
+ vi.useRealTimers();
510
+ });
511
+
512
+ it('can be restarted after stop', () => {
513
+ vi.useFakeTimers();
514
+ const timer = createInputKeyRepeatTimer({ delay: 500, interval: 100 });
515
+ let count = 0;
516
+ timer.start(() => count++);
517
+ timer.stop();
518
+ timer.start(() => count++);
519
+ expect(count).toBe(2); // two immediate fires
520
+ vi.useRealTimers();
521
+ });
522
+ });
523
+
524
+ describe('createInputManager', () => {
525
+ it('creates an enabled manager by default', () => {
526
+ const manager = createInputManager();
527
+ expect(manager.enabled).toBe(true);
528
+ expect(manager.onPointerDown).toBeDefined();
529
+ });
530
+
531
+ it('can create a disabled manager', () => {
532
+ const manager = createInputManager();
533
+ manager.enabled = false;
534
+ expect(manager.enabled).toBe(false);
535
+ });
536
+ });
537
+
538
+ describe('createInputSignals', () => {
539
+ it('returns all input signals', () => {
540
+ const signals = createInputSignals();
541
+ expect(signals.onGamepadAxisMove).toBeDefined();
542
+ expect(signals.onGamepadButtonDown).toBeDefined();
543
+ expect(signals.onGamepadButtonUp).toBeDefined();
544
+ expect(signals.onGamepadConnect).toBeDefined();
545
+ expect(signals.onGamepadDisconnect).toBeDefined();
546
+ expect(signals.onKeyDown).toBeDefined();
547
+ expect(signals.onKeyUp).toBeDefined();
548
+ expect(signals.onPointerCancel).toBeDefined();
549
+ expect(signals.onPointerDown).toBeDefined();
550
+ expect(signals.onPointerMove).toBeDefined();
551
+ expect(signals.onPointerMoveRelative).toBeDefined();
552
+ expect(signals.onPointerUp).toBeDefined();
553
+ expect(signals.onTextEdit).toBeDefined();
554
+ expect(signals.onTextInput).toBeDefined();
555
+ expect(signals.onWheel).toBeDefined();
556
+ });
557
+
558
+ it('returns a new object each call', () => {
559
+ expect(createInputSignals()).not.toBe(createInputSignals());
560
+ });
561
+ });
562
+
563
+ describe('createInputState', () => {
564
+ it('creates state with empty collections including frame-edge sets', () => {
565
+ const state = createInputState();
566
+ expect(state.keysDown.size).toBe(0);
567
+ expect(state.pointerButtonsDown.size).toBe(0);
568
+ expect(state.gamepadButtonsDown.size).toBe(0);
569
+ expect(state.axisValues.size).toBe(0);
570
+ expect(state.justPressedKeys.size).toBe(0);
571
+ expect(state.justReleasedKeys.size).toBe(0);
572
+ expect(state.justPressedGamepadButtons.size).toBe(0);
573
+ expect(state.justReleasedGamepadButtons.size).toBe(0);
574
+ });
575
+ });
576
+
577
+ describe('detachGamepadInput', () => {
578
+ it('removes listeners so signals stop firing', () => {
579
+ const manager = createInputManager();
580
+ attachGamepadInput(manager, window);
581
+
582
+ let fired = 0;
583
+ connectSignal(manager.onGamepadConnect, () => fired++);
584
+
585
+ detachGamepadInput(manager, window);
586
+ window.dispatchEvent(createGamepadEvent('gamepadconnected', 0, 'Pad'));
587
+ expect(fired).toBe(0);
588
+ });
589
+
590
+ it('is a no-op when nothing is attached', () => {
591
+ const manager = createInputManager();
592
+ expect(() => detachGamepadInput(manager, window)).not.toThrow();
593
+ });
594
+ });
595
+
596
+ describe('detachKeyboardInput', () => {
597
+ it('removes listeners so signals stop firing', () => {
598
+ const manager = createInputManager();
599
+ const target = document.createElement('input');
600
+ attachKeyboardInput(manager, target);
601
+
602
+ let fired = 0;
603
+ connectSignal(manager.onKeyDown, () => fired++);
604
+
605
+ detachKeyboardInput(manager, target);
606
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'A' }));
607
+ expect(fired).toBe(0);
608
+ });
609
+ });
610
+
611
+ describe('detachPointerInput', () => {
612
+ it('removes listeners so signals stop firing', () => {
613
+ const manager = createInputManager();
614
+ const element = document.createElement('div');
615
+ attachPointerInput(manager, element);
616
+
617
+ let fired = 0;
618
+ connectSignal(manager.onPointerDown, () => fired++);
619
+
620
+ detachPointerInput(manager, element);
621
+ element.dispatchEvent(createPointerEvent('pointerdown'));
622
+ expect(fired).toBe(0);
623
+ });
624
+
625
+ it('detaches one target without affecting another bound to the same manager', () => {
626
+ const manager = createInputManager();
627
+ const first = document.createElement('div');
628
+ const second = document.createElement('div');
629
+ attachPointerInput(manager, first);
630
+ attachPointerInput(manager, second);
631
+
632
+ let fired = 0;
633
+ connectSignal(manager.onPointerDown, () => fired++);
634
+
635
+ detachPointerInput(manager, first);
636
+ first.dispatchEvent(createPointerEvent('pointerdown'));
637
+ expect(fired).toBe(0);
638
+ second.dispatchEvent(createPointerEvent('pointerdown'));
639
+ expect(fired).toBe(1);
640
+ });
641
+ });
642
+
643
+ describe('detachRelativePointerInput', () => {
644
+ it('removes the listener so signals stop firing', () => {
645
+ const manager = createInputManager();
646
+ const element = document.createElement('div');
647
+ attachRelativePointerInput(manager, element);
648
+
649
+ let fired = 0;
650
+ connectSignal(manager.onPointerMoveRelative, () => fired++);
651
+
652
+ detachRelativePointerInput(manager, element);
653
+ element.ownerDocument.dispatchEvent(new MouseEvent('mousemove'));
654
+ expect(fired).toBe(0);
655
+ });
656
+ });
657
+
658
+ describe('detachTextInput', () => {
659
+ it('removes listeners so signals stop firing', () => {
660
+ const manager = createInputManager();
661
+ const element = document.createElement('div');
662
+ attachTextInput(manager, element);
663
+
664
+ let fired = 0;
665
+ connectSignal(manager.onTextInput, () => fired++);
666
+
667
+ detachTextInput(manager, element);
668
+ element.dispatchEvent(createInputEvent('beforeinput', 'x'));
669
+ expect(fired).toBe(0);
670
+ });
671
+ });
672
+
673
+ describe('detachWheelInput', () => {
674
+ it('removes listeners so signals stop firing', () => {
675
+ const manager = createInputManager();
676
+ const element = document.createElement('div');
677
+ attachWheelInput(manager, element);
678
+
679
+ let fired = 0;
680
+ connectSignal(manager.onWheel, () => fired++);
681
+
682
+ detachWheelInput(manager, element);
683
+ element.dispatchEvent(createWheelEvent({ deltaMode: WheelEvent.DOM_DELTA_LINE, deltaY: -3 }));
684
+ expect(fired).toBe(0);
685
+ });
686
+ });
687
+
688
+ describe('endInputStateFrame', () => {
689
+ it('clears all frame-edge sets', () => {
690
+ const manager = createInputManager();
691
+ const target = document.createElement('input');
692
+ attachKeyboardInput(manager, target);
693
+ const state = createInputState();
694
+ connectInputStateToInputManager(state, manager);
695
+
696
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
697
+ expect(state.justPressedKeys.size).toBe(1);
698
+
699
+ endInputStateFrame(state);
700
+ expect(state.justPressedKeys.size).toBe(0);
701
+ expect(state.justReleasedKeys.size).toBe(0);
702
+ expect(state.justPressedGamepadButtons.size).toBe(0);
703
+ expect(state.justReleasedGamepadButtons.size).toBe(0);
704
+ });
705
+
706
+ it('does not affect held-state sets', () => {
707
+ const manager = createInputManager();
708
+ const target = document.createElement('input');
709
+ attachKeyboardInput(manager, target);
710
+ const state = createInputState();
711
+ connectInputStateToInputManager(state, manager);
712
+
713
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
714
+ endInputStateFrame(state);
715
+ // Key is still held even after frame roll
716
+ expect(state.keysDown.has(KeyCode.A)).toBe(true);
717
+ });
718
+ });
719
+
720
+ describe('exitInputPointerLock', () => {
721
+ it('calls document.exitPointerLock when available', () => {
722
+ let called = false;
723
+ Object.defineProperty(document, 'exitPointerLock', {
724
+ configurable: true,
725
+ value: () => {
726
+ called = true;
727
+ },
728
+ });
729
+ exitInputPointerLock();
730
+ expect(called).toBe(true);
731
+ });
732
+ });
733
+
734
+ describe('getCoalescedInputPointerEvents', () => {
735
+ it('falls back to a single event when getCoalescedEvents is unavailable', () => {
736
+ const event = createPointerEvent('pointermove', { clientX: 10, clientY: 20 });
737
+ const received: number[] = [];
738
+ getCoalescedInputPointerEvents(event, (data) => {
739
+ received.push(data.x);
740
+ });
741
+ expect(received).toEqual([10]);
742
+ });
743
+
744
+ it('iterates coalesced events when available', () => {
745
+ const coalesced = [
746
+ createPointerEvent('pointermove', { clientX: 1, clientY: 0 }),
747
+ createPointerEvent('pointermove', { clientX: 2, clientY: 0 }),
748
+ ];
749
+ const event = createPointerEvent('pointermove', { clientX: 3, clientY: 0 });
750
+ Object.defineProperty(event, 'getCoalescedEvents', {
751
+ value: () => coalesced,
752
+ });
753
+ const received: number[] = [];
754
+ getCoalescedInputPointerEvents(event, (data) => {
755
+ received.push(data.x);
756
+ });
757
+ expect(received).toEqual([1, 2]);
758
+ });
759
+ });
760
+
761
+ describe('getGamepadAxisName', () => {
762
+ it('returns the semantic axis name for a standard mapping', () => {
763
+ expect(getGamepadAxisName('standard', 0)).toBe(GamepadAxisKind.STICK_LEFT_X);
764
+ expect(getGamepadAxisName('standard', 1)).toBe(GamepadAxisKind.STICK_LEFT_Y);
765
+ expect(getGamepadAxisName('standard', 2)).toBe(GamepadAxisKind.STICK_RIGHT_X);
766
+ expect(getGamepadAxisName('standard', 3)).toBe(GamepadAxisKind.STICK_RIGHT_Y);
767
+ });
768
+
769
+ it('returns null for non-standard mapping', () => {
770
+ expect(getGamepadAxisName('raw', 0)).toBeNull();
771
+ expect(getGamepadAxisName('', 0)).toBeNull();
772
+ });
773
+
774
+ it('returns null for an out-of-range index', () => {
775
+ expect(getGamepadAxisName('standard', 99)).toBeNull();
776
+ });
777
+ });
778
+
779
+ describe('getGamepadButtonName', () => {
780
+ it('returns the semantic button name for a standard mapping', () => {
781
+ expect(getGamepadButtonName('standard', 0)).toBe(GamepadButtonKind.BUTTON_SOUTH);
782
+ expect(getGamepadButtonName('standard', 12)).toBe(GamepadButtonKind.DPAD_UP);
783
+ expect(getGamepadButtonName('standard', 16)).toBe(GamepadButtonKind.HOME);
784
+ });
785
+
786
+ it('returns null for non-standard mapping', () => {
787
+ expect(getGamepadButtonName('raw', 0)).toBeNull();
788
+ expect(getGamepadButtonName('', 0)).toBeNull();
789
+ });
790
+
791
+ it('returns null for an out-of-range index', () => {
792
+ expect(getGamepadButtonName('standard', 99)).toBeNull();
793
+ });
794
+ });
795
+
796
+ describe('getInputGamepadAxis', () => {
797
+ it('returns 0 for an unknown gamepad/axis combination', () => {
798
+ const state = createInputState();
799
+ expect(getInputGamepadAxis(state, 0, 0)).toBe(0);
800
+ });
801
+ });
802
+
803
+ describe('getKeyCodeFromDomKeyboardEvent', () => {
804
+ it('maps printable keys to SDL-compatible lower-case codes', () => {
805
+ expect(getKeyCodeFromDomKeyboardEvent(createKeyboardEvent('keydown', { key: 'A' }))).toBe(KeyCode.A);
806
+ });
807
+
808
+ it('maps named keys', () => {
809
+ expect(
810
+ getKeyCodeFromDomKeyboardEvent(createKeyboardEvent('keydown', { code: 'ArrowLeft', key: 'ArrowLeft' })),
811
+ ).toBe(KeyCode.LEFT);
812
+ });
813
+
814
+ it('maps numpad keys by location', () => {
815
+ expect(
816
+ getKeyCodeFromDomKeyboardEvent(
817
+ createKeyboardEvent('keydown', {
818
+ code: 'Numpad1',
819
+ key: '1',
820
+ location: KeyboardEvent.DOM_KEY_LOCATION_NUMPAD,
821
+ }),
822
+ ),
823
+ ).toBe(KeyCode.NUMPAD_1);
824
+ });
825
+
826
+ it.each([
827
+ ['Again', KeyCode.AGAIN],
828
+ ['Copy', KeyCode.COPY],
829
+ ['Cut', KeyCode.CUT],
830
+ ['Undo', KeyCode.UNDO],
831
+ ])('maps editing code %s', (code, expected) => {
832
+ expect(getKeyCodeFromDomKeyboardEvent(createKeyboardEvent('keydown', { code, key: '' }))).toBe(expected);
833
+ });
834
+
835
+ it.each([
836
+ ['NumpadBackspace', KeyCode.NUMPAD_BACKSPACE],
837
+ ['NumpadClear', KeyCode.NUMPAD_CLEAR],
838
+ ['NumpadClearEntry', KeyCode.NUMPAD_CLEAR_ENTRY],
839
+ ['NumpadComma', KeyCode.NUMPAD_COMMA],
840
+ ['NumpadHash', KeyCode.NUMPAD_HASH],
841
+ ['NumpadMemoryAdd', KeyCode.NUMPAD_MEM_ADD],
842
+ ['NumpadMemoryClear', KeyCode.NUMPAD_MEM_CLEAR],
843
+ ['NumpadMemoryRecall', KeyCode.NUMPAD_MEM_RECALL],
844
+ ['NumpadMemoryStore', KeyCode.NUMPAD_MEM_STORE],
845
+ ['NumpadMemorySubtract', KeyCode.NUMPAD_MEM_SUBTRACT],
846
+ ['NumpadParenLeft', KeyCode.NUMPAD_LEFT_PARENTHESIS],
847
+ ['NumpadParenRight', KeyCode.NUMPAD_RIGHT_PARENTHESIS],
848
+ ])('maps numpad code %s by location', (code, expected) => {
849
+ expect(
850
+ getKeyCodeFromDomKeyboardEvent(
851
+ createKeyboardEvent('keydown', { code, key: '', location: KeyboardEvent.DOM_KEY_LOCATION_NUMPAD }),
852
+ ),
853
+ ).toBe(expected);
854
+ });
855
+ });
856
+
857
+ describe('getKeyModifierFromDomKeyboardEvent', () => {
858
+ it('maps DOM modifier flags to Lime-compatible bit flags', () => {
859
+ const modifier = getKeyModifierFromDomKeyboardEvent(
860
+ createKeyboardEvent('keydown', { ctrlKey: true, shiftKey: true }),
861
+ );
862
+ expect((modifier & KeyModifier.CTRL) !== 0).toBe(true);
863
+ expect((modifier & KeyModifier.SHIFT) !== 0).toBe(true);
864
+ });
865
+ });
866
+
867
+ describe('getMouseWheelModeFromDomWheelEvent', () => {
868
+ it('maps DOM wheel delta modes', () => {
869
+ expect(getMouseWheelModeFromDomWheelEvent(createWheelEvent({ deltaMode: WheelEvent.DOM_DELTA_PIXEL }))).toBe(
870
+ 'pixels',
871
+ );
872
+ expect(getMouseWheelModeFromDomWheelEvent(createWheelEvent({ deltaMode: WheelEvent.DOM_DELTA_PAGE }))).toBe(
873
+ 'pages',
874
+ );
875
+ });
876
+ });
877
+
878
+ describe('hasInputPointerLock', () => {
879
+ it('returns false when no element is pointer-locked', () => {
880
+ Object.defineProperty(document, 'pointerLockElement', {
881
+ configurable: true,
882
+ get: () => null,
883
+ });
884
+ expect(hasInputPointerLock()).toBe(false);
885
+ });
886
+
887
+ it('returns true when an element holds the pointer lock', () => {
888
+ const element = document.createElement('div');
889
+ Object.defineProperty(document, 'pointerLockElement', {
890
+ configurable: true,
891
+ get: () => element,
892
+ });
893
+ expect(hasInputPointerLock()).toBe(true);
894
+ // Restore
895
+ Object.defineProperty(document, 'pointerLockElement', {
896
+ configurable: true,
897
+ get: () => null,
898
+ });
899
+ });
900
+ });
901
+
902
+ describe('isInputGamepadButtonDown', () => {
903
+ it('returns false for an unknown gamepad/button combination', () => {
904
+ const state = createInputState();
905
+ expect(isInputGamepadButtonDown(state, 0, 0)).toBe(false);
906
+ });
907
+ });
908
+
909
+ describe('isInputKeyDown', () => {
910
+ it('returns false when no keys are held', () => {
911
+ const state = createInputState();
912
+ expect(isInputKeyDown(state, KeyCode.A)).toBe(false);
913
+ });
914
+ });
915
+
916
+ describe('isInputPointerButtonDown', () => {
917
+ it('returns false when no buttons are held', () => {
918
+ const state = createInputState();
919
+ expect(isInputPointerButtonDown(state, 0, 0)).toBe(false);
920
+ });
921
+ });
922
+
923
+ describe('pollGamepadInput', () => {
924
+ beforeEach(() => {
925
+ Object.defineProperty(navigator, 'getGamepads', {
926
+ configurable: true,
927
+ value: () => [],
928
+ });
929
+ });
930
+
931
+ it('emits onGamepadButtonDown when a button transitions to pressed', () => {
932
+ const manager = createInputManager();
933
+ const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
934
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
935
+
936
+ let received: { button: number; gamepad: number } | null = null;
937
+ connectSignal(manager.onGamepadButtonDown, (data: Readonly<InputGamepadButtonData>) => {
938
+ received = { button: data.button, gamepad: data.gamepad };
939
+ });
940
+
941
+ pollGamepadInput(manager);
942
+ expect(received).toEqual({ button: 0, gamepad: 0 });
943
+ });
944
+
945
+ it('populates timeStamp on gamepad button data', () => {
946
+ const manager = createInputManager();
947
+ const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
948
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
949
+
950
+ let receivedTimeStamp = -1;
951
+ connectSignal(manager.onGamepadButtonDown, (data: Readonly<InputGamepadButtonData>) => {
952
+ receivedTimeStamp = data.timeStamp;
953
+ });
954
+
955
+ pollGamepadInput(manager);
956
+ expect(receivedTimeStamp).toBeGreaterThanOrEqual(0);
957
+ });
958
+
959
+ it('does not emit when state is unchanged', () => {
960
+ const manager = createInputManager();
961
+ const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
962
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
963
+
964
+ pollGamepadInput(manager);
965
+
966
+ let fired = 0;
967
+ connectSignal(manager.onGamepadButtonDown, () => fired++);
968
+ pollGamepadInput(manager);
969
+ expect(fired).toBe(0);
970
+ });
971
+ });
972
+
973
+ describe('releaseInputPointerCapture', () => {
974
+ it('calls releasePointerCapture on the element', () => {
975
+ const element = document.createElement('div');
976
+ let capturedId = -1;
977
+ element.releasePointerCapture = (id) => {
978
+ capturedId = id;
979
+ };
980
+ releaseInputPointerCapture(element, 5);
981
+ expect(capturedId).toBe(5);
982
+ });
983
+
984
+ it('does not throw when the pointer was already released', () => {
985
+ const element = document.createElement('div');
986
+ element.releasePointerCapture = () => {
987
+ throw new DOMException('No pointer');
988
+ };
989
+ expect(() => releaseInputPointerCapture(element, 0)).not.toThrow();
990
+ });
991
+ });
992
+
993
+ describe('requestInputPointerLock', () => {
994
+ it('resolves to true when requestPointerLock succeeds synchronously', async () => {
995
+ const element = document.createElement('div');
996
+ // Cast: synchronous void return is valid per the spec (older browsers), but the
997
+ // TypeScript lib types it as Promise<void>; the double cast handles the overlap check.
998
+ element.requestPointerLock = (() => undefined) as unknown as () => Promise<void>;
999
+ const result = await requestInputPointerLock(element);
1000
+ expect(result).toBe(true);
1001
+ });
1002
+
1003
+ it('resolves to true when requestPointerLock returns a resolving Promise', async () => {
1004
+ const element = document.createElement('div');
1005
+ element.requestPointerLock = () => Promise.resolve();
1006
+ const result = await requestInputPointerLock(element);
1007
+ expect(result).toBe(true);
1008
+ });
1009
+
1010
+ it('resolves to false when requestPointerLock throws', async () => {
1011
+ const element = document.createElement('div');
1012
+ element.requestPointerLock = () => {
1013
+ throw new Error('Not allowed');
1014
+ };
1015
+ const result = await requestInputPointerLock(element);
1016
+ expect(result).toBe(false);
1017
+ });
1018
+ });
1019
+
1020
+ describe('setInputPointerCapture', () => {
1021
+ it('calls setPointerCapture on the element', () => {
1022
+ const element = document.createElement('div');
1023
+ let capturedId = -1;
1024
+ element.setPointerCapture = (id) => {
1025
+ capturedId = id;
1026
+ };
1027
+ setInputPointerCapture(element, 7);
1028
+ expect(capturedId).toBe(7);
1029
+ });
1030
+ });
1031
+
1032
+ describe('wasInputGamepadButtonPressed', () => {
1033
+ beforeEach(() => {
1034
+ Object.defineProperty(navigator, 'getGamepads', {
1035
+ configurable: true,
1036
+ value: () => [],
1037
+ });
1038
+ });
1039
+
1040
+ it('returns true when a button was pressed this frame', () => {
1041
+ const manager = createInputManager();
1042
+ const state = createInputState();
1043
+ connectInputStateToInputManager(state, manager);
1044
+
1045
+ const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
1046
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
1047
+ pollGamepadInput(manager);
1048
+
1049
+ expect(wasInputGamepadButtonPressed(state, 0, 0)).toBe(true);
1050
+ });
1051
+
1052
+ it('returns false after endInputStateFrame', () => {
1053
+ const manager = createInputManager();
1054
+ const state = createInputState();
1055
+ connectInputStateToInputManager(state, manager);
1056
+
1057
+ const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
1058
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
1059
+ pollGamepadInput(manager);
1060
+ endInputStateFrame(state);
1061
+
1062
+ expect(wasInputGamepadButtonPressed(state, 0, 0)).toBe(false);
1063
+ });
1064
+ });
1065
+
1066
+ describe('wasInputGamepadButtonReleased', () => {
1067
+ beforeEach(() => {
1068
+ Object.defineProperty(navigator, 'getGamepads', {
1069
+ configurable: true,
1070
+ value: () => [],
1071
+ });
1072
+ });
1073
+
1074
+ it('returns true when a button was released this frame', () => {
1075
+ const manager = createInputManager();
1076
+ const state = createInputState();
1077
+ connectInputStateToInputManager(state, manager);
1078
+
1079
+ // Press the button first
1080
+ const mockPadDown = {
1081
+ axes: [],
1082
+ buttons: [{ pressed: true, touched: true, value: 1 }],
1083
+ index: 0,
1084
+ } as unknown as Gamepad;
1085
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPadDown, null, null, null]);
1086
+ pollGamepadInput(manager);
1087
+ endInputStateFrame(state);
1088
+
1089
+ // Release it
1090
+ const mockPadUp = {
1091
+ axes: [],
1092
+ buttons: [{ pressed: false, touched: false, value: 0 }],
1093
+ index: 0,
1094
+ } as unknown as Gamepad;
1095
+ vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPadUp, null, null, null]);
1096
+ pollGamepadInput(manager);
1097
+
1098
+ expect(wasInputGamepadButtonReleased(state, 0, 0)).toBe(true);
1099
+ });
1100
+ });
1101
+
1102
+ describe('wasInputKeyPressed', () => {
1103
+ it('returns true when a key was pressed this frame', () => {
1104
+ const manager = createInputManager();
1105
+ const target = document.createElement('input');
1106
+ attachKeyboardInput(manager, target);
1107
+ const state = createInputState();
1108
+ connectInputStateToInputManager(state, manager);
1109
+
1110
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
1111
+ expect(wasInputKeyPressed(state, KeyCode.A)).toBe(true);
1112
+ });
1113
+
1114
+ it('returns false when key was not pressed this frame', () => {
1115
+ const state = createInputState();
1116
+ expect(wasInputKeyPressed(state, KeyCode.A)).toBe(false);
1117
+ });
1118
+
1119
+ it('returns false after endInputStateFrame', () => {
1120
+ const manager = createInputManager();
1121
+ const target = document.createElement('input');
1122
+ attachKeyboardInput(manager, target);
1123
+ const state = createInputState();
1124
+ connectInputStateToInputManager(state, manager);
1125
+
1126
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
1127
+ endInputStateFrame(state);
1128
+ expect(wasInputKeyPressed(state, KeyCode.A)).toBe(false);
1129
+ });
1130
+ });
1131
+
1132
+ describe('wasInputKeyReleased', () => {
1133
+ it('returns true when a key was released this frame', () => {
1134
+ const manager = createInputManager();
1135
+ const target = document.createElement('input');
1136
+ attachKeyboardInput(manager, target);
1137
+ const state = createInputState();
1138
+ connectInputStateToInputManager(state, manager);
1139
+
1140
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
1141
+ endInputStateFrame(state);
1142
+ target.dispatchEvent(createKeyboardEvent('keyup', { code: 'KeyA', key: 'a' }));
1143
+ expect(wasInputKeyReleased(state, KeyCode.A)).toBe(true);
1144
+ });
1145
+
1146
+ it('returns false when key was not released this frame', () => {
1147
+ const state = createInputState();
1148
+ expect(wasInputKeyReleased(state, KeyCode.A)).toBe(false);
1149
+ });
1150
+
1151
+ it('returns false after endInputStateFrame', () => {
1152
+ const manager = createInputManager();
1153
+ const target = document.createElement('input');
1154
+ attachKeyboardInput(manager, target);
1155
+ const state = createInputState();
1156
+ connectInputStateToInputManager(state, manager);
1157
+
1158
+ target.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
1159
+ target.dispatchEvent(createKeyboardEvent('keyup', { code: 'KeyA', key: 'a' }));
1160
+ endInputStateFrame(state);
1161
+ expect(wasInputKeyReleased(state, KeyCode.A)).toBe(false);
1162
+ });
1163
+ });
1164
+
1165
+ function createInputEvent(type: string, data: string): InputEvent {
1166
+ return new InputEvent(type, { bubbles: true, data });
1167
+ }
1168
+
1169
+ function createKeyboardEvent(type: string, options: KeyboardEventInit = {}): KeyboardEvent {
1170
+ return new KeyboardEvent(type, {
1171
+ bubbles: true,
1172
+ cancelable: true,
1173
+ ...options,
1174
+ });
1175
+ }
1176
+
1177
+ function createPointerEvent(
1178
+ type: string,
1179
+ options: Partial<PointerEvent> & { pressure?: number; tiltX?: number; tiltY?: number } = {},
1180
+ ): PointerEvent {
1181
+ const event = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent;
1182
+ Object.defineProperties(event, {
1183
+ altKey: { value: options.altKey ?? false },
1184
+ button: { value: options.button ?? 0 },
1185
+ buttons: { value: options.buttons ?? 1 },
1186
+ clientX: { value: options.clientX ?? 0 },
1187
+ clientY: { value: options.clientY ?? 0 },
1188
+ ctrlKey: { value: options.ctrlKey ?? false },
1189
+ height: { value: 1 },
1190
+ isPrimary: { value: options.isPrimary ?? true },
1191
+ metaKey: { value: options.metaKey ?? false },
1192
+ pointerId: { value: options.pointerId ?? 0 },
1193
+ pointerType: { value: options.pointerType ?? 'mouse' },
1194
+ pressure: { value: options.pressure ?? 0 },
1195
+ shiftKey: { value: options.shiftKey ?? false },
1196
+ tiltX: { value: options.tiltX ?? 0 },
1197
+ tiltY: { value: options.tiltY ?? 0 },
1198
+ twist: { value: 0 },
1199
+ width: { value: 1 },
1200
+ });
1201
+ return event;
1202
+ }
1203
+
1204
+ function createWheelEvent(options: WheelEventInit = {}): WheelEvent {
1205
+ return new WheelEvent('wheel', {
1206
+ bubbles: true,
1207
+ cancelable: true,
1208
+ clientX: 0,
1209
+ clientY: 0,
1210
+ deltaX: 0,
1211
+ deltaY: 0,
1212
+ ...options,
1213
+ });
1214
+ }
1215
+
1216
+ function createGamepadEvent(type: string, index: number, id: string): Event {
1217
+ const event = new Event(type, { bubbles: false }) as GamepadEvent;
1218
+ const gamepad = {
1219
+ axes: [],
1220
+ buttons: [],
1221
+ connected: true,
1222
+ id,
1223
+ index,
1224
+ mapping: 'standard',
1225
+ timestamp: 0,
1226
+ } as unknown as Gamepad;
1227
+ Object.defineProperty(event, 'gamepad', { value: gamepad });
1228
+ return event;
1229
+ }