Files
siftlode/frontend/src/components/Playlists.dom.test.tsx
T
peter dc902d2c0e test(fe): cover the palette's content, AltGr, the banner's name, share colours, playlist headers
- AppCommandPalette had no test: modules gated by the account, hidden channels left out, the
  playlist row selecting before it navigates, and the surprise riding setPage's `then`.
- keymap: an Alt/AltGr-modified key (Ctrl+Alt on Windows, a constant on the HU layout) fires no
  bare-key or Mod row.
- Banner: the dismiss button falls back to "Close" rather than going unnamed.
- ShareDialog: a protected link's lit lock and Revoke's danger colour stay on the icon.
- Both playlist headers are rendered now: the YouTube one needed the toolbar slot it portals into.
  Meta line sums known runtimes and treats an ABSENT duration as nothing (null never exercised the
  guard: `3000 + null` is 3000); a one-row list offers no view controls.
2026-09-19 17:23:55 +02:00

452 lines
20 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";
const h = vi.hoisted(() => ({
notify: vi.fn(),
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: () => async () => true }));
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: 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 { 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> = {}) {
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 });
// 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 tree = () => (
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Playlists />
</PageToolbarSlot.Provider>
</QueryClientProvider>
);
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();
});
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 — 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", "playlists.orderDown");
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", "playlists.orderDown");
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(1));
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);
await rowMenu("Video X", "playlists.orderUp");
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" }),
);
});
});
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)] });
await screen.findByRole("button", { name: "card.play — Video B" });
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 · 50m");
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();
});
});