Files
suaveplan/packages/gaming/games/controller/src/keyboard/keyboard.test.ts
2026-05-27 23:47:53 +01:00

332 lines
10 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import { DEFAULT_KEYBOARD_KEY_MAP, KeyboardController } from "./keyboard.js";
import type { EventTargetLike, KeyboardEventLike } from "./keyboard.types.js";
interface MockTarget extends EventTargetLike {
readonly down: Set<(e: KeyboardEventLike) => void>;
readonly up: Set<(e: KeyboardEventLike) => void>;
dispatchDown(event: KeyboardEventLike): void;
dispatchUp(event: KeyboardEventLike): void;
}
function createTarget(): MockTarget {
const down = new Set<(e: KeyboardEventLike) => void>();
const up = new Set<(e: KeyboardEventLike) => void>();
return {
down,
up,
addEventListener(type, listener) {
if (type === "keydown") down.add(listener);
else up.add(listener);
},
removeEventListener(type, listener) {
if (type === "keydown") down.delete(listener);
else up.delete(listener);
},
dispatchDown(event) {
for (const cb of down) cb(event);
},
dispatchUp(event) {
for (const cb of up) cb(event);
},
};
}
describe("KeyboardController — construction", () => {
it("attaches automatically by default", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
expect(target.down.size).toBe(1);
expect(target.up.size).toBe(1);
kb.destroy();
expect(target.down.size).toBe(0);
expect(target.up.size).toBe(0);
});
it("respects autoAttach: false", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target, autoAttach: false });
expect(target.down.size).toBe(0);
kb.attach();
expect(target.down.size).toBe(1);
kb.destroy();
});
it("throws INVALID_OPTIONS when target lacks addEventListener", {
timeout: 5000,
}, () => {
expect(
() =>
new KeyboardController({
target: { foo: 1 } as unknown as EventTargetLike,
})
).toThrowError(ControllerError);
});
it("throws INVALID_OPTIONS for pollRate out of range", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new KeyboardController({ pollRate: 0 });
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_OPTIONS
);
});
it("throws INVALID_KEY_MAP for unknown input target", {
timeout: 5000,
}, () => {
const target = createTarget();
const err = (() => {
try {
return new KeyboardController({
target,
keyMap: { KeyZ: "notAnInput" as never },
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_KEY_MAP
);
expect((err as ControllerError).context).toEqual({
entry: ["KeyZ", "notAnInput"],
});
});
it("throws KEYBOARD_TARGET_MISSING when no target resolvable", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new KeyboardController({ autoAttach: true });
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.KEYBOARD_TARGET_MISSING
);
});
it("resolves globalThis.window when present", { timeout: 5000 }, () => {
const target = createTarget();
const g = globalThis as { window?: unknown };
const prior = g.window;
g.window = target;
try {
const kb = new KeyboardController();
expect(target.down.size).toBe(1);
kb.destroy();
} finally {
if (prior === undefined) delete g.window;
else g.window = prior;
}
});
it("exposes the resolved key map and default map", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
expect(kb.getKeyMap().KeyW).toBe("up");
expect(DEFAULT_KEYBOARD_KEY_MAP.ArrowUp).toBe("up");
kb.destroy();
});
});
describe("KeyboardController — key events", () => {
it("maps keydown via event.code", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "KeyW" });
expect(kb.getValue("up")).toBe(255);
target.dispatchUp({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
kb.destroy();
});
it("falls back to event.key when code is unknown", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "Unknown", key: "w" });
expect(kb.getValue("up")).toBe(255);
kb.destroy();
});
it("ignores unmapped keys and missing identifiers", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "NotAKey" });
target.dispatchDown({});
expect(kb.getValue("up")).toBe(0);
target.dispatchUp({ code: "NotAKey" });
kb.destroy();
});
it("ignores repeated keydown events", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
let changes = 0;
kb.onInput("up", () => {
changes += 1;
});
target.dispatchDown({ code: "KeyW" });
target.dispatchDown({ code: "KeyW" });
target.dispatchDown({ code: "KeyW", repeat: true });
expect(changes).toBe(1);
kb.destroy();
});
it("ignores keyup without prior keydown", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchUp({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
kb.destroy();
});
it("calls preventDefault when configured", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target, preventDefault: true });
let prevented = 0;
target.dispatchDown({
code: "KeyW",
preventDefault: () => {
prevented += 1;
},
});
target.dispatchUp({
code: "KeyW",
preventDefault: () => {
prevented += 1;
},
});
expect(prevented).toBe(2);
kb.destroy();
});
it("does not call preventDefault when not configured", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
let prevented = 0;
target.dispatchDown({
code: "KeyW",
preventDefault: () => {
prevented += 1;
},
});
// missing preventDefault on event should not crash
target.dispatchUp({ code: "KeyW" });
expect(prevented).toBe(0);
kb.destroy();
});
it("getPressedKeys reflects current state", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "KeyW" });
target.dispatchDown({ code: "KeyA" });
expect(kb.getPressedKeys().sort()).toEqual(["KeyA", "KeyW"]);
target.dispatchUp({ code: "KeyW" });
expect(kb.getPressedKeys()).toEqual(["KeyA"]);
kb.destroy();
});
it("no-ops after destroy", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
kb.destroy();
target.dispatchDown({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
// second destroy is idempotent
kb.destroy();
});
it("attach is idempotent and noop after destroy", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
kb.attach();
expect(target.down.size).toBe(1);
kb.destroy();
kb.attach();
expect(target.down.size).toBe(0);
});
it("detach is idempotent and tolerates missing target", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target, autoAttach: false });
kb.detach();
expect(target.down.size).toBe(0);
kb.attach();
kb.detach();
kb.detach();
expect(target.down.size).toBe(0);
kb.destroy();
});
it("accepts injected clock, idGenerator, pollRate and strict", {
timeout: 5000,
}, () => {
const target = createTarget();
let t = 0;
const kb = new KeyboardController({
target,
clock: () => t,
idGenerator: () => "kb-1",
pollRate: 30,
strict: true,
});
expect(kb.id).toBe("kb-1");
target.dispatchDown({ code: "KeyW" });
t += 100;
kb.update();
kb.destroy();
});
it("uses event.key when event.code is undefined", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ key: "w" });
expect(kb.getValue("up")).toBe(255);
target.dispatchUp({ key: "w" });
expect(kb.getValue("up")).toBe(0);
kb.destroy();
});
it("ignores key events delivered after destroy via retained listener", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
const listener = [...target.down][0];
if (listener === undefined) {
throw new Error("expected one keydown listener");
}
kb.destroy();
listener({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
});
});