Initial Commit
This commit is contained in:
331
packages/gaming/games/controller/src/keyboard/keyboard.test.ts
Normal file
331
packages/gaming/games/controller/src/keyboard/keyboard.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user