Files
healer-man/src/game/inputManager.test.ts
T
2026-09-16 14:30:58 -04:00

464 lines
20 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
CONTROLLER_SYSTEM_BACK_TOKEN,
emitControllerToken,
orderedControllerTokenTransitions,
resetControllerState,
setExternalControllerMovement,
interceptControllerInput,
} from "../input/controller";
import {
MAX_MOUSE_DELTA_PER_EVENT,
MAX_MOUSE_DELTA_PER_FRAME,
POINTER_LOCK_MOUSE_GRACE_MS,
beginMouseLookDrag,
clearBrowserSelection,
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
controllerCommandForToken,
createMouseLookBuffer,
endMouseLookDrag,
installInput,
isMouseLookDragActive,
isDesktopCameraLookPointer,
keyboardAxesForKeys,
keyboardCommandForCode,
queueJumpRequest,
sanitizeMouseDelta,
readInputSnapshot,
type InputActions,
} from "./inputManager";
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { setCapturingInput } from "../input/keybindRuntime";
function stubInputEventTargets(selection: Pick<Selection, "removeAllRanges"> | null = null): void {
const windowTarget = new EventTarget();
const documentTarget = new EventTarget();
Object.defineProperty(windowTarget, "getSelection", { configurable: true, value: () => selection });
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(() => {
setCapturingInput(false);
endMouseLookDrag();
clearJumpRequest();
resetControllerState();
vi.unstubAllGlobals();
});
function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Partial<KeyboardEvent> = {}) {
const event = new Event(type, { cancelable: true });
Object.defineProperties(event, Object.fromEntries(Object.entries({ code, target: null, ...modifiers }).map(([key, value]) => [key, { value }])));
window.dispatchEvent(event);
return event;
}
describe("remappable gameplay controls", () => {
it("keeps moving forward when a legacy WoW skill also owns W", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc["bar:0:0"] = "KeyW";
next.getBindings = (device) => preferences[device];
next.desktopActionBarsActive = () => true;
next.castDesktopSlot = vi.fn();
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW");
expect(readInputSnapshot().moveForward).toBe(1);
expect(next.castDesktopSlot).not.toHaveBeenCalled();
keyboardEvent("keyup", "KeyW");
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("honors remapped movement and clears held input when a menu opens", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc = replaceInputBinding(preferences.pc, "pc", "move-forward", "ArrowUp");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(0);
keyboardEvent("keydown", "ArrowUp");
expect(readInputSnapshot()).toMatchObject({ moveForward: 1, lookY: 0 });
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("casts remapped bar chords once and gives ground confirmation its context", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc["bar:4:11"] = "Ctrl+Shift+KeyQ";
preferences.pc["ground-confirm"] = "Ctrl+Shift+KeyQ";
next.getBindings = (device) => preferences[device]; next.desktopActionBarsActive = () => true; next.castDesktopSlot = vi.fn();
const dispose = installInput(next);
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true, repeat: true });
expect(next.castDesktopSlot).toHaveBeenCalledExactlyOnceWith(4, 11);
(next.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
expect(next.confirmGroundTarget).toHaveBeenCalledTimes(1);
expect(next.castDesktopSlot).toHaveBeenCalledTimes(1);
dispose();
});
it("remaps controller axes and buttons with analog movement preserved", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-forward", "Axis3-");
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-right", "Button3");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
setExternalControllerMovement({ moveX: 0, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot().moveForward).toBe(0);
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: -.7 });
expect(readInputSnapshot().moveForward).toBeGreaterThan(.3);
expect(readInputSnapshot().moveForward).toBeLessThan(1);
expect(readInputSnapshot().lookY).toBe(0);
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 });
emitControllerToken({ token: "Button3", repeat: false, pressed: true });
expect(readInputSnapshot().moveX).toBe(1);
expect(next.castActionBinding).not.toHaveBeenCalled();
emitControllerToken({ token: "Button3", repeat: false, pressed: false });
expect(readInputSnapshot().moveX).toBe(0);
dispose();
});
it("uses customized controller combinations before their base button", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.controller = replaceInputBinding(preferences.controller, "controller", "jump", "Button8&Button0");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
emitControllerToken({ token: "Button8", repeat: false, pressed: true });
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
expect(consumeJumpRequest()).not.toBeNull();
expect(next.castActionBinding).not.toHaveBeenCalled();
expect(next.lootActiveBoss).not.toHaveBeenCalled();
dispose();
});
it("blocks keyboard actions, controller actions and movement throughout capture", () => {
stubInputEventTargets();
const next = inputActionSpies(); const dispose = installInput(next);
setCapturingInput(true);
const captured = vi.fn(() => true); const unsubscribe = interceptControllerInput(captured);
keyboardEvent("keydown", "KeyW"); keyboardEvent("keydown", "KeyI"); keyboardEvent("keydown", "Digit1");
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
emitControllerToken({ token: "Button9", repeat: false, pressed: true });
setExternalControllerMovement({ moveX: 1, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 });
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.toggleInventory).not.toHaveBeenCalled(); expect(next.togglePause).not.toHaveBeenCalled();
expect(captured).toHaveBeenCalledTimes(2);
unsubscribe(); dispose();
});
});
describe("gameplay input bindings", () => {
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
nextActions.handleCustomKey = vi.fn(() => true);
const dispose = installInput(nextActions);
for (const code of ["KeyW", "KeyI", "Digit1"]) {
const event = new Event("keydown", { cancelable: true });
Object.defineProperties(event, { code: { value: code }, target: { value: null } });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
}
expect(nextActions.toggleInventory).not.toHaveBeenCalled();
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
expect(nextActions.handleCustomKey).toHaveBeenCalledTimes(3);
dispose();
});
it("does not fire the old numbered bindings when desktop bars are enabled", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
nextActions.desktopActionBarsActive = () => true;
const dispose = installInput(nextActions);
const event = new Event("keydown", { cancelable: true });
Object.defineProperties(event, { code: { value: "Digit1" }, target: { value: null } });
window.dispatchEvent(event);
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
dispose();
});
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", () => {
const selection = { removeAllRanges: vi.fn() };
stubInputEventTargets(selection);
const nextActions = inputActionSpies();
const dispose = installInput(nextActions);
beginMouseLookDrag(100, 80);
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
const move = new Event("pointermove") as PointerEvent;
Object.defineProperties(move, {
clientX: { value: 114 },
clientY: { value: 73 },
pointerType: { value: "mouse" },
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("prevents page selection only while right-mouse camera drag is active", () => {
stubInputEventTargets();
const dispose = installInput(inputActionSpies());
const idleSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(idleSelection);
expect(idleSelection.defaultPrevented).toBe(false);
beginMouseLookDrag(20, 30);
const cameraSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(cameraSelection);
expect(cameraSelection.defaultPrevented).toBe(true);
endMouseLookDrag();
const releasedSelection = new Event("selectstart", { cancelable: true });
document.dispatchEvent(releasedSelection);
expect(releasedSelection.defaultPrevented).toBe(false);
dispose();
});
it("can clear an existing browser selection without a camera drag", () => {
const selection = { removeAllRanges: vi.fn() };
stubInputEventTargets(selection);
clearBrowserSelection();
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
});
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]);
});
});