@flighthq/input 0.5.0-next.1944.cd6b6e7 → 0.5.1-edge.638.d4dbd4e
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.
- package/README.md +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/inputManager.d.ts +19 -29
- package/dist/inputManager.d.ts.map +1 -1
- package/dist/inputManager.js +403 -287
- package/dist/inputManager.js.map +1 -1
- package/package.json +3 -3
- package/src/index.test.ts +27 -0
- package/src/inputManager.test.ts +414 -165
package/src/inputManager.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { connectSignal, emitSignal } from '@flighthq/signals/contract';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
InputGamepadButtonData,
|
|
4
|
+
InputIngressBackend,
|
|
5
|
+
InputIngressSink,
|
|
6
|
+
InputIngressSource,
|
|
7
|
+
InputPointerData,
|
|
8
|
+
} from '@flighthq/types/contract';
|
|
3
9
|
import { GamepadAxisKind, GamepadButtonKind, KeyCode, KeyModifier } from '@flighthq/types/contract';
|
|
4
10
|
|
|
5
11
|
import {
|
|
@@ -16,6 +22,7 @@ import {
|
|
|
16
22
|
createInputManager,
|
|
17
23
|
createInputSignals,
|
|
18
24
|
createInputState,
|
|
25
|
+
createWebInputIngressBackend,
|
|
19
26
|
detachGamepadInput,
|
|
20
27
|
detachKeyboardInput,
|
|
21
28
|
detachPointerInput,
|
|
@@ -23,21 +30,21 @@ import {
|
|
|
23
30
|
detachTextInput,
|
|
24
31
|
detachWheelInput,
|
|
25
32
|
endInputStateFrame,
|
|
26
|
-
exitInputPointerLock,
|
|
27
33
|
getCoalescedInputPointerEvents,
|
|
28
34
|
getGamepadAxisName,
|
|
29
35
|
getGamepadButtonName,
|
|
30
36
|
getInputGamepadAxis,
|
|
37
|
+
getInputIngressBackend,
|
|
31
38
|
getKeyCodeFromDomKeyboardEvent,
|
|
32
39
|
getKeyModifierFromDomKeyboardEvent,
|
|
33
40
|
getMouseWheelModeFromDomWheelEvent,
|
|
34
|
-
hasInputPointerLock,
|
|
35
41
|
isInputGamepadButtonDown,
|
|
36
42
|
isInputKeyDown,
|
|
37
43
|
isInputPointerButtonDown,
|
|
38
|
-
|
|
44
|
+
installInputIngressHostBackend,
|
|
39
45
|
releaseInputPointerCapture,
|
|
40
|
-
|
|
46
|
+
resetInputIngressBackendForTest,
|
|
47
|
+
setInputIngressBackend,
|
|
41
48
|
setInputPointerCapture,
|
|
42
49
|
wasInputGamepadButtonPressed,
|
|
43
50
|
wasInputGamepadButtonReleased,
|
|
@@ -45,6 +52,27 @@ import {
|
|
|
45
52
|
wasInputKeyReleased,
|
|
46
53
|
} from './inputManager';
|
|
47
54
|
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
resetInputIngressBackendForTest();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type InputIngressAttachmentKind = 'gamepad' | 'keyboard' | 'pointer' | 'relativePointer' | 'text' | 'wheel';
|
|
61
|
+
|
|
62
|
+
function createTestInputIngressBackend(
|
|
63
|
+
attach: (kind: InputIngressAttachmentKind, source: InputIngressSource, sink: InputIngressSink) => () => void = () =>
|
|
64
|
+
() => {},
|
|
65
|
+
): InputIngressBackend {
|
|
66
|
+
return {
|
|
67
|
+
attachGamepad: (source, sink) => attach('gamepad', source, sink),
|
|
68
|
+
attachKeyboard: (source, sink) => attach('keyboard', source, sink),
|
|
69
|
+
attachPointer: (source, sink) => attach('pointer', source, sink),
|
|
70
|
+
attachRelativePointer: (source, sink) => attach('relativePointer', source, sink),
|
|
71
|
+
attachText: (source, sink) => attach('text', source, sink),
|
|
72
|
+
attachWheel: (source, sink) => attach('wheel', source, sink),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
describe('applyGamepadAxisDeadZone', () => {
|
|
49
77
|
it('returns 0 when value is within the dead zone', () => {
|
|
50
78
|
expect(applyGamepadAxisDeadZone(0.1, 0.2)).toBe(0);
|
|
@@ -141,6 +169,42 @@ describe('attachGamepadInput', () => {
|
|
|
141
169
|
window.dispatchEvent(createGamepadEvent('gamepadconnected', 0, 'Pad'));
|
|
142
170
|
expect(fired).toBe(0);
|
|
143
171
|
});
|
|
172
|
+
|
|
173
|
+
it('accepts native axis and button pushes without browser polling globals', () => {
|
|
174
|
+
let sink: InputIngressSink | null = null;
|
|
175
|
+
setInputIngressBackend(
|
|
176
|
+
createTestInputIngressBackend((kind, _source, attachedSink) => {
|
|
177
|
+
if (kind === 'gamepad') sink = attachedSink;
|
|
178
|
+
return () => {};
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
vi.stubGlobal('navigator', undefined);
|
|
182
|
+
vi.stubGlobal('requestAnimationFrame', undefined);
|
|
183
|
+
vi.stubGlobal('cancelAnimationFrame', undefined);
|
|
184
|
+
vi.stubGlobal('performance', undefined);
|
|
185
|
+
const manager = createInputManager();
|
|
186
|
+
const state = createInputState();
|
|
187
|
+
connectInputStateToInputManager(state, manager);
|
|
188
|
+
const events: string[] = [];
|
|
189
|
+
connectSignal(manager.onGamepadConnect, () => events.push('connect'));
|
|
190
|
+
connectSignal(manager.onGamepadAxisMove, () => events.push('axis'));
|
|
191
|
+
connectSignal(manager.onGamepadButtonDown, () => events.push('down'));
|
|
192
|
+
connectSignal(manager.onGamepadButtonUp, () => events.push('up'));
|
|
193
|
+
connectSignal(manager.onGamepadDisconnect, () => events.push('disconnect'));
|
|
194
|
+
|
|
195
|
+
attachGamepadInput(manager, {});
|
|
196
|
+
sink!.gamepadConnect({ gamepad: 2, id: 'Native Pad', mapping: 'raw' });
|
|
197
|
+
sink!.gamepadAxisMove({ axis: 1, gamepad: 2, timeStamp: 10, value: 0.75 });
|
|
198
|
+
sink!.gamepadButtonDown({ button: 3, gamepad: 2, timeStamp: 11, value: 1 });
|
|
199
|
+
expect(getInputGamepadAxis(state, 2, 1)).toBe(0.75);
|
|
200
|
+
expect(isInputGamepadButtonDown(state, 2, 3)).toBe(true);
|
|
201
|
+
sink!.gamepadButtonUp({ button: 3, gamepad: 2, timeStamp: 12, value: 0 });
|
|
202
|
+
sink!.gamepadDisconnect({ gamepad: 2, id: 'Native Pad', mapping: 'raw' });
|
|
203
|
+
|
|
204
|
+
expect(events).toEqual(['connect', 'axis', 'down', 'up', 'disconnect']);
|
|
205
|
+
expect(getInputGamepadAxis(state, 2, 1)).toBe(0);
|
|
206
|
+
expect(isInputGamepadButtonDown(state, 2, 3)).toBe(false);
|
|
207
|
+
});
|
|
144
208
|
});
|
|
145
209
|
|
|
146
210
|
describe('attachKeyboardInput', () => {
|
|
@@ -414,24 +478,9 @@ describe('connectInputStateToInputManager', () => {
|
|
|
414
478
|
const state = createInputState();
|
|
415
479
|
connectInputStateToInputManager(state, manager);
|
|
416
480
|
|
|
417
|
-
|
|
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);
|
|
481
|
+
emitSignal(manager.onGamepadButtonDown, { button: 0, gamepad: 0, timeStamp: 1, value: 1 });
|
|
425
482
|
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);
|
|
483
|
+
emitSignal(manager.onGamepadButtonUp, { button: 0, gamepad: 0, timeStamp: 2, value: 0 });
|
|
435
484
|
expect(isInputGamepadButtonDown(state, 0, 0)).toBe(false);
|
|
436
485
|
});
|
|
437
486
|
|
|
@@ -440,14 +489,7 @@ describe('connectInputStateToInputManager', () => {
|
|
|
440
489
|
const state = createInputState();
|
|
441
490
|
connectInputStateToInputManager(state, manager);
|
|
442
491
|
|
|
443
|
-
|
|
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);
|
|
492
|
+
emitSignal(manager.onGamepadAxisMove, { axis: 0, gamepad: 0, timeStamp: 1, value: 0.75 });
|
|
451
493
|
expect(getInputGamepadAxis(state, 0, 0)).toBe(0.75);
|
|
452
494
|
});
|
|
453
495
|
|
|
@@ -574,6 +616,159 @@ describe('createInputState', () => {
|
|
|
574
616
|
});
|
|
575
617
|
});
|
|
576
618
|
|
|
619
|
+
describe('createWebInputIngressBackend', () => {
|
|
620
|
+
it('owns gamepad polling and emits only changed Web state', () => {
|
|
621
|
+
const frames = installManualAnimationFrames();
|
|
622
|
+
const getGamepads = vi.fn<() => Gamepad[]>();
|
|
623
|
+
Object.defineProperty(navigator, 'getGamepads', { configurable: true, value: getGamepads });
|
|
624
|
+
const manager = createInputManager();
|
|
625
|
+
const axes: number[] = [];
|
|
626
|
+
const buttons: number[] = [];
|
|
627
|
+
connectSignal(manager.onGamepadAxisMove, (data) => axes.push(data.value));
|
|
628
|
+
connectSignal(manager.onGamepadButtonUp, (data) => buttons.push(data.value));
|
|
629
|
+
attachGamepadInput(manager, window);
|
|
630
|
+
|
|
631
|
+
const initial = createGamepad(0, 'Pad', [0.25], [{ pressed: true, touched: true, value: 1 }]);
|
|
632
|
+
getGamepads.mockReturnValue([initial]);
|
|
633
|
+
window.dispatchEvent(createGamepadEvent('gamepadconnected', initial));
|
|
634
|
+
frames.runAllCurrent();
|
|
635
|
+
expect(axes).toEqual([]);
|
|
636
|
+
expect(buttons).toEqual([]);
|
|
637
|
+
|
|
638
|
+
getGamepads.mockReturnValue([createGamepad(0, 'Pad', [0.75], [{ pressed: false, touched: false, value: 0 }])]);
|
|
639
|
+
frames.runAllCurrent();
|
|
640
|
+
expect(axes).toEqual([0.75]);
|
|
641
|
+
expect(buttons).toEqual([0]);
|
|
642
|
+
frames.runAllCurrent();
|
|
643
|
+
expect(axes).toEqual([0.75]);
|
|
644
|
+
expect(buttons).toEqual([0]);
|
|
645
|
+
detachGamepadInput(manager, window);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('keeps Web polling state and releases independent per source', () => {
|
|
649
|
+
const frames = installManualAnimationFrames();
|
|
650
|
+
const getGamepads = vi.fn<() => Gamepad[]>().mockReturnValue([createGamepad(0, 'Pad', [0.5], [])]);
|
|
651
|
+
Object.defineProperty(navigator, 'getGamepads', { configurable: true, value: getGamepads });
|
|
652
|
+
const firstSource = new EventTarget();
|
|
653
|
+
const secondSource = new EventTarget();
|
|
654
|
+
const firstManager = createInputManager();
|
|
655
|
+
const secondManager = createInputManager();
|
|
656
|
+
let firstMoves = 0;
|
|
657
|
+
let secondMoves = 0;
|
|
658
|
+
connectSignal(firstManager.onGamepadAxisMove, () => firstMoves++);
|
|
659
|
+
connectSignal(secondManager.onGamepadAxisMove, () => secondMoves++);
|
|
660
|
+
attachGamepadInput(firstManager, firstSource);
|
|
661
|
+
attachGamepadInput(secondManager, secondSource);
|
|
662
|
+
|
|
663
|
+
frames.runAllCurrent();
|
|
664
|
+
expect([firstMoves, secondMoves]).toEqual([1, 1]);
|
|
665
|
+
detachGamepadInput(firstManager, firstSource);
|
|
666
|
+
getGamepads.mockReturnValue([createGamepad(0, 'Pad', [0.75], [])]);
|
|
667
|
+
frames.runAllCurrent();
|
|
668
|
+
expect([firstMoves, secondMoves]).toEqual([1, 2]);
|
|
669
|
+
detachGamepadInput(secondManager, secondSource);
|
|
670
|
+
expect(frames.pending.size).toBe(0);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('routes two window identities only to their corresponding managers', () => {
|
|
674
|
+
const firstFrame = document.createElement('iframe');
|
|
675
|
+
const secondFrame = document.createElement('iframe');
|
|
676
|
+
document.body.append(firstFrame, secondFrame);
|
|
677
|
+
const firstWindow = firstFrame.contentWindow!;
|
|
678
|
+
const secondWindow = secondFrame.contentWindow!;
|
|
679
|
+
const firstManager = createInputManager();
|
|
680
|
+
const secondManager = createInputManager();
|
|
681
|
+
const firstEvents: number[] = [];
|
|
682
|
+
const secondEvents: number[] = [];
|
|
683
|
+
connectSignal(firstManager.onKeyDown, (data) => firstEvents.push(data.keyCode));
|
|
684
|
+
connectSignal(secondManager.onKeyDown, (data) => secondEvents.push(data.keyCode));
|
|
685
|
+
attachKeyboardInput(firstManager, firstWindow);
|
|
686
|
+
attachKeyboardInput(secondManager, secondWindow);
|
|
687
|
+
|
|
688
|
+
firstWindow.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyA', key: 'a' }));
|
|
689
|
+
expect(firstEvents).toEqual([KeyCode.A]);
|
|
690
|
+
expect(secondEvents).toEqual([]);
|
|
691
|
+
|
|
692
|
+
secondWindow.dispatchEvent(createKeyboardEvent('keydown', { code: 'KeyB', key: 'b' }));
|
|
693
|
+
expect(firstEvents).toEqual([KeyCode.A]);
|
|
694
|
+
expect(secondEvents).toEqual([KeyCode.B]);
|
|
695
|
+
|
|
696
|
+
detachKeyboardInput(firstManager, firstWindow);
|
|
697
|
+
detachKeyboardInput(secondManager, secondWindow);
|
|
698
|
+
firstFrame.remove();
|
|
699
|
+
secondFrame.remove();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('returns inert releases for native source identities the Web adapter cannot interpret', () => {
|
|
703
|
+
const frames = installManualAnimationFrames();
|
|
704
|
+
const backend = createWebInputIngressBackend();
|
|
705
|
+
const source = {};
|
|
706
|
+
const sink = {} as InputIngressSink;
|
|
707
|
+
expect(() => backend.attachKeyboard(source, sink)()).not.toThrow();
|
|
708
|
+
expect(() => backend.attachGamepad(source, sink)()).not.toThrow();
|
|
709
|
+
expect(frames.request).not.toHaveBeenCalled();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe('createWebInputIngressBackend gamepad polling', () => {
|
|
714
|
+
beforeEach(() => {
|
|
715
|
+
Object.defineProperty(navigator, 'getGamepads', {
|
|
716
|
+
configurable: true,
|
|
717
|
+
value: () => [],
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('emits onGamepadButtonDown when a button transitions to pressed', () => {
|
|
722
|
+
const frames = installManualAnimationFrames();
|
|
723
|
+
const manager = createInputManager();
|
|
724
|
+
const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
|
|
725
|
+
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
|
|
726
|
+
|
|
727
|
+
let received: { button: number; gamepad: number } | null = null;
|
|
728
|
+
connectSignal(manager.onGamepadButtonDown, (data: Readonly<InputGamepadButtonData>) => {
|
|
729
|
+
received = { button: data.button, gamepad: data.gamepad };
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
attachGamepadInput(manager, window);
|
|
733
|
+
frames.runAllCurrent();
|
|
734
|
+
expect(received).toEqual({ button: 0, gamepad: 0 });
|
|
735
|
+
detachGamepadInput(manager, window);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('populates timeStamp on gamepad button data', () => {
|
|
739
|
+
const frames = installManualAnimationFrames();
|
|
740
|
+
const manager = createInputManager();
|
|
741
|
+
const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
|
|
742
|
+
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
|
|
743
|
+
|
|
744
|
+
let receivedTimeStamp = -1;
|
|
745
|
+
connectSignal(manager.onGamepadButtonDown, (data: Readonly<InputGamepadButtonData>) => {
|
|
746
|
+
receivedTimeStamp = data.timeStamp;
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
attachGamepadInput(manager, window);
|
|
750
|
+
frames.runAllCurrent();
|
|
751
|
+
expect(receivedTimeStamp).toBeGreaterThanOrEqual(0);
|
|
752
|
+
detachGamepadInput(manager, window);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('does not emit when state is unchanged', () => {
|
|
756
|
+
const frames = installManualAnimationFrames();
|
|
757
|
+
const manager = createInputManager();
|
|
758
|
+
const mockPad = { axes: [], buttons: [{ pressed: true, touched: true, value: 1 }], index: 0 } as unknown as Gamepad;
|
|
759
|
+
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
|
|
760
|
+
|
|
761
|
+
attachGamepadInput(manager, window);
|
|
762
|
+
frames.runAllCurrent();
|
|
763
|
+
|
|
764
|
+
let fired = 0;
|
|
765
|
+
connectSignal(manager.onGamepadButtonDown, () => fired++);
|
|
766
|
+
frames.runAllCurrent();
|
|
767
|
+
expect(fired).toBe(0);
|
|
768
|
+
detachGamepadInput(manager, window);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
577
772
|
describe('detachGamepadInput', () => {
|
|
578
773
|
it('removes listeners so signals stop firing', () => {
|
|
579
774
|
const manager = createInputManager();
|
|
@@ -591,6 +786,21 @@ describe('detachGamepadInput', () => {
|
|
|
591
786
|
const manager = createInputManager();
|
|
592
787
|
expect(() => detachGamepadInput(manager, window)).not.toThrow();
|
|
593
788
|
});
|
|
789
|
+
|
|
790
|
+
it('cannot resurrect Web polling when detached from a sink callback', () => {
|
|
791
|
+
const frames = installManualAnimationFrames();
|
|
792
|
+
Object.defineProperty(navigator, 'getGamepads', {
|
|
793
|
+
configurable: true,
|
|
794
|
+
value: () => [createGamepad(0, 'Pad', [0.5], [])],
|
|
795
|
+
});
|
|
796
|
+
const manager = createInputManager();
|
|
797
|
+
connectSignal(manager.onGamepadAxisMove, () => detachGamepadInput(manager, window));
|
|
798
|
+
attachGamepadInput(manager, window);
|
|
799
|
+
|
|
800
|
+
frames.runAllCurrent();
|
|
801
|
+
expect(frames.pending.size).toBe(0);
|
|
802
|
+
expect(frames.cancel).toHaveBeenCalledOnce();
|
|
803
|
+
});
|
|
594
804
|
});
|
|
595
805
|
|
|
596
806
|
describe('detachKeyboardInput', () => {
|
|
@@ -717,20 +927,6 @@ describe('endInputStateFrame', () => {
|
|
|
717
927
|
});
|
|
718
928
|
});
|
|
719
929
|
|
|
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
930
|
describe('getCoalescedInputPointerEvents', () => {
|
|
735
931
|
it('falls back to a single event when getCoalescedEvents is unavailable', () => {
|
|
736
932
|
const event = createPointerEvent('pointermove', { clientX: 10, clientY: 20 });
|
|
@@ -800,6 +996,13 @@ describe('getInputGamepadAxis', () => {
|
|
|
800
996
|
});
|
|
801
997
|
});
|
|
802
998
|
|
|
999
|
+
describe('getInputIngressBackend', () => {
|
|
1000
|
+
it('uses one stable Web fallback', () => {
|
|
1001
|
+
const fallback = getInputIngressBackend();
|
|
1002
|
+
expect(getInputIngressBackend()).toBe(fallback);
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
|
|
803
1006
|
describe('getKeyCodeFromDomKeyboardEvent', () => {
|
|
804
1007
|
it('maps printable keys to SDL-compatible lower-case codes', () => {
|
|
805
1008
|
expect(getKeyCodeFromDomKeyboardEvent(createKeyboardEvent('keydown', { key: 'A' }))).toBe(KeyCode.A);
|
|
@@ -875,27 +1078,12 @@ describe('getMouseWheelModeFromDomWheelEvent', () => {
|
|
|
875
1078
|
});
|
|
876
1079
|
});
|
|
877
1080
|
|
|
878
|
-
describe('
|
|
879
|
-
it('
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
});
|
|
1081
|
+
describe('installInputIngressHostBackend', () => {
|
|
1082
|
+
it('preserves the first installed host identity', () => {
|
|
1083
|
+
const firstHost = createTestInputIngressBackend();
|
|
1084
|
+
installInputIngressHostBackend(firstHost);
|
|
1085
|
+
installInputIngressHostBackend(createTestInputIngressBackend());
|
|
1086
|
+
expect(getInputIngressBackend()).toBe(firstHost);
|
|
899
1087
|
});
|
|
900
1088
|
});
|
|
901
1089
|
|
|
@@ -920,56 +1108,6 @@ describe('isInputPointerButtonDown', () => {
|
|
|
920
1108
|
});
|
|
921
1109
|
});
|
|
922
1110
|
|
|
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
1111
|
describe('releaseInputPointerCapture', () => {
|
|
974
1112
|
it('calls releasePointerCapture on the element', () => {
|
|
975
1113
|
const element = document.createElement('div');
|
|
@@ -990,30 +1128,116 @@ describe('releaseInputPointerCapture', () => {
|
|
|
990
1128
|
});
|
|
991
1129
|
});
|
|
992
1130
|
|
|
993
|
-
describe('
|
|
994
|
-
it('
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
expect(
|
|
1131
|
+
describe('resetInputIngressBackendForTest', () => {
|
|
1132
|
+
it('clears custom and host slots back to the Web fallback', () => {
|
|
1133
|
+
const fallback = getInputIngressBackend();
|
|
1134
|
+
installInputIngressHostBackend(createTestInputIngressBackend());
|
|
1135
|
+
setInputIngressBackend(createTestInputIngressBackend());
|
|
1136
|
+
|
|
1137
|
+
resetInputIngressBackendForTest();
|
|
1138
|
+
expect(getInputIngressBackend()).toBe(fallback);
|
|
1001
1139
|
});
|
|
1140
|
+
});
|
|
1002
1141
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1142
|
+
describe('setInputIngressBackend', () => {
|
|
1143
|
+
it('gives a custom backend precedence and reveals the installed host when cleared', () => {
|
|
1144
|
+
const firstHost = createTestInputIngressBackend();
|
|
1145
|
+
installInputIngressHostBackend(firstHost);
|
|
1146
|
+
const custom = createTestInputIngressBackend();
|
|
1147
|
+
setInputIngressBackend(custom);
|
|
1148
|
+
expect(getInputIngressBackend()).toBe(custom);
|
|
1149
|
+
setInputIngressBackend(null);
|
|
1150
|
+
expect(getInputIngressBackend()).toBe(firstHost);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it('passes each exact source identity through all six attachment families and releases each once', () => {
|
|
1154
|
+
const attachments: Array<Readonly<{ kind: InputIngressAttachmentKind; source: InputIngressSource }>> = [];
|
|
1155
|
+
const releases = new Map<InputIngressAttachmentKind, ReturnType<typeof vi.fn>>();
|
|
1156
|
+
setInputIngressBackend(
|
|
1157
|
+
createTestInputIngressBackend((kind, source) => {
|
|
1158
|
+
attachments.push({ kind, source });
|
|
1159
|
+
const release = vi.fn();
|
|
1160
|
+
releases.set(kind, release);
|
|
1161
|
+
return release;
|
|
1162
|
+
}),
|
|
1163
|
+
);
|
|
1164
|
+
const manager = createInputManager();
|
|
1165
|
+
const sources = {
|
|
1166
|
+
gamepad: {},
|
|
1167
|
+
keyboard: {},
|
|
1168
|
+
pointer: {},
|
|
1169
|
+
relativePointer: {},
|
|
1170
|
+
text: {},
|
|
1171
|
+
wheel: {},
|
|
1172
|
+
} satisfies Record<InputIngressAttachmentKind, InputIngressSource>;
|
|
1173
|
+
|
|
1174
|
+
attachGamepadInput(manager, sources.gamepad);
|
|
1175
|
+
attachKeyboardInput(manager, sources.keyboard);
|
|
1176
|
+
attachPointerInput(manager, sources.pointer);
|
|
1177
|
+
attachRelativePointerInput(manager, sources.relativePointer);
|
|
1178
|
+
attachTextInput(manager, sources.text);
|
|
1179
|
+
attachWheelInput(manager, sources.wheel);
|
|
1180
|
+
|
|
1181
|
+
expect(attachments).toEqual([
|
|
1182
|
+
{ kind: 'gamepad', source: sources.gamepad },
|
|
1183
|
+
{ kind: 'keyboard', source: sources.keyboard },
|
|
1184
|
+
{ kind: 'pointer', source: sources.pointer },
|
|
1185
|
+
{ kind: 'relativePointer', source: sources.relativePointer },
|
|
1186
|
+
{ kind: 'text', source: sources.text },
|
|
1187
|
+
{ kind: 'wheel', source: sources.wheel },
|
|
1188
|
+
]);
|
|
1189
|
+
|
|
1190
|
+
detachGamepadInput(manager, sources.gamepad);
|
|
1191
|
+
detachKeyboardInput(manager, sources.keyboard);
|
|
1192
|
+
detachPointerInput(manager, sources.pointer);
|
|
1193
|
+
detachRelativePointerInput(manager, sources.relativePointer);
|
|
1194
|
+
detachTextInput(manager, sources.text);
|
|
1195
|
+
detachWheelInput(manager, sources.wheel);
|
|
1196
|
+
detachGamepadInput(manager, sources.gamepad);
|
|
1197
|
+
detachKeyboardInput(manager, sources.keyboard);
|
|
1198
|
+
detachPointerInput(manager, sources.pointer);
|
|
1199
|
+
detachRelativePointerInput(manager, sources.relativePointer);
|
|
1200
|
+
detachTextInput(manager, sources.text);
|
|
1201
|
+
detachWheelInput(manager, sources.wheel);
|
|
1202
|
+
|
|
1203
|
+
expect([...releases.values()].every((release) => release.mock.calls.length === 1)).toBe(true);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('pins cleanup to the originating backend and never destroys the borrowed source', () => {
|
|
1207
|
+
const releaseA = vi.fn();
|
|
1208
|
+
const releaseB = vi.fn();
|
|
1209
|
+
const backendA = createTestInputIngressBackend((kind) => (kind === 'keyboard' ? releaseA : vi.fn()));
|
|
1210
|
+
const backendB = createTestInputIngressBackend((kind) => (kind === 'keyboard' ? releaseB : vi.fn()));
|
|
1211
|
+
const source = { destroy: vi.fn() };
|
|
1212
|
+
const manager = createInputManager();
|
|
1213
|
+
|
|
1214
|
+
setInputIngressBackend(backendA);
|
|
1215
|
+
attachKeyboardInput(manager, source);
|
|
1216
|
+
setInputIngressBackend(backendB);
|
|
1217
|
+
detachKeyboardInput(manager, source);
|
|
1218
|
+
detachKeyboardInput(manager, source);
|
|
1219
|
+
|
|
1220
|
+
expect(releaseA).toHaveBeenCalledOnce();
|
|
1221
|
+
expect(releaseB).not.toHaveBeenCalled();
|
|
1222
|
+
expect(source.destroy).not.toHaveBeenCalled();
|
|
1008
1223
|
});
|
|
1009
1224
|
|
|
1010
|
-
it('
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1225
|
+
it('releases the old origin once when the same source is reattached after backend replacement', () => {
|
|
1226
|
+
const releaseA = vi.fn();
|
|
1227
|
+
const releaseB = vi.fn();
|
|
1228
|
+
const source = {};
|
|
1229
|
+
const manager = createInputManager();
|
|
1230
|
+
|
|
1231
|
+
setInputIngressBackend(createTestInputIngressBackend(() => releaseA));
|
|
1232
|
+
attachPointerInput(manager, source);
|
|
1233
|
+
setInputIngressBackend(createTestInputIngressBackend(() => releaseB));
|
|
1234
|
+
attachPointerInput(manager, source);
|
|
1235
|
+
expect(releaseA).toHaveBeenCalledOnce();
|
|
1236
|
+
|
|
1237
|
+
detachPointerInput(manager, source);
|
|
1238
|
+
detachPointerInput(manager, source);
|
|
1239
|
+
expect(releaseA).toHaveBeenCalledOnce();
|
|
1240
|
+
expect(releaseB).toHaveBeenCalledOnce();
|
|
1017
1241
|
});
|
|
1018
1242
|
});
|
|
1019
1243
|
|
|
@@ -1042,9 +1266,7 @@ describe('wasInputGamepadButtonPressed', () => {
|
|
|
1042
1266
|
const state = createInputState();
|
|
1043
1267
|
connectInputStateToInputManager(state, manager);
|
|
1044
1268
|
|
|
1045
|
-
|
|
1046
|
-
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
|
|
1047
|
-
pollGamepadInput(manager);
|
|
1269
|
+
emitSignal(manager.onGamepadButtonDown, { button: 0, gamepad: 0, timeStamp: 1, value: 1 });
|
|
1048
1270
|
|
|
1049
1271
|
expect(wasInputGamepadButtonPressed(state, 0, 0)).toBe(true);
|
|
1050
1272
|
});
|
|
@@ -1054,17 +1276,15 @@ describe('wasInputGamepadButtonPressed', () => {
|
|
|
1054
1276
|
const state = createInputState();
|
|
1055
1277
|
connectInputStateToInputManager(state, manager);
|
|
1056
1278
|
|
|
1057
|
-
|
|
1058
|
-
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPad, null, null, null]);
|
|
1059
|
-
pollGamepadInput(manager);
|
|
1279
|
+
emitSignal(manager.onGamepadButtonDown, { button: 0, gamepad: 0, timeStamp: 1, value: 1 });
|
|
1060
1280
|
endInputStateFrame(state);
|
|
1061
1281
|
|
|
1062
1282
|
expect(wasInputGamepadButtonPressed(state, 0, 0)).toBe(false);
|
|
1063
1283
|
});
|
|
1064
1284
|
|
|
1065
|
-
// Driven by emitting the signal
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1285
|
+
// Driven by emitting the signal because the Web adapter already edge-detects and cannot produce a
|
|
1286
|
+
// repeat. A native backend reporting held buttons every poll can, and these signals are public —
|
|
1287
|
+
// which is why the guard belongs on the state machine.
|
|
1068
1288
|
it('returns false when a held button is reported down again', () => {
|
|
1069
1289
|
const manager = createInputManager();
|
|
1070
1290
|
const state = createInputState();
|
|
@@ -1108,24 +1328,12 @@ describe('wasInputGamepadButtonReleased', () => {
|
|
|
1108
1328
|
const state = createInputState();
|
|
1109
1329
|
connectInputStateToInputManager(state, manager);
|
|
1110
1330
|
|
|
1111
|
-
// Press the button first
|
|
1112
|
-
|
|
1113
|
-
axes: [],
|
|
1114
|
-
buttons: [{ pressed: true, touched: true, value: 1 }],
|
|
1115
|
-
index: 0,
|
|
1116
|
-
} as unknown as Gamepad;
|
|
1117
|
-
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPadDown, null, null, null]);
|
|
1118
|
-
pollGamepadInput(manager);
|
|
1331
|
+
// Press the button first.
|
|
1332
|
+
emitSignal(manager.onGamepadButtonDown, { button: 0, gamepad: 0, timeStamp: 1, value: 1 });
|
|
1119
1333
|
endInputStateFrame(state);
|
|
1120
1334
|
|
|
1121
|
-
// Release it
|
|
1122
|
-
|
|
1123
|
-
axes: [],
|
|
1124
|
-
buttons: [{ pressed: false, touched: false, value: 0 }],
|
|
1125
|
-
index: 0,
|
|
1126
|
-
} as unknown as Gamepad;
|
|
1127
|
-
vi.spyOn(navigator, 'getGamepads').mockReturnValue([mockPadUp, null, null, null]);
|
|
1128
|
-
pollGamepadInput(manager);
|
|
1335
|
+
// Release it.
|
|
1336
|
+
emitSignal(manager.onGamepadButtonUp, { button: 0, gamepad: 0, timeStamp: 2, value: 0 });
|
|
1129
1337
|
|
|
1130
1338
|
expect(wasInputGamepadButtonReleased(state, 0, 0)).toBe(true);
|
|
1131
1339
|
});
|
|
@@ -1299,17 +1507,58 @@ function createWheelEvent(options: WheelEventInit = {}): WheelEvent {
|
|
|
1299
1507
|
});
|
|
1300
1508
|
}
|
|
1301
1509
|
|
|
1302
|
-
function
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1510
|
+
function createGamepad(
|
|
1511
|
+
index: number,
|
|
1512
|
+
id: string,
|
|
1513
|
+
axes: readonly number[] = [],
|
|
1514
|
+
buttons: readonly GamepadButton[] = [],
|
|
1515
|
+
): Gamepad {
|
|
1516
|
+
return {
|
|
1517
|
+
axes,
|
|
1518
|
+
buttons,
|
|
1307
1519
|
connected: true,
|
|
1308
1520
|
id,
|
|
1309
1521
|
index,
|
|
1310
1522
|
mapping: 'standard',
|
|
1311
1523
|
timestamp: 0,
|
|
1312
1524
|
} as unknown as Gamepad;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function createGamepadEvent(type: string, gamepadOrIndex: Gamepad | number, id?: string): Event {
|
|
1528
|
+
const event = new Event(type, { bubbles: false }) as GamepadEvent;
|
|
1529
|
+
const gamepad = typeof gamepadOrIndex === 'number' ? createGamepad(gamepadOrIndex, id ?? '') : gamepadOrIndex;
|
|
1313
1530
|
Object.defineProperty(event, 'gamepad', { value: gamepad });
|
|
1314
1531
|
return event;
|
|
1315
1532
|
}
|
|
1533
|
+
|
|
1534
|
+
function installManualAnimationFrames(): Readonly<{
|
|
1535
|
+
cancel: ReturnType<typeof vi.fn>;
|
|
1536
|
+
pending: Map<number, FrameRequestCallback>;
|
|
1537
|
+
request: ReturnType<typeof vi.fn>;
|
|
1538
|
+
runAllCurrent(): void;
|
|
1539
|
+
}> {
|
|
1540
|
+
let nextHandle = 1;
|
|
1541
|
+
const pending = new Map<number, FrameRequestCallback>();
|
|
1542
|
+
const request = vi.fn((callback: FrameRequestCallback): number => {
|
|
1543
|
+
const handle = nextHandle++;
|
|
1544
|
+
pending.set(handle, callback);
|
|
1545
|
+
return handle;
|
|
1546
|
+
});
|
|
1547
|
+
const cancel = vi.fn((handle: number): void => {
|
|
1548
|
+
pending.delete(handle);
|
|
1549
|
+
});
|
|
1550
|
+
vi.stubGlobal('requestAnimationFrame', request);
|
|
1551
|
+
vi.stubGlobal('cancelAnimationFrame', cancel);
|
|
1552
|
+
return {
|
|
1553
|
+
cancel,
|
|
1554
|
+
pending,
|
|
1555
|
+
request,
|
|
1556
|
+
runAllCurrent(): void {
|
|
1557
|
+
const current = [...pending.entries()];
|
|
1558
|
+
for (const [handle, callback] of current) {
|
|
1559
|
+
if (!pending.delete(handle)) continue;
|
|
1560
|
+
callback(performance.now());
|
|
1561
|
+
}
|
|
1562
|
+
},
|
|
1563
|
+
};
|
|
1564
|
+
}
|