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.
743 lines
34 KiB
TypeScript
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");
|
|
});
|
|
});
|