// @vitest-environment jsdom // // Contract of the jump-to palette: filtering (word-wise and accent-insensitive), arrow/Enter // selection over the FLAT result list, and closing before the command runs. Items are plain props // (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough. import { useState } from "react"; import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import CommandPalette, { type CommandGroup } from "./CommandPalette"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); beforeAll(() => { // jsdom has no layout, so scrollIntoView is missing on the elements the active-row effect touches. Element.prototype.scrollIntoView ??= () => {}; }); afterEach(cleanup); // `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before // running the command (see closeThenNavigate), so the command lands a tick later — hence the waits. const ran: string[] = []; const groups = (): CommandGroup[] => [ { id: "modules", label: "Modules", items: [ { id: "page:feed", label: "Feed", run: () => ran.push("page:feed") }, { id: "page:plex", label: "Plex", keywords: "media library", run: () => ran.push("plex") }, ], }, { id: "channels", label: "Channels", items: [ { id: "ch:1", label: "TheVR Podcast", hint: "@thevr", run: () => ran.push("ch:1") }, { id: "ch:2", label: "Pénzügyi Fitnesz", run: () => ran.push("ch:2") }, ], }, ]; // The palette must REALLY close, not just report it: `choose` waits for the Back-to-close entry // that only a genuine unmount reclaims, so a harness that keeps it mounted would never run anything. function Harness() { const [isOpen, setIsOpen] = useState(true); return ( { ran.push("closed"); setIsOpen(false); }} groups={groups()} /> ); } const open = () => { ran.length = 0; return render(); }; const rows = () => [...document.querySelectorAll("[data-cmd-id]")].map((el) => el.getAttribute("data-cmd-id")); /** The virtually-selected row, read the way a screen reader would. */ const selected = () => document.querySelector("[aria-selected='true']")?.getAttribute("data-cmd-id") ?? null; const field = () => screen.getByRole("combobox", { name: "palette.placeholder" }); const type = (value: string) => fireEvent.change(field(), { target: { value } }); const press = (key: string) => fireEvent.keyDown(field(), { key }); describe("CommandPalette", () => { it("renders nothing while closed", () => { render( {}} groups={groups()} />); expect(screen.queryByText("palette.title")).toBeNull(); }); it("lists every group until you type", () => { open(); expect(rows()).toEqual(["page:feed", "page:plex", "ch:1", "ch:2"]); }); // You half-remember a name; you should not have to remember the word order or the spacing. it("matches on each word, in any order", () => { open(); type("pod thevr"); expect(rows()).toEqual(["ch:1"]); }); // Bilingual app, hurried typing: nobody reaches for the accent keys. it("matches without accents", () => { open(); type("penzugyi"); expect(rows()).toEqual(["ch:2"]); }); it("matches hidden keywords and the group name", () => { open(); type("media"); expect(rows()).toEqual(["page:plex"]); type("channels"); expect(rows()).toEqual(["ch:1", "ch:2"]); }); it("says so when nothing matches", () => { open(); type("zzzz"); expect(rows()).toEqual([]); expect(screen.getByText("palette.empty")).toBeTruthy(); }); it("selects the first row and points the input at it", () => { open(); expect(selected()).toBe("page:feed"); expect( screen .getByRole("combobox", { name: "palette.placeholder" }) .getAttribute("aria-activedescendant"), ).toContain("page:feed"); }); // The arrows walk the flat result list, not the sections — crossing a group heading is invisible. it("walks results with the arrows, across group boundaries", () => { open(); press("ArrowDown"); press("ArrowDown"); expect(selected()).toBe("ch:1"); }); it("wraps in both directions", () => { open(); press("ArrowUp"); expect(selected()).toBe("ch:2"); press("ArrowDown"); expect(selected()).toBe("page:feed"); }); it("keeps the selection valid when the filter shrinks the list", () => { open(); press("ArrowDown"); press("ArrowDown"); press("ArrowDown"); // ch:2 type("thevr"); // one row left; the old index is out of range expect(selected()).toBe("ch:1"); }); // No test in THIS file goes through the full close→reclaim→run cycle: jsdom gives a file one // session history and history.ts's overlay bookkeeping is module-global, so the cycle only holds // in isolation — it lives in CommandPalette.ordering.dom.test.tsx. Which command Enter picks is // covered by the selection assertions above; this checks it chooses at all. it("chooses the selected row on Enter", () => { open(); press("ArrowDown"); press("Enter"); expect(ran).toEqual(["closed"]); }); });