Every object's action list ends its middle group with Copy name / Copy link (E28 C1, the rows a finger cannot select); the playlist's had neither.
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
// @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 <body> and lost their
|
|
// place (E10 review round 1, measured again in Chrome in E11 S2). 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<typeof import("react-i18next")>();
|
|
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) => (
|
|
<div key={i.id}>{i.body}</div>
|
|
))}
|
|
</>
|
|
),
|
|
}));
|
|
|
|
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(
|
|
<QueryClientProvider client={qc}>
|
|
<FloatingTree>
|
|
<PlaylistsRail
|
|
layout={{} as never}
|
|
setLayout={() => {}}
|
|
collapsed={false}
|
|
onToggleCollapse={() => {}}
|
|
/>
|
|
</FloatingTree>
|
|
</QueryClientProvider>,
|
|
);
|
|
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 (E28 audit).
|
|
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()));
|
|
});
|
|
});
|
|
|
|
// E28 C1: 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(
|
|
<QueryClientProvider client={qc}>
|
|
<FloatingTree>
|
|
<PlaylistsRail
|
|
layout={{} as never}
|
|
setLayout={() => {}}
|
|
collapsed={false}
|
|
onToggleCollapse={() => {}}
|
|
/>
|
|
</FloatingTree>
|
|
</QueryClientProvider>,
|
|
);
|
|
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);
|
|
});
|
|
});
|