332 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|