Files
healer-man/src/game/inputManager.test.ts
T
2026-08-20 14:05:50 -04:00

294 lines
11 KiB
TypeScript

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<typeof vi.fn>;
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<typeof vi.fn>).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<typeof vi.fn>;
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<typeof vi.fn>;
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]);
});
});