// @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, waitFor } 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 closeTopOverlayThenRun), 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"); }); // E28 F35: the active row wore `bg-card`, ~1.07:1 on the palette's glass. It is the menus' row now. it("marks the active row with the menus' roving highlight, and only that row", () => { open(); const rows = screen.getAllByRole("option"); const active = rows.filter((r) => r.hasAttribute("data-active")); expect(active).toHaveLength(1); expect(active[0]!.getAttribute("aria-selected")).toBe("true"); for (const r of rows) { expect(r.className).toContain("data-active:bg-accent/35"); expect(r.className).not.toMatch(/(^|\s)bg-card(\s|$)/); } }); // 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"]); }); }); // The owner's case (E11 S3): typing "theme" put a channel matched only through its handle above every // "Theme: …" row, and Enter opened the channel. describe("CommandPalette · ranking", () => { const noop = () => {}; const ranked = (): CommandGroup[] => [ { id: "channels", label: "Channels", items: [ { id: "ch:melt", label: "The Meltzone Podcast", hint: "@themeltzone", run: noop }, { id: "ch:talk", label: "Talk about themes", run: noop }, ], }, { id: "actions", label: "Actions", items: [ { id: "theme:midnight", label: "Theme: Midnight", run: noop }, { id: "theme:forest", label: "Theme: Forest", run: noop }, ], }, ]; it("ranks rows inside each group, keeps the group order, and starts on the best match", () => { render(); type("theme"); // label elsewhere (talk) > handle only (melt); label start (midnight = forest, group order kept) expect(rows()).toEqual(["ch:talk", "ch:melt", "theme:midnight", "theme:forest"]); // The highlight is what Enter takes (same `activeIndex`); the full close→run cycle cannot run in // this file — see the note on "chooses the selected row on Enter". expect(selected()).toBe("theme:midnight"); }); it("ranks by the worst word first, so every word in the label beats a label start plus a keyword", () => { const groups: CommandGroup[] = [ { id: "channels", label: "Channels", items: [ { id: "weekly", label: "Podcast Weekly", keywords: "melt", run: noop }, { id: "melt", label: "The Meltzone Podcast", run: noop }, ], }, ]; render(); type("podcast melt"); expect(rows()).toEqual(["melt", "weekly"]); }); // The channel and playlist lists load after the palette opens — often while you are already typing. it("follows the best match as a list lands, but keeps a row the arrows picked", () => { const early: CommandGroup[] = [ranked()[0]!]; const r = render(); type("theme"); expect(selected()).toBe("ch:talk"); r.rerender(); expect(selected()).toBe("theme:midnight"); press("ArrowDown"); // theme:forest, picked r.rerender(); expect(selected()).toBe("theme:forest"); type("them"); // a new query goes back to following the best match expect(selected()).toBe("theme:midnight"); }); }); describe("CommandPalette · accessibility", () => { it("names each group by its heading", () => { open(); const channels = screen.getByRole("group", { name: "Channels" }); expect( [...channels.querySelectorAll("[role='option']")].map((o) => o.getAttribute("data-cmd-id")), ).toEqual(["ch:1", "ch:2"]); expect(screen.getByRole("group", { name: "Modules" })).toBeTruthy(); }); it("is expanded only while there is something to choose, and says 'nothing' outside the listbox", () => { open(); expect(field().getAttribute("aria-expanded")).toBe("true"); type("zzzz"); expect(field().getAttribute("aria-expanded")).toBe("false"); const listbox = screen.getByRole("listbox", { hidden: true }); expect(listbox.hidden).toBe(true); // aria-controls still names a real element expect(document.getElementById(field().getAttribute("aria-controls")!)).toBe(listbox); expect(listbox.contains(screen.getByText("palette.empty"))).toBe(false); }); it("announces the result count politely, once the typing pauses", async () => { open(); const status = screen.getByRole("status"); expect(status.textContent).toBe(""); // nothing to announce on a blank field type("the"); expect(status.textContent).toBe(""); // not per keystroke await waitFor(() => expect(status.textContent).toBe("palette.results")); type("zzzz"); await waitFor(() => expect(status.textContent).toBe("palette.empty")); type(""); expect(status.textContent).toBe(""); }); }); // Owner request (E11): with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown // move by what fits in the list, and — unlike the arrows — stop at the ends instead of wrapping. describe("CommandPalette · paging", () => { const many = (): CommandGroup[] => [ { id: "rows", label: "Rows", items: Array.from({ length: 12 }, (_, i) => ({ id: `r${i}`, label: `Row ${i}`, run: () => {}, })), }, ]; const openMany = () => render( {}} groups={many()} />); const rect = (top: number, height: number) => ({ top, bottom: top + height, height, left: 0, right: 100, width: 100, x: 0, y: top, }) as DOMRect; it("pages by the rows in view minus one, and stops at both ends", () => { // The list shows 100px and the rows are 30px tall: 3 fit, so a page is 2 rows. const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( this: Element, ) { if (this.getAttribute("role") === "listbox") return rect(0, 100); const all = [...document.querySelectorAll('[role="option"]')]; return rect(all.indexOf(this) * 30, 30); }); // Restored in `finally`: a spy left behind by a failing assertion fails every later test too. try { openMany(); press("PageDown"); expect(selected()).toBe("r2"); press("PageDown"); expect(selected()).toBe("r4"); for (let i = 0; i < 10; i++) press("PageDown"); expect(selected()).toBe("r11"); // no wrap to the top press("PageUp"); expect(selected()).toBe("r9"); for (let i = 0; i < 10; i++) press("PageUp"); expect(selected()).toBe("r0"); } finally { spy.mockRestore(); } }); it("steps a fixed page when there is no layout to measure", () => { openMany(); press("PageDown"); expect(selected()).toBe("r10"); press("PageDown"); expect(selected()).toBe("r11"); }); it("leaves Home and End to the text field", () => { openMany(); press("PageDown"); expect(fireEvent.keyDown(field(), { key: "Home" })).toBe(true); // not preventDefault-ed expect(fireEvent.keyDown(field(), { key: "End" })).toBe(true); expect(selected()).toBe("r10"); }); });