// @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 "../components/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("../components/ui/CommandPalette", () => ({ default: (props: { groups: CommandGroup[] }) => { groups = props.groups; return null; }, })); const channels = vi.fn(); const playlists = vi.fn(); // Partial: the module registry the palette lists reaches the feed's store, which reads its defaults here. vi.mock("../lib/api", async (importOriginal) => ({ ...(await importOriginal()), 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(), // listNav, loaded under the palette, hands the sheet its list probe as it loads. setListInTopLayerProbe: vi.fn(), })); // Each module's saved views, and the page the palette opens over. const views = { feed: [] as { id: number; name: string }[], plex: [] as { id: number; name: string }[], }; const nav = { page: "feed" }; const calls: string[] = []; const setPage = vi.fn((page: string, then?: () => void) => { calls.push(`setPage:${page}`); then?.(); }); vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page: nav.page }), useNavigationActions: () => ({ setPage, openChannel: vi.fn() }), })); vi.mock("../modules/playlists/playlistsContext", () => ({ usePlaylistsActions: () => ({ setSelectedId: (id: number) => calls.push(`select:${id}`), }), })); vi.mock("../features/prefs/prefsContext", () => ({ usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }), })); vi.mock("../modules/feed/feedSavedViews", async (importOriginal) => ({ ...(await importOriginal>()), useFeedSavedViews: () => ({ views: views.feed, apply: vi.fn() }), })); vi.mock("../modules/plex/plexSavedViews", async (importOriginal) => ({ ...(await importOriginal>()), usePlexSavedViews: () => ({ views: views.plex, apply: vi.fn() }), })); const requestSurprise = vi.fn(); vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() })); const requestLinkBox = vi.fn(); vi.mock("../lib/downloadIntake", () => ({ requestLinkBox: () => requestLinkBox() })); 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([]); views.feed = []; views.plex = []; nav.page = "feed"; }); 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("offers Plex's saved views over the Plex page, and the feed's over any other", () => { views.feed = [{ id: 1, name: "feed view" }]; views.plex = [{ id: 2, name: "plex view" }]; nav.page = "plex"; mount(); expect(ids("views")).toEqual(["view:2"]); cleanup(); nav.page = "home"; mount(); expect(ids("views")).toEqual(["view:1"]); }); 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("Download from a link takes the reader to the Files link box, asked on that entry", () => { mount(); const item = group("actions")!.items.find((i) => i.id === "action:downloadLink")!; setPage.mockImplementationOnce(() => {}); item.run(); expect(setPage.mock.lastCall?.[0]).toBe("files"); expect(requestLinkBox).not.toHaveBeenCalled(); setPage.mock.lastCall?.[1]?.(); expect(requestLinkBox).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"]); }); });