// @vitest-environment jsdom // // The `global` scope's LIVE behaviour, as opposed to `matches`/`dispatch` in isolation (keymap.test.ts // covers those against a handler map the test supplies itself, which cannot see the real handlers). // // Every assertion here pins a fix that E10 review round 2 proved was un-pinned: reverting the guard // each one covers left the whole suite green, which is how a fix round quietly becomes the next // round's source of regressions. import { useEffect } from "react"; import { describe, it, expect, afterEach, vi } from "vitest"; import { render, cleanup, act } from "@testing-library/react"; import { closeKeyboardHelp, closePalette, useGlobalKeymap, useKeyboardHelpOpen, usePaletteOpen, } from "./keymap"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); afterEach(() => { cleanup(); closeKeyboardHelp(); closePalette(); }); /** Renders the live stores so an assertion reads what a real consumer would render. */ function Host({ enabled = true, onState, onHome = () => {}, }: { enabled?: boolean; onState: (s: string) => void; onHome?: () => void; }) { useGlobalKeymap(enabled, (page) => page === "home" && onHome(), ["home"]); const help = useKeyboardHelpOpen(); const palette = usePaletteOpen(); useEffect(() => { onState(`${help ? "help" : ""}${palette ? "palette" : ""}` || "none"); }, [help, palette, onState]); return null; } const key = (init: KeyboardEventInit) => act(() => { window.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }), ); }); describe("useGlobalKeymap", () => { it("opens the cheat sheet on h and the palette on Mod+K", () => { let state = ""; render( (state = s)} />); key({ key: "h" }); expect(state).toBe("help"); key({ key: "h" }); expect(state).toBe("none"); key({ key: "k", ctrlKey: true }); expect(state).toBe("palette"); }); // Mod+K is written as a toggle, and the palette autofocuses its own search field — so without // `allowInInput` on the row the second press was eaten by the typing guard and Chrome took the // chord instead (round 1). The FIRST press must still be routed from a field, too. it("toggles the palette shut from inside a text field", () => { let state = ""; render( (state = s)} />); const field = document.createElement("input"); document.body.appendChild(field); field.focus(); act(() => { field.dispatchEvent( new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }), ); }); expect(state).toBe("palette"); act(() => { field.dispatchEvent( new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }), ); }); expect(state).toBe("none"); field.remove(); }); // `h` still belongs to whatever is being typed. it("leaves a plain letter to the text field it was typed into", () => { let state = ""; render( (state = s)} />); const field = document.createElement("input"); document.body.appendChild(field); field.focus(); act(() => { field.dispatchEvent( new KeyboardEvent("keydown", { key: "h", bubbles: true, cancelable: true }), ); }); expect(state).toBe("none"); field.remove(); }); // Each toggle mounts/unmounts an overlay that costs a real history entry, so ~30 of them a second // desyncs the overlay bookkeeping from the browser's actual stack. Holding a key is not a press. it("ignores auto-repeat", () => { let state = ""; render( (state = s)} />); key({ key: "h", repeat: true }); expect(state).toBe("none"); key({ key: "k", ctrlKey: true, repeat: true }); expect(state).toBe("none"); }); // E24. The chord is the point: the Plex player binds bare letters and `w` sits on top of `s`, so a // bare `w` would yank a viewer out of the film on a blind reach for the subtitle key. it("jumps Home on Shift+W and ignores a bare w", () => { const onHome = vi.fn(); render( {}} onHome={onHome} />); key({ key: "w" }); expect(onHome).not.toHaveBeenCalled(); key({ key: "w", shiftKey: true }); expect(onHome).toHaveBeenCalledTimes(1); }); it("leaves Shift+W to the text field it was typed into", () => { const onHome = vi.fn(); render( {}} onHome={onHome} />); const field = document.createElement("input"); document.body.appendChild(field); field.focus(); act(() => { field.dispatchEvent( new KeyboardEvent("keydown", { key: "w", shiftKey: true, bubbles: true, cancelable: true }), ); }); expect(onHome).not.toHaveBeenCalled(); field.remove(); }); // The handler is App's `() => setPage("home")` — a fresh closure every render. It is held in a ref // rather than in the effect's deps, so the listener is bound once; this pins that the ref is the // CURRENT one and not the closure captured at mount. it("calls the latest handler after a re-render, without rebinding", () => { const first = vi.fn(); const second = vi.fn(); const { rerender } = render( {}} onHome={first} />); rerender( {}} onHome={second} />); key({ key: "w", shiftKey: true }); expect(first).not.toHaveBeenCalled(); expect(second).toHaveBeenCalledTimes(1); }); // Bound above the branch that mounts the overlays, the keys were swallowed on the loading/Welcome // screens and the flipped store then sprang the overlay open by itself once the shell mounted. it("binds nothing, and clears a stale store, while disabled", () => { let state = ""; const { rerender } = render( (state = s)} />); key({ key: "h" }); expect(state).toBe("help"); rerender( (state = s)} />); expect(state).toBe("none"); // the stale `true` is cleared, not carried into the next mount key({ key: "h" }); expect(state).toBe("none"); // and the listener is gone }); });