@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.
@@ -1,5 +1,11 @@
1
1
  import { connectSignal, emitSignal } from '@flighthq/signals/contract';
2
- import type { InputGamepadButtonData, InputPointerData } from '@flighthq/types/contract';
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
- pollGamepadInput,
44
+ installInputIngressHostBackend,
39
45
  releaseInputPointerCapture,
40
- requestInputPointerLock,
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
- 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);
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
- 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);
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('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
- });
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('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);
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
- 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);
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('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);
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
- 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);
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
- 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);
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 rather than by pollGamepadInput, because the built-in poller already
1066
- // edge-detects and so cannot produce a repeat. A native backend reporting held buttons every poll
1067
- // can, and these signals are public — which is why the guard belongs on the state machine.
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
- const mockPadDown = {
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
- const mockPadUp = {
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 createGamepadEvent(type: string, index: number, id: string): Event {
1303
- const event = new Event(type, { bubbles: false }) as GamepadEvent;
1304
- const gamepad = {
1305
- axes: [],
1306
- buttons: [],
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
+ }