Files
siftlode/frontend/src/lib/keymap.test.ts
T
peter 828dad015c feat(e24): reach the Home surface — the landing toggle, a palette command, Shift+W
S4's reachability half. Home already led the nav order; what was missing was every
way IN other than the rail.

- Settings → Appearance → Startup: "Start on Home", the documented escape hatch for
  the landing override. Deliberately still localStorage and not a server pref —
  `loadInitialPage` answers before /api/me lands, and a landing module decided after
  the first paint is a visible jump. Its two stored values are now ONE exported
  constant, so the writer and the boot-time reader cannot drift on a magic string.
- The command palette gets "Meglepetés" / "Surprise me": it navigates to Home and
  rerolls the hero on arrival. No second "Mit nézzek?" row beside it — the Home
  module row already matches that phrase through its descriptive title.
- A global Shift+W jumps to Home. A chord, not a bare `w`: the Plex player binds bare
  letters and `w` sits on `s`, so a blind reach for the subtitle key would have thrown
  a viewer out of the film.

The palette's request crosses a lazy module boundary, so it is a token that waits
(lib/homeIntent) rather than an event fired into an empty room. Browser-measured:
taken with a compare-and-clear, because StrictMode's double-invoked mount effect
otherwise rerolled twice and landed on the page two picks deep.

HU/EN parity kept; UAT on localdev in both languages, including the empty shelf.
2026-09-08 00:06:34 +02:00

224 lines
9.1 KiB
TypeScript

// @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,
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();
});
// 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<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.palette");
});
});
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");
});
});