// @vitest-environment jsdom // // The palette's DOMAIN half: which rows exist and what they do. The list/filter/keyboard shell // (`ui/CommandPalette`) has its own tests, so it is stubbed here to hand back the groups it was given — // what this file pins is the content: modules gated by the account, hidden channels left out, and the // two `run`s whose ORDER matters. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, cleanup, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { CommandGroup } from "./ui/CommandPalette"; vi.mock("react-i18next", async (importOriginal) => ({ // The rest of the module too: the recorder's actions reach `i18n/index`, which needs it. ...(await importOriginal()), useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), })); let groups: CommandGroup[] = []; vi.mock("./ui/CommandPalette", () => ({ default: (props: { groups: CommandGroup[] }) => { groups = props.groups; return null; }, })); const channels = vi.fn(); const playlists = vi.fn(); vi.mock("../lib/api", () => ({ api: { channels: (...a: unknown[]) => channels(...a), playlists: (...a: unknown[]) => playlists(...a), }, })); let me: Record | undefined; vi.mock("../lib/useMe", () => ({ useMe: () => me })); vi.mock("../lib/keymap", () => ({ usePaletteOpen: () => true, closePalette: vi.fn(), toggleKeyboardHelp: vi.fn(), })); const calls: string[] = []; const setPage = vi.fn((page: string, then?: () => void) => { calls.push(`setPage:${page}`); then?.(); }); vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage, openChannel: vi.fn() }), })); vi.mock("./playlistsContext", () => ({ usePlaylistsActions: () => ({ setSelectedId: (id: number) => calls.push(`select:${id}`), }), })); vi.mock("./prefsContext", () => ({ usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }), })); vi.mock("./savedViewsContext", () => ({ useSavedViews: () => ({ views: [], apply: vi.fn() }), })); const requestSurprise = vi.fn(); vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() })); import AppCommandPalette from "./AppCommandPalette"; const user = (over: Record = {}) => ({ id: 1, role: "user", plex_enabled: false, ...over, }); function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); } const group = (id: string) => groups.find((g) => g.id === id); const ids = (id: string) => group(id)?.items.map((i) => i.id) ?? []; beforeEach(() => { groups = []; calls.length = 0; me = user(); setPage.mockClear(); requestSurprise.mockClear(); channels.mockReset().mockResolvedValue([]); playlists.mockReset().mockResolvedValue([]); }); afterEach(cleanup); describe("AppCommandPalette", () => { it("offers only the modules this account can open, in nav order, and Settings", () => { mount(); expect(ids("modules")).toContain("page:feed"); expect(ids("modules")).not.toContain("page:plex"); expect(ids("modules")).not.toContain("page:config"); // admin-only expect(ids("modules").slice(-1)).toEqual(["page:settings"]); cleanup(); me = user({ role: "admin", plex_enabled: true }); mount(); expect(ids("modules")).toContain("page:plex"); expect(ids("modules")).toContain("page:config"); }); it("leaves out the channels the user has hidden", async () => { channels.mockResolvedValue([ { id: "UC1", title: "Shown", handle: "@shown", hidden: false }, { id: "UC2", title: "Hidden", handle: "@hidden", hidden: true }, ]); mount(); await waitFor(() => expect(group("channels")).toBeTruthy()); expect(ids("channels")).toEqual(["channel:UC1"]); }); it("selects the playlist BEFORE switching to the page, so it opens on that list", async () => { playlists.mockResolvedValue([{ id: 7, name: "Later", item_count: 3 }]); mount(); await waitFor(() => expect(group("playlists")).toBeTruthy()); group("playlists")!.items[0]!.run(); expect(calls).toEqual(["select:7", "setPage:playlists"]); }); it("asks for the surprise once the navigation to Home has happened, not before", () => { mount(); const surprise = group("actions")!.items.find((i) => i.id === "action:surprise")!; // Over an open player the push lands a moment after the call, and the token must name the // entry that push makes (lib/homeIntent) — so it rides `setPage`'s `then`, not the next line. setPage.mockImplementationOnce((page: string) => void calls.push(`setPage:${page}`)); surprise.run(); expect(requestSurprise).not.toHaveBeenCalled(); const then = setPage.mock.lastCall?.[1]; expect(setPage.mock.lastCall?.[0]).toBe("home"); then?.(); expect(requestSurprise).toHaveBeenCalledTimes(1); }); it("offers the debug recorder by its state", () => { const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:")); mount(); expect(recorder()).toEqual(["action:recorder:start"]); cleanup(); const until = new Date(Date.now() + 60_000).toISOString(); me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } }); mount(); expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]); }); });