feat(e10): put every keyboard shortcut in one table
Six surfaces each owned a private keydown listener with its own key literals and its own idea of "am I typing?", and they had drifted: PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A (select all) cycled the audio track, and its typing guard missed <select> and contentEditable where the other two surfaces covered both. lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own listener and closures - PlexPlayer's refs are not worth re-plumbing - but the chords, the modifier rules and the typing guard live in one place, and dispatch() binds a scope's rows to handlers through a mapped type, so a row without a handler (or a handler without a row) is a tsc error rather than a comment someone forgets. Matching is now exact on modifiers, which is what kills the Ctrl+A bug and lets Shift+arrow be its own row beside the bare arrow. Escape opts out of the typing guard (allowInInput) so it stays the way out of a player's own subtitle-offset field. The player's shortcut sheet renders helpLines() instead of a hand-copied array beside the handler - the two used to be edited separately, which is how a sheet ends up documenting a key that no longer exists. Labels move to the new `keys` i18n namespace (EN + HU), and a test asserts every row is named in both.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// @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";
|
||||
|
||||
/** 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);
|
||||
});
|
||||
|
||||
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,
|
||||
help: noop,
|
||||
info: noop,
|
||||
stats: noop,
|
||||
back: () => calls.push("back"),
|
||||
escape: () => calls.push("escape"),
|
||||
}) as const;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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<string>();
|
||||
for (const s of SHORTCUTS) {
|
||||
const key = `${s.scope}:${s.id}`;
|
||||
expect(seen.has(key), `duplicate ${key}`).toBe(false);
|
||||
seen.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// The done-condition of E10 S3: 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<typeof helpLines>[0]) => helpLines(scopes)[0]?.labelKey;
|
||||
expect(first(["plexBrowse", "global"])).toBe("keys.browseBack");
|
||||
expect(first(["global", "plexBrowse"])).toBe("keys.contextMenu");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChord", () => {
|
||||
it("renders glyphs and upper-cases the letter", () => {
|
||||
expect(formatChord("ArrowLeft")).toBe("←");
|
||||
expect(formatChord("Backspace")).toBe("⌫");
|
||||
expect(formatChord("Shift+ArrowRight")).toBe("⇧ →");
|
||||
expect(formatChord("k")).toBe("K");
|
||||
expect(formatChord("Space")).toBe("Space");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user