import { afterEach, describe, expect, it, vi } from "vitest"; import { CONTROLLER_SYSTEM_BACK_TOKEN, emitControllerToken, orderedControllerTokenTransitions, resetControllerState, } from "../input/controller"; import { MAX_MOUSE_DELTA_PER_EVENT, MAX_MOUSE_DELTA_PER_FRAME, POINTER_LOCK_MOUSE_GRACE_MS, beginMouseLookDrag, clearJumpRequest, consumeJumpRequest, consumeMouseLook, controllerCommandForToken, createMouseLookBuffer, endMouseLookDrag, installInput, isMouseLookDragActive, isDesktopCameraLookPointer, keyboardAxesForKeys, keyboardCommandForCode, queueJumpRequest, sanitizeMouseDelta, type InputActions, } from "./inputManager"; function stubInputEventTargets(): void { const windowTarget = new EventTarget(); const documentTarget = new EventTarget(); Object.defineProperty(documentTarget, "pointerLockElement", { configurable: true, value: null }); vi.stubGlobal("window", windowTarget); vi.stubGlobal("document", documentTarget); } function inputActionSpies(blocked = false): InputActions { return { togglePause: vi.fn(), toggleMap: vi.fn(), toggleInventory: vi.fn(), toggleSpellbook: vi.fn(), toggleTalents: vi.fn(), lootActiveBoss: vi.fn(() => false), castActionBinding: vi.fn(), cycleTarget: vi.fn(), cyclePartyTarget: vi.fn(), setPartyCommand: vi.fn(), recallParty: vi.fn(), clearTarget: vi.fn(), groundTargetActive: vi.fn(() => false), confirmGroundTarget: vi.fn(), cancelGroundTarget: vi.fn(), gameplayActionsBlocked: vi.fn(() => blocked), setCameraLookActive: vi.fn(), setInputMode: vi.fn(), setActionLayer: vi.fn(), }; } afterEach(() => { endMouseLookDrag(); clearJumpRequest(); resetControllerState(); vi.unstubAllGlobals(); }); describe("gameplay input bindings", () => { it("reserves I, P, N, and E for equipment and loot interactions", () => { expect(keyboardCommandForCode("KeyI")).toBe("inventory"); expect(keyboardCommandForCode("KeyE")).toBe("loot"); expect(keyboardCommandForCode("KeyP")).toBe("spellbook"); expect(keyboardCommandForCode("KeyN")).toBe("talents"); expect(keyboardCommandForCode("F1")).toBe("party-attack"); expect(keyboardCommandForCode("F2")).toBe("party-defend"); expect(keyboardCommandForCode("F3")).toBe("party-stop"); expect(keyboardCommandForCode("F4")).toBe("party-recall"); expect(keyboardCommandForCode("Escape")).toBe("pause"); expect(keyboardCommandForCode("Tab", true)).toBe("target-previous"); }); it("uses the primary controller button to loot before casting", () => { stubInputEventTargets(); const nextActions = inputActionSpies(); const loot = nextActions.lootActiveBoss as ReturnType; loot.mockReturnValue(true); const dispose = installInput(nextActions); emitControllerToken({ token: "Button0", repeat: false, pressed: true }); expect(loot).toHaveBeenCalledTimes(1); expect(nextActions.castActionBinding).not.toHaveBeenCalled(); dispose(); }); it("confirms and cancels an active ground reticle before face-button casts", () => { stubInputEventTargets(); const nextActions = inputActionSpies(); (nextActions.groundTargetActive as ReturnType).mockReturnValue(true); const dispose = installInput(nextActions); emitControllerToken({ token: "Button0", repeat: false, pressed: true }); emitControllerToken({ token: "Button0", repeat: false, pressed: false }); emitControllerToken({ token: "Button1", repeat: false, pressed: true }); expect(nextActions.confirmGroundTarget).toHaveBeenCalledTimes(1); expect(nextActions.cancelGroundTarget).toHaveBeenCalledTimes(1); expect(nextActions.lootActiveBoss).not.toHaveBeenCalled(); expect(nextActions.castActionBinding).not.toHaveBeenCalled(); dispose(); }); it("maps Thor controls without stealing Select from display routing", () => { expect(controllerCommandForToken("Button9")).toBe("pause"); expect(controllerCommandForToken("Button11")).toBe("target-next"); expect(controllerCommandForToken("Button10")).toBe("jump"); expect(controllerCommandForToken("Button12")).toBe("party-previous"); expect(controllerCommandForToken("Button13")).toBe("party-next"); expect(controllerCommandForToken("Button4")).toBeNull(); expect(controllerCommandForToken("Button6")).toBeNull(); expect(controllerCommandForToken("Button0")).toBeNull(); expect(controllerCommandForToken("Button15")).toBeNull(); expect(controllerCommandForToken("Button8")).toBeNull(); expect(controllerCommandForToken(CONTROLLER_SYSTEM_BACK_TOKEN)).toBe("pause"); }); it("resolves held modifiers end to end and returns to Base on release", () => { stubInputEventTargets(); const nextActions = inputActionSpies(); const dispose = installInput(nextActions); emitControllerToken({ token: "Button6", repeat: false, pressed: true }); emitControllerToken({ token: "Button0", repeat: false, pressed: true }); expect(nextActions.castActionBinding).toHaveBeenLastCalledWith("tertiary", "face-bottom"); emitControllerToken({ token: "Button0", repeat: false, pressed: false }); for (const transition of orderedControllerTokenTransitions( new Set(["Button6"]), new Set(["Button0"]), )) emitControllerToken(transition); expect(nextActions.castActionBinding).toHaveBeenLastCalledWith("primary", "face-bottom"); expect(nextActions.castActionBinding).toHaveBeenCalledTimes(2); dispose(); }); it("uses semantic Android Back for gameplay pause but leaves blocked menus in control", () => { stubInputEventTargets(); const nextActions = inputActionSpies(); const blocked = nextActions.gameplayActionsBlocked as ReturnType; const dispose = installInput(nextActions); emitControllerToken({ token: CONTROLLER_SYSTEM_BACK_TOKEN, repeat: false, pressed: true }); emitControllerToken({ token: CONTROLLER_SYSTEM_BACK_TOKEN, repeat: false, pressed: false }); expect(nextActions.togglePause).toHaveBeenCalledTimes(1); blocked.mockReturnValue(true); emitControllerToken({ token: CONTROLLER_SYSTEM_BACK_TOKEN, repeat: false, pressed: true }); emitControllerToken({ token: CONTROLLER_SYSTEM_BACK_TOKEN, repeat: false, pressed: false }); emitControllerToken({ token: "Button1", repeat: false, pressed: true }); expect(nextActions.togglePause).toHaveBeenCalledTimes(1); expect(nextActions.castActionBinding).not.toHaveBeenCalled(); dispose(); }); it("leaves digit keys to the layered action-binding resolver", () => { expect(keyboardCommandForCode("Digit1")).toBeNull(); expect(keyboardCommandForCode("Digit8")).toBeNull(); }); it("queues Space and L3 jump presses once and blocks them behind menus", () => { expect(keyboardCommandForCode("Space")).toBe("jump"); queueJumpRequest(25); expect(consumeJumpRequest()).toBe(25); expect(consumeJumpRequest()).toBeNull(); stubInputEventTargets(); const nextActions = inputActionSpies(); const blocked = nextActions.gameplayActionsBlocked as ReturnType; const dispose = installInput(nextActions); emitControllerToken({ token: "Button10", repeat: false, pressed: true }); expect(consumeJumpRequest()).not.toBeNull(); emitControllerToken({ token: "Button10", repeat: true, pressed: true }); expect(consumeJumpRequest()).toBeNull(); blocked.mockReturnValue(true); emitControllerToken({ token: "Button10", repeat: false, pressed: false }); emitControllerToken({ token: "Button10", repeat: false, pressed: true }); expect(consumeJumpRequest()).toBeNull(); dispose(); }); it("uses WASD for movement and arrow keys for camera look", () => { expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({ moveX: 1, moveForward: 1, lookX: 0, lookY: 0, }); expect(keyboardAxesForKeys(new Set(["ArrowRight", "ArrowUp"]))).toEqual({ moveX: 0, moveForward: 0, lookX: 1, lookY: -1, }); expect(keyboardAxesForKeys(new Set(["ArrowLeft", "ArrowDown"]))).toEqual({ moveX: 0, moveForward: 0, lookX: -1, lookY: 1, }); }); it("starts desktop camera look only from the mouse's right button", () => { expect(isDesktopCameraLookPointer("mouse", 2)).toBe(true); expect(isDesktopCameraLookPointer("mouse", 0)).toBe(false); expect(isDesktopCameraLookPointer("mouse", 1)).toBe(false); expect(isDesktopCameraLookPointer("touch", 2)).toBe(false); expect(isDesktopCameraLookPointer("pen", 2)).toBe(false); }); it("turns the camera from a right-mouse drag without pointer lock", () => { stubInputEventTargets(); const nextActions = inputActionSpies(); const dispose = installInput(nextActions); beginMouseLookDrag(100, 80); const move = new Event("mousemove") as MouseEvent; Object.defineProperties(move, { clientX: { value: 114 }, clientY: { value: 73 }, movementX: { value: 9_999 }, movementY: { value: 9_999 }, }); document.dispatchEvent(move); expect(document.pointerLockElement).toBeNull(); expect(isMouseLookDragActive()).toBe(true); expect(consumeMouseLook()).toEqual([14, -7]); expect(nextActions.setCameraLookActive).toHaveBeenLastCalledWith(true); endMouseLookDrag(); expect(isMouseLookDragActive()).toBe(false); expect(nextActions.setCameraLookActive).toHaveBeenLastCalledWith(false); dispose(); }); it("contains malformed and stale pointer-lock movement", () => { expect(sanitizeMouseDelta(Number.NaN)).toBe(0); expect(sanitizeMouseDelta(Number.POSITIVE_INFINITY)).toBe(0); expect(sanitizeMouseDelta(undefined)).toBe(0); expect(sanitizeMouseDelta(42)).toBe(42); expect(sanitizeMouseDelta(9_999)).toBe(MAX_MOUSE_DELTA_PER_EVENT); expect(sanitizeMouseDelta(-9_999)).toBe(-MAX_MOUSE_DELTA_PER_EVENT); }); it("drops the complete pointer-lock transition burst, then accepts real movement", () => { let now = 1_000; const buffer = createMouseLookBuffer(() => now); buffer.reset(POINTER_LOCK_MOUSE_GRACE_MS); expect(buffer.push(9_999, -9_999)).toBe(false); now += POINTER_LOCK_MOUSE_GRACE_MS - 1; expect(buffer.push(-9_999, 9_999)).toBe(false); expect(buffer.consume()).toEqual([0, 0]); now += 1; expect(buffer.push(Number.NaN, undefined)).toBe(false); expect(buffer.push(9_999, 9_999)).toBe(true); expect(buffer.push(9_999, 9_999)).toBe(true); expect(buffer.push(9_999, 9_999)).toBe(true); expect(buffer.consume()).toEqual([MAX_MOUSE_DELTA_PER_FRAME, MAX_MOUSE_DELTA_PER_FRAME]); expect(buffer.consume()).toEqual([0, 0]); }); it("does not suppress movement when a reset has no pointer-lock grace period", () => { let now = 200; const buffer = createMouseLookBuffer(() => now); buffer.reset(); expect(buffer.push(7, -4)).toBe(true); expect(buffer.consume()).toEqual([7, -4]); now += 1; buffer.reset(POINTER_LOCK_MOUSE_GRACE_MS); now += POINTER_LOCK_MOUSE_GRACE_MS; expect(buffer.push(3, 5)).toBe(true); expect(buffer.consume()).toEqual([3, 5]); }); });