// @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, player: null as null | { video: { id: string; position_seconds: number }; onClose: () => 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("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) })); vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) })); vi.mock("./PlayerModal", () => ({ default: (props: NonNullable) => { h.player = props; return null; }, })); import Playlists from "./Playlists"; 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 list = (id: number, items: Video[]) => ({ id, name: `List ${id}`, kind: "user", yt_playlist_id: null, item_count: items.length, items, }); let qc: QueryClient; function mount(lists: Record) { 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: {} }); const tree = () => ( ); 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.notify.mockClear(); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); 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. act(() => patchCachedProgress(qc, new Map([["V", 830]]))); 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" })); }); });