Files
siftlode/frontend/src/components/Playlists.dom.test.tsx
T
peter 3741d1a46d fix(playlists): a list reopened from the cache keeps its rows
Leaving the Playlists page and coming back painted the selected list empty ("0 videos") while
the rail counted its rows. The page re-seeds its undoable order from the cached list in an
effect; StrictMode runs that effect twice on the remount, so a reset was followed in the same
tick by a rebase. Since E32 S3 a reset is state only and the rebase read the mirror ref, which
still held the empty initial history, and wrote it back over the reset.

rebase is now a state updater, so it builds on whatever an earlier call in the tick installed.
Both tests seen RED (hook: [] for [10, 20]; page: no rows), and the bug reproduced in the
browser on the old hook.
2026-10-04 02:48:10 +02:00

743 lines
34 KiB
TypeScript

// @vitest-environment jsdom
//
// The Playlists page as the player sees it: which row it is launched with, and what it is told to
// do when the viewer removes that row from inside it. PlayerModal itself is replaced by a stand-in
// that records its props — its own behaviour is pinned in PlayerModal.dom.test.tsx.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
const h = vi.hoisted(() => ({
notify: vi.fn(),
confirm: vi.fn(async () => true),
selectedId: 1 as number | null,
mounted: false,
mounts: 0,
player: null as null | {
video: { id: string; position_seconds: number };
onClose: () => void;
onState: (id: string, status: string) => void;
onRemoveFromList?: (v: unknown) => Promise<boolean>;
},
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
i18n: { language: "en" },
}),
};
});
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
// The list's own menu is usePlaylistActions' (pinned in its own test); here its rename row, which
// opens THIS page's title field, and its two destructive rows as that hook marks them (danger).
vi.mock("./usePlaylistActions", () => ({
usePlaylistActions: () => ({
remove: vi.fn(),
rename: vi.fn(),
items: (_pl: unknown, o: { onRename: () => void }) => [
{ id: "rename", label: "playlists.rename", onSelect: o.onRename },
{ id: "revert", label: "playlists.revert", danger: true, onSelect: vi.fn() },
{ id: "delete", label: "playlists.delete", danger: true, onSelect: vi.fn() },
],
}),
}));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
return {
default: function PlayerStandIn(props: NonNullable<typeof h.player>) {
h.player = props;
useEffect(() => {
h.mounted = true;
h.mounts++;
return () => {
h.mounted = false;
};
}, []);
return null;
},
};
});
import Playlists from "./Playlists";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { PageMenuSlot, type PageMenuOpener } from "./ui/pageMenuSlot";
import { api, HttpError, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { patchCachedProgress } from "../lib/progressCache";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const vid = (id: string, position_seconds: number): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const WATCH_LATER = 9;
const list = (id: number, items: Video[]) => ({
id,
name: `List ${id}`,
kind: id === WATCH_LATER ? "watch_later" : "user",
yt_playlist_id: null,
item_count: items.length,
items,
});
let qc: QueryClient;
function mount(
lists: Record<number, Video[]>,
prefs: Record<string, unknown> = {},
{ strictOverCache = false } = {},
) {
vi.spyOn(api, "playlists").mockResolvedValue(
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
);
vi.spyOn(api, "playlist").mockImplementation(
async (id: number) => list(id, lists[id] ?? []) as never,
);
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
// Coming back to the page: the selected list is already cached, and StrictMode (dev) runs its
// effects twice on that mount.
if (strictOverCache && h.selectedId != null)
qc.setQueryData(qk.playlist(h.selectedId), list(h.selectedId, lists[h.selectedId] ?? []));
// The header portals into the shell's fixed toolbar band; without a slot it renders nothing, and
// it went untested for exactly that reason.
const slot = document.createElement("div");
document.body.appendChild(slot);
const page = () => (
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Playlists />
</PageToolbarSlot.Provider>
</QueryClientProvider>
);
const tree = () => (strictOverCache ? <StrictMode>{page()}</StrictMode> : page());
const view = render(tree());
return { rerender: () => view.rerender(tree()) };
}
const play = async (title: string) =>
fireEvent.click(await screen.findByRole("button", { name: `card.play — ${title}` }));
beforeEach(() => {
h.selectedId = 1;
h.player = null;
h.mounted = false;
h.mounts = 0;
h.notify.mockClear();
h.confirm.mockClear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("Playlists — the row the player is launched with", () => {
it("carries the position the player just stored, not the one the list first loaded with", async () => {
// The page renders from its own undoable copy of the list, re-seeded only when the SET of ids
// changes — so the position the player wrote into the cache never reached it: reopen a video
// closed at 830 s and it resumed at the old spot, and the next checkpoint wrote that over 830.
mount({ 1: [vid("V", 748)] });
await play("Video V");
await waitFor(() => expect(h.player?.video.position_seconds).toBe(748));
act(() => h.player!.onClose());
h.player = null;
// What the player's cleanup does with the position the server confirmed. react-query hands a
// cache write to its observers on a setTimeout(0); outside act, the re-render it triggers is
// scheduled by React on its own and raced the click below under the gate's parallel load (the
// reopen read 748). The write is SYNCHRONOUS, so its notify timer is registered before ours and
// fires first — and inside act, the render it causes is flushed before act returns.
await act(async () => {
patchCachedProgress(qc, new Map([["V", 830]]));
await new Promise((r) => setTimeout(r, 0));
});
await play("Video V");
await waitFor(() => expect(h.player?.video.position_seconds).toBe(830));
});
});
describe("Playlists — coming back to the page", () => {
it("shows the cached list's rows, not an empty list", async () => {
// The seeding effect ran twice over the cached list (StrictMode): reset, then — same membership —
// rebase, which rebased the empty list from before the reset and wrote it back. The header read
// "0 videos" and the page said the list was empty while the rail counted its rows.
mount({ 1: [vid("A", 0), vid("B", 0)] }, {}, { strictOverCache: true });
expect(await screen.findByRole("button", { name: "card.play — Video A" })).toBeTruthy();
expect(screen.queryByText("playlists.empty")).toBeNull();
});
});
describe("Playlists — removing a video from inside the player", () => {
it("removes it from the list the player was launched from, even after the selection moved", async () => {
// The selection can change under an open player (the command palette opens over it); the
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video W" }); // list 2 is on screen now
await act(async () => {
await h.player!.onRemoveFromList!(vid("V", 0));
});
expect(remove).toHaveBeenCalledWith(1, "V");
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
});
it("says so when the removal fails because the list is gone", async () => {
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
// it, so a confirmed removal failed in total silence behind the full-screen player.
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
let done: boolean | undefined;
await act(async () => {
done = await h.player!.onRemoveFromList!(vid("V", 0));
});
expect(done).toBe(false);
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
});
it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => {
// Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player
// launched from Watch later as soon as another list was selected under it — and deleted from
// Watch later, since the removal itself targets the launch list.
h.selectedId = WATCH_LATER;
const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
expect(h.player!.onRemoveFromList).toBeUndefined();
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video W" });
expect(h.player!.onRemoveFromList).toBeUndefined();
});
it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => {
// The player steps to the next item after a removal and stores the last one's position into
// every cached list. Landing before the refetch, that write handed the page a list still holding
// the video, and the page — seeing a different set of ids — reset: the row came back.
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
mount({ 1: [vid("V", 0), vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out
await act(async () => {
await h.player!.onRemoveFromList!(vid("V", 0));
});
await act(async () => {
patchCachedProgress(qc, new Map([["V", 99]]));
await new Promise((r) => setTimeout(r, 0));
});
expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull();
expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy();
});
});
describe("Playlists — a failed refetch of a list that is on screen", () => {
it("keeps the list, instead of replacing it with the error", async () => {
// A background refetch — on focus, after a move or a removal — failing during an API restart
// replaced a loaded list with "couldn't load" (the `isError && <no data>` convention, ignored).
mount({ 1: [vid("V", 0)] });
await screen.findByRole("button", { name: "card.play — Video V" });
vi.mocked(api.playlist).mockRejectedValue(new HttpError(502, "gateway"));
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.playlist(1) });
await new Promise((r) => setTimeout(r, 20)); // react-query notifies its observers on a timer
});
expect(qc.getQueryState(qk.playlist(1))?.status).toBe("error");
expect(screen.queryByText("playlists.loadFailed")).toBeNull();
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
});
});
describe("Playlists — the player outlives a change of selection", () => {
it("stays mounted while a newly selected list is still loading", async () => {
// The page swaps itself for "Loading…" until the new list arrives; with the player rendered only
// in the loaded branch, that took it down mid-playback and put it back at its launch video.
const { rerender } = mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.mounted).toBe(true));
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}));
h.selectedId = 2;
rerender();
// The shared loading state, which a screen reader hears (the old bare line had no live region).
expect(
(await screen.findByText("playlists.loading")).closest('[role="status"]'),
).not.toBeNull();
expect(h.mounted).toBe(true);
expect(h.mounts).toBe(1); // never went down — a remount would also end `mounted`
});
it("stays mounted when the newly selected list fails to load — a list deleted elsewhere", async () => {
const { rerender } = mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.mounted).toBe(true));
vi.mocked(api.playlist).mockRejectedValue(new HttpError(404, "gone"));
h.selectedId = 2;
rerender();
await screen.findByText("playlists.loadFailed");
expect(h.mounted).toBe(true);
expect(h.mounts).toBe(1);
});
it("stays mounted when nothing is selected any more", async () => {
const { rerender } = mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.mounted).toBe(true));
h.selectedId = null;
rerender();
await screen.findByText("playlists.pickOne");
expect(h.mounted).toBe(true);
expect(h.mounts).toBe(1);
});
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
// Invalidating the SELECTED list left the launch list's row showing the old status.
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] });
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video W" });
await act(async () => {
h.player!.onState("V", "watched");
await new Promise((r) => setTimeout(r, 0));
});
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
});
});
const rowsOnScreen = () =>
screen
.getAllByRole("button", { name: /^card\.play — / })
.map((b) => b.getAttribute("aria-label")?.replace("card.play — ", ""));
async function rowMenu(title: string, item: string) {
const row = (await screen.findByRole("button", { name: `card.play — ${title}` })).closest("tr")!;
fireEvent.contextMenu(row);
await waitFor(() =>
expect(
screen.getAllByRole("menuitem", { hidden: true }).some((b) => b.textContent === item),
).toBe(true),
);
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === item)!,
);
}
describe("Playlists — a change the server refused is taken back on screen", () => {
// The resync refetch returns the SAME list the server had, react-query keeps the same `data`
// object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the
// optimistic change used to stay on screen for good.
it("puts back a row whose move failed", async () => {
vi.spyOn(api, "moveToPlaylist").mockRejectedValue(
new HttpError(404, "gone", { code: "not_found", reason: "move_target" }),
);
mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 });
await rowMenu("Video V", "playlists.moveToRecent");
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })),
);
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
});
it("puts back the order when saving a reorder failed", async () => {
vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom"));
mount({ 1: [vid("V", 0), vid("W", 0)] });
await rowMenu("Video V", "common.order.down");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
});
it("puts back the order the server ACCEPTED, not the one it had two reorders ago", async () => {
// The rollback reads the cached detail, and nothing wrote the accepted order into it — only the
// refetch that follows a successful reorder did. So a second reorder rejected while that refetch
// was still in flight rolled the rows back past the FIRST one, which the server had accepted.
// The membership guard cannot correct an order (its key is the sorted id set), so the wrong
// order stayed on screen for good and the next drag persisted it (deep-review round 5).
const reorder = vi
.spyOn(api, "reorderPlaylist")
.mockResolvedValueOnce(undefined as never)
.mockRejectedValue(new HttpError(500, "boom"));
mount({ 1: [vid("V", 0), vid("W", 0), vid("X", 0)] });
await screen.findByRole("button", { name: "card.play — Video V" });
// From here no refetch ever lands, so the cache is whatever the page itself put there.
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}) as never);
await rowMenu("Video V", "common.order.down");
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(1));
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);
await rowMenu("Video X", "common.order.up");
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(2));
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);
});
it("puts back a row whose removal failed, even when the resync fails too", async () => {
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
// back — the list kept showing it as removed while it was still in the playlist.
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
mount({ 1: [vid("V", 0), vid("W", 0)] });
await screen.findByRole("button", { name: "card.play — Video V" });
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
await rowMenu("Video V", "playlists.removeItem");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled());
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
);
});
});
// E28 C1: a row's X column went. Remove is in the row's menu and on the Delete key, with no confirm —
// the toast's Undo puts the row back where it stood, in ONE request.
describe("Playlists — removing a row, and undoing it", () => {
// The server's side of a removal, so the refetch after it agrees with it.
const serverRemoves = (lists: Record<number, Video[]>) =>
vi.spyOn(api, "removeFromPlaylist").mockImplementation(async (id: number, videoId: string) => {
lists[id] = lists[id]!.filter((v) => v.id !== videoId);
return undefined as never;
});
// The removal's toast — found by what it says, not by being the latest: a refetch after the
// removal may notify too.
const lastToast = () =>
h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("playlists.removedToast"))
.pop()!;
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
const keysLiveOn = (el: Element) =>
waitFor(() => expect(el.closest("[data-nav-list]")).not.toBeNull());
const pressKey = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
it("removes from the menu at once, and the toast's Undo puts it back at its place", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
mount(lists);
await rowMenu("Video B", "playlists.removeItem");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
// No confirm: the toast's Undo is the safety net.
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(lastToast().message).toBe("playlists.removedToast:Video B"));
expect(lastToast().action?.label).toBe("common.undo");
act(() => lastToast().action!.onClick());
// Index 1 of the STORED order: where it was, not the end of the list.
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
});
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
mount(lists);
await screen.findByRole("button", { name: "card.play — Video C" });
// Sort the VIEW by title, descending: C, B, A on screen — the stored order is still A, B, C.
fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ }));
fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ }));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video C", "Video B", "Video A"]));
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
});
// E28 review C4: the stored index drifts when the list changes before the Undo (a row added at
// the top in another tab) — the row goes back after its old neighbour instead.
it("puts it back after its neighbour when the list changed before the Undo", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
mount(lists);
await rowMenu("Video B", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
lists[1] = [vid("X", 0), ...lists[1]!];
act(() => lastToast().action!.onClick());
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 2));
});
it("says so when the Undo fails", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
mount(lists);
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.undoRemoveFailed" }),
),
);
});
it("removes the walked row on Delete, and the focus goes on to the next", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
mount(lists);
await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video C" }));
pressKey("j");
pressKey("j");
expect(document.activeElement?.textContent).toContain("Video B");
pressKey("Delete");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video C"));
});
it("offers no Delete, no Remove and no X on Watch later's rows", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
h.selectedId = WATCH_LATER;
mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] });
await keysLiveOn(await screen.findByRole("button", { name: "card.play — Video B" }));
pressKey("j");
pressKey("Delete");
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(api.removeFromPlaylist).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /removeItemOf/ })).toBeNull();
});
it("opens the row's menu from its '…', with the copy rows", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
const more = screen.getByRole("button", { name: "playlists.rowMenu" });
expect(more.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual(
expect.arrayContaining([
"card.play",
"common.copy.title",
"common.copy.link",
"playlists.removeItem",
]),
),
);
expect(
screen.getByRole("button", { name: "playlists.rowMenu" }).getAttribute("aria-expanded"),
).toBe("true");
});
});
// E28 C1: the header keeps Play all; rename, YouTube and delete are the list's menu, behind a "…".
describe("Playlists · the header's '…'", () => {
it("replaces the pencil, the YouTube buttons and the trash, and renames from its menu", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
for (const gone of [
"playlists.rename",
"playlists.delete",
"playlists.pushToYoutube",
"playlists.exportToYoutube",
"playlists.openOnYoutube",
])
expect(screen.queryByRole("button", { name: gone })).toBeNull();
const more = screen.getByRole("button", { name: "playlists.railMenu" });
act(() => more.focus());
fireEvent.click(more);
// The surface is visibility:hidden in jsdom (floating-ui never positions): matched on text.
const renameRow = await waitFor(() => {
const row = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "playlists.rename");
expect(row).toBeTruthy();
return row!;
});
fireEvent.click(renameRow);
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
// Leaving the field by Escape hands the focus back to the "…" it was opened from.
fireEvent.keyDown(field, { key: "Escape" });
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "playlists.railMenu" }),
),
);
});
});
describe("Playlists · a narrow table", () => {
it("leaves out '#' and Channel while the TABLE is narrow, header and cells alike, so the title keeps a width", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
// By the table's own width (its @container), not the viewport's: beside the rail a desktop
// window left the table 432 px and the title 0.
expect(document.querySelector("table")!.parentElement!.classList.contains("@container")).toBe(
true,
);
const narrowHidden = (el: Element) =>
el.classList.contains("hidden") && el.classList.contains("@2xl:table-cell");
const heads = [...document.querySelectorAll("thead th")];
const cells = [...document.querySelectorAll("tbody tr:first-child td")];
// Columns: grip, #, title, channel, length, "…". Length went too (E28 S3f3, the owner's pick A).
expect(heads.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
expect(cells.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
});
it("carries the hidden length on the picture and the channel under a two-line title", async () => {
mount({ 1: [vid("A", 0)] });
const thumb = await screen.findByRole("button", { name: "card.play — Video A" });
const narrowOnly = (el: Element | null): boolean =>
!!el && (el.classList.contains("@2xl:hidden") || narrowOnly(el.parentElement));
// 3000 s = 50:00, drawn on the picture only while the Length column is hidden.
const badge =
[...thumb.querySelectorAll("span")].find((e) => e.textContent === "50:00") ?? null;
expect(narrowOnly(badge)).toBe(true);
const cell = thumb.closest("td")!;
const title = [...cell.querySelectorAll("span")].find((e) => e.textContent === "Video A")!;
expect(title.className).toContain("line-clamp-2");
expect(title.className).toContain("@2xl:line-clamp-1");
const channel =
[...cell.querySelectorAll("span")].find((e) => e.textContent === "Channel") ?? null;
expect(narrowOnly(channel)).toBe(true);
});
});
describe("Playlists — the rows are a keyboard list", () => {
it("walks the rows with j and plays the focused one on Enter", async () => {
mount({ 1: [vid("A", 0), vid("B", 0)] });
const row = await screen.findByRole("button", { name: "card.play — Video B" });
// The keys are live once the list's effect registers it, which can land after the rows paint.
await waitFor(() => expect(row.closest("[data-nav-list]")).not.toBeNull());
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
press("j");
press("j");
expect(document.activeElement?.tagName).toBe("TR");
expect(document.activeElement?.textContent).toContain("Video B");
press("Enter");
await waitFor(() => expect(h.player?.video.id).toBe("B"));
});
});
// The header's meta line and its view-controls gate were typechecked and linted but never rendered,
// so a regression in either shipped green (E9 review). The count and the runtime sum the WHOLE
// list; an unknown duration counts as nothing rather than poisoning the sum; and a list of one has
// no view to change.
describe("Playlists · the header", () => {
// MISSING, not null: `3000 + null` is 3000 in JS, so only an absent field shows whether the sum
// guards an unknown duration or would turn the whole runtime into NaN.
const untimed = (id: string) => {
const { duration_seconds: _drop, ...rest } = vid(id, 0);
return rest as unknown as Video;
};
const meta = async () =>
(
await screen.findByText(
(_, el) => el?.tagName === "SPAN" && /^playlists\.itemCount/.test(el.textContent ?? ""),
)
).textContent;
it("sums what is known, and says nothing about a runtime nothing sums to", async () => {
mount({ 1: [vid("A", 0), untimed("B")] });
expect(await meta()).toBe("playlists.itemCount · 50 min");
cleanup();
mount({ 1: [untimed("C")] });
expect(await meta()).toBe("playlists.itemCount");
});
it("offers the view controls only once there is more than one row to arrange", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByText("Video A");
expect(screen.queryByText("playlists.groupByChannel")).toBeNull();
cleanup();
mount({ 1: [vid("A", 0), vid("B", 0)] });
expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy();
});
});
// DESIGN "Background menu": the page's blank area mirrors the band (Play all, the view controls) and
// the list's own "…" — without its destructive rows, which stay one deliberate "…" away.
describe("Playlists · the page's background menu", () => {
it("offers Play all, Group by and the list's menu, never Delete", async () => {
let open: PageMenuOpener | null = null;
vi.spyOn(api, "playlists").mockResolvedValue([list(1, [vid("A", 0), vid("B", 0)])] as never);
vi.spyOn(api, "playlist").mockImplementation(
async () => list(1, [vid("A", 0), vid("B", 0)]) as never,
);
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: {} });
render(
<QueryClientProvider client={qc}>
<PageMenuSlot.Provider
value={(o) => {
open = o;
return () => {
if (open === o) open = null;
};
}}
>
<Playlists />
</PageMenuSlot.Provider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "card.play — Video A" });
expect(open, "registered once a list is on screen").not.toBeNull();
act(() => open!(5, 5, document.body));
const rows = await waitFor(() => {
const r = screen
.getAllByRole("menu", { hidden: true })
.find((m) => m.getAttribute("aria-label") === "common.pageMenu");
expect(r).toBeTruthy();
return [...r!.querySelectorAll("[role^=menuitem]")].map((b) => b.textContent);
});
expect(rows).toEqual(
expect.arrayContaining(["playlists.playAll", "playlists.groupByChannel", "playlists.rename"]),
);
expect(rows).not.toContain("playlists.delete");
expect(rows).not.toContain("playlists.revert");
});
});