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); }); });