// @vitest-environment jsdom // // The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render // the row as a NEW button; without a refocus the keyboard user was dropped on and lost their // place (measured in Chrome). Everything around the rail that is // not the rename path is stubbed. import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; }); vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ search: "", selectedId: 1 }), usePlaylistsActions: () => ({ setSelectedId: vi.fn() }), })); vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) })); vi.mock("./comfortPin", () => ({ useComfortPin: () => ({ item: () => [] }), pinPlaylist: (id: number) => id, })); // The real usePlaylistActions builds the menu (the rename row is its first); only the dialogs are // stubbed, and the write is watched at the API. vi.mock("./confirmContext", () => ({ useChoice: () => async () => null, useConfirm: () => async () => false, })); const rename = vi.fn(async () => ({}) as never); // The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly. vi.mock("./FilterRail", () => ({ default: ({ items }: { items: { id: string; body: ReactNode }[] }) => ( <> {items.map((i) => (
{i.body}
))} ), })); import PlaylistsRail from "./PlaylistsRail"; import { api, type Playlist } from "../lib/api"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; Element.prototype.scrollIntoView ??= () => {}; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); rename.mockClear(); }); const alpha = { id: 1, name: "alpha", kind: "custom", source: "local", item_count: 3, total_duration_seconds: 0, cover_thumbnail: null, dirty: false, } as unknown as Playlist; async function openRename() { vi.spyOn(api, "playlists").mockResolvedValue([alpha]); vi.spyOn(api, "renamePlaylist").mockImplementation(rename); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( {}} collapsed={false} onToggleCollapse={() => {}} /> , ); const row = (await screen.findByText("alpha")).closest("button")!; act(() => row.focus()); fireEvent.keyDown(row, { key: "F10", shiftKey: true }); fireEvent.click(await screen.findByText("playlists.rename")); // The menu runs its action a task after it closes (see ui/ActionMenu). const field = await screen.findByRole("textbox", { name: "playlists.rename" }); await vi.waitFor(() => expect(document.activeElement).toBe(field)); return field as HTMLInputElement; } const rowButton = () => screen.getByText("alpha").closest("button"); describe("PlaylistsRail · rename from the keyboard", () => { it("Escape cancels and puts the focus back on the row", async () => { const field = await openRename(); fireEvent.change(field, { target: { value: "alpha2" } }); // Cancelled: the closing key must not go on to press the row the focus lands on. expect(fireEvent.keyDown(field, { key: "Escape" })).toBe(false); await vi.waitFor(() => expect(document.activeElement).toBe(rowButton())); expect(rename).not.toHaveBeenCalled(); }); it("Enter commits and puts the focus back on the row", async () => { const field = await openRename(); fireEvent.change(field, { target: { value: "beta" } }); expect(fireEvent.keyDown(field, { key: "Enter" })).toBe(false); expect(rename).toHaveBeenCalledWith(1, "beta"); await vi.waitFor(() => expect(document.activeElement).toBe(rowButton())); }); }); // The row's "…" opens the same menu as its right-click — the list's one action list. describe("PlaylistsRail · the row's '…'", () => { it("opens the playlist's menu under the row, and says so", async () => { vi.spyOn(api, "playlists").mockResolvedValue([alpha]); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( {}} collapsed={false} onToggleCollapse={() => {}} /> , ); await screen.findByText("alpha"); const more = screen.getByRole("button", { name: "playlists.railMenu" }); expect(more.getAttribute("aria-expanded")).toBe("false"); fireEvent.click(more); await vi.waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([ "playlists.rename", "common.copy.name", "playlists.exportToYoutube", "playlists.delete", ]), ); expect( screen.getByRole("button", { name: "playlists.railMenu" }).getAttribute("aria-expanded"), ).toBe("true"); // It is not inside the row's own button: a button cannot hold a button. expect(more.closest("button")).toBe(more); expect(rowButton()?.contains(more)).toBe(false); }); });