// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import en from "../i18n/locales/en/keys.json"; import hu from "../i18n/locales/hu/keys.json"; import { SHORTCUTS, dispatch, formatChord, helpLines, isActivationTarget, isTypingTarget, matches, } from "./keymap"; import { markEscapeHandled } from "./layerStack"; /** A keydown as it arrives from a real focus target (jsdom only fills `e.target` on dispatch). */ function press(init: KeyboardEventInit, on?: HTMLElement): KeyboardEvent { const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); (on ?? document.body).dispatchEvent(e); return e; } describe("matches", () => { it("matches a plain letter case-insensitively", () => { expect(matches(press({ key: "a" }), "a")).toBe(true); expect(matches(press({ key: "A" }), "a")).toBe(true); }); // The bug this table was written to kill: `case "a"` fired on Ctrl+A, so select-all cycled the // Plex audio track. A chord without `Mod` must REJECT a modified key, not ignore the modifier. it("rejects a modified key for an unmodified chord", () => { expect(matches(press({ key: "a", ctrlKey: true }), "a")).toBe(false); expect(matches(press({ key: "a", metaKey: true }), "a")).toBe(false); expect(matches(press({ key: "ArrowLeft", shiftKey: true }), "ArrowLeft")).toBe(false); }); // AltGr is a constant-use key on this user's HU layout (@, [, ], {, }, \ all sit behind it), and on // Windows it arrives as Ctrl+Alt. Typing one of those must never fire a bare-key row or a Mod row. it("rejects an Alt- or AltGr-modified key, even when its Ctrl half would match Mod", () => { expect(matches(press({ key: "a", altKey: true }), "a")).toBe(false); expect(matches(press({ key: "k", ctrlKey: true, altKey: true }), "Mod+k")).toBe(false); }); // …but a symbol the layout can only type through AltGr IS that symbol: on the HU layout `[` is // AltGr+F (Ctrl+Alt on Windows), and a bare `[` row must answer it, or the clip marks are dead keys. it("matches a bare-symbol row typed through AltGr, never a letter row or a Mod row", () => { const altGr = (key: string) => press({ key, ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit); expect(matches(altGr("["), "[")).toBe(true); expect(matches(altGr("]"), "]")).toBe(true); expect(matches(altGr("a"), "a")).toBe(false); expect(matches(altGr("k"), "Mod+k")).toBe(false); // A real Ctrl+Alt chord (no AltGr) on a symbol is still not the bare symbol. expect(matches(press({ key: "[", ctrlKey: true, altKey: true }), "[")).toBe(false); }); it("treats Mod as Ctrl or Cmd", () => { expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true); expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true); expect(matches(press({ key: "k" }), "Mod+k")).toBe(false); }); it("distinguishes Shift chords from their bare key", () => { expect(matches(press({ key: "ArrowLeft", shiftKey: true }), "Shift+ArrowLeft")).toBe(true); expect(matches(press({ key: "ArrowLeft" }), "Shift+ArrowLeft")).toBe(false); expect(matches(press({ key: "z", ctrlKey: true, shiftKey: true }), "Mod+Shift+z")).toBe(true); expect(matches(press({ key: "z", ctrlKey: true }), "Mod+Shift+z")).toBe(false); }); it("accepts Space by key or by code", () => { expect(matches(press({ key: " " }), "Space")).toBe(true); expect(matches(press({ key: "Unidentified", code: "Space" }), "Space")).toBe(true); }); }); describe("isTypingTarget", () => { it("covers every field kind, not just input/textarea", () => { for (const tag of ["input", "textarea", "select"] as const) { const el = document.createElement(tag); document.body.append(el); expect(isTypingTarget(press({ key: "a" }, el))).toBe(true); el.remove(); } const div = document.createElement("div"); div.contentEditable = "true"; // jsdom does not implement the contentEditable→isContentEditable reflection. Object.defineProperty(div, "isContentEditable", { value: true }); document.body.append(div); expect(isTypingTarget(press({ key: "a" }, div))).toBe(true); div.remove(); }); it("is false on ordinary content", () => { expect(isTypingTarget(press({ key: "a" }))).toBe(false); }); }); describe("isActivationTarget", () => { it("is true for the controls Space activates natively", () => { const btn = document.createElement("button"); document.body.append(btn); expect(isActivationTarget(press({ key: " " }, btn))).toBe(true); btn.remove(); expect(isActivationTarget(press({ key: " " }))).toBe(false); }); }); describe("dispatch", () => { const noop = () => {}; const plexHandlers = (calls: string[]) => ({ playPause: () => calls.push("playPause"), fullscreen: () => calls.push("fullscreen"), seekBack: () => calls.push("seekBack"), seekFwd: () => calls.push("seekFwd"), fineBack: () => calls.push("fineBack"), fineFwd: () => calls.push("fineFwd"), prevEpisode: () => calls.push("prevEpisode"), nextEpisode: () => calls.push("nextEpisode"), volumeUp: noop, volumeDown: noop, mute: noop, audio: () => calls.push("audio"), subtitle: noop, info: noop, stats: noop, clip: () => calls.push("clip"), markIn: () => calls.push("markIn"), markOut: () => calls.push("markOut"), back: () => calls.push("back"), escape: () => calls.push("escape"), }) as const; it("routes the clip marks, typed plain or through AltGr", () => { const calls: string[] = []; const h = plexHandlers(calls); dispatch(press({ key: "c" }), "plexPlayer", h); dispatch(press({ key: "[" }), "plexPlayer", h); const altGr = { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit; dispatch(press({ key: "]", ...altGr }), "plexPlayer", h); expect(calls).toEqual(["clip", "markIn", "markOut"]); }); it("routes each arrow variant to its own id", () => { const calls: string[] = []; const h = plexHandlers(calls); dispatch(press({ key: "ArrowLeft" }), "plexPlayer", h); dispatch(press({ key: "ArrowLeft", ctrlKey: true }), "plexPlayer", h); dispatch(press({ key: "ArrowLeft", shiftKey: true }), "plexPlayer", h); expect(calls).toEqual(["seekBack", "fineBack", "prevEpisode"]); }); it("does not fire an ordinary shortcut while typing", () => { const calls: string[] = []; const input = document.createElement("input"); document.body.append(input); expect(dispatch(press({ key: "a" }, input), "plexPlayer", plexHandlers(calls))).toBe(false); expect(calls).toEqual([]); input.remove(); }); // Escape is the one opt-in: a player whose Escape died inside its own subtitle-offset field // would be a trap, so `allowInInput` lets it through where every other key is swallowed. it("still fires Escape from inside a field", () => { const calls: string[] = []; const input = document.createElement("input"); document.body.append(input); expect(dispatch(press({ key: "Escape" }, input), "plexPlayer", plexHandlers(calls))).toBe(true); expect(calls).toEqual(["escape"]); input.remove(); }); // An Escape a menu above already took is spent — a player or page acting on it too is the // double close the layer stack exists to prevent. it("ignores an Escape a layer already took", () => { const calls: string[] = []; const e = press({ key: "Escape" }); markEscapeHandled(e); expect(dispatch(e, "plexPlayer", plexHandlers(calls))).toBe(false); expect(calls).toEqual([]); }); // UAT: an arrow the player's settings tabs had already taken (they switch tab and prevent its // default) ALSO sought the video 10 s. A key a focused widget consumed is spent, for every scope. it("ignores a key a focused widget already handled", () => { const calls: string[] = []; const tab = document.createElement("button"); tab.setAttribute("role", "tab"); document.body.append(tab); tab.addEventListener("keydown", (e) => e.preventDefault()); expect(dispatch(press({ key: "ArrowRight" }, tab), "plexPlayer", plexHandlers(calls))).toBe( false, ); expect(calls).toEqual([]); tab.remove(); }); // A `handledLocally` row is documentation + a shared chord, matched on the focused element by its // own component (Shift+F10 belongs to whichever row has focus). `dispatch` must not claim it, and // the mapped handler type must not demand one — this test is the runtime half of that. it("does not route a locally-handled row", () => { const calls: string[] = []; // The handler map is the global scope's routable ids only — `contextMenu` is absent from it, // which is the compile-time half of the same claim; tsc rejects passing one. const handlers = { help: () => void calls.push("help"), palette: () => void calls.push("palette"), home: () => void calls.push("home"), }; expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false); expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false); expect(dispatch(press({ key: "h" }), "global", handlers)).toBe(true); expect(dispatch(press({ key: "k", ctrlKey: true }), "global", handlers)).toBe(true); expect(calls).toEqual(["help", "palette"]); }); it("ignores keys belonging to another scope", () => { const calls: string[] = []; // `m` (mute) is a plexPlayer key; the browse scope only owns Backspace. expect(dispatch(press({ key: "m" }), "plexBrowse", { back: () => calls.push("back") })).toBe( false, ); expect(calls).toEqual([]); }); }); describe("the table itself", () => { // `dispatch` looks a row's handler up by id, so a duplicate would silently shadow the second one. it("has no duplicate id within a scope", () => { const seen = new Set(); for (const s of SHORTCUTS) { const key = `${s.scope}:${s.id}`; expect(seen.has(key), `duplicate ${key}`).toBe(false); seen.add(key); } }); // The keymap's contract: every shortcut in the app is listed in one place AND named in // both languages. A row whose label is missing would render as a raw i18n key in the help sheet. it("names every shortcut in both languages", () => { for (const s of SHORTCUTS) { const [ns, key] = s.labelKey.split("."); expect(ns, `${s.id} must live in the keys namespace`).toBe("keys"); expect(en, `en is missing ${s.labelKey}`).toHaveProperty(key!); expect(hu, `hu is missing ${s.labelKey}`).toHaveProperty(key!); } }); }); describe("helpLines", () => { it("groups rows that share a description into one line", () => { const back = helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.back"); expect(back?.chords).toEqual(["Backspace", "Escape"]); }); it("carries the episode-only condition through so the sheet can filter it", () => { const episode = helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.episode"); expect(episode?.when).toBe("episode"); expect(helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.mute")?.when).toBeUndefined(); }); it("returns only the requested scopes", () => { expect(helpLines(["plexBrowse"]).map((l) => l.labelKey)).toEqual(["keys.browseBack"]); }); // A surface's own keys are what its reader came for; the app-wide ones are context underneath. it("orders scopes as asked, not as tabled", () => { const first = (scopes: Parameters[0]) => helpLines(scopes)[0]?.labelKey; expect(first(["plexBrowse", "global"])).toBe("keys.browseBack"); expect(first(["global", "plexBrowse"])).toBe("keys.palette"); }); }); describe("formatChord", () => { const words = { Space: "Szóköz", ContextMenu: "Menü" }; it("renders glyphs and upper-cases the letter", () => { expect(formatChord("ArrowLeft", words)).toBe("←"); expect(formatChord("Backspace", words)).toBe("⌫"); expect(formatChord("Shift+ArrowRight", words)).toBe("⇧ →"); expect(formatChord("k", words)).toBe("K"); }); // A key whose cap carries a word is named in the reader's language: the Hungarian sheet said "Space". it("names a word key in the caller's language", () => { expect(formatChord("Space", words)).toBe("Szóköz"); expect(formatChord("ContextMenu", words)).toBe("Menü"); expect(formatChord("Shift+F10", words)).toBe("⇧ F10"); }); });