Files
siftlode/frontend/src/components/Playlists.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +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.
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" }),
);
});
});
// 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));
});
// 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");
});
});
// 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 (the owner's pick).
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. 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");
});
});