// @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; }, })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string, o?: Record) => (o?.title ? `${key}:${o.title}` : key), i18n: { language: "en" }, }), }; }); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), 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) { 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, prefs: Record = {}, { 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 = () => ( ); const tree = () => (strictOverCache ? {page()} : 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 && ` 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) => 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( { open = o; return () => { if (open === o) open = null; }; }} > , ); 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"); }); });