// @vitest-environment jsdom // // A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list // is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts // none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), })); vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() })); // The player itself is not what is under test: it shows what it was opened on. vi.mock("./PlayerModal", () => ({ default: ({ video, queue }: { video: Video; queue?: Video[] }) => (

playing {video.id} {queue ? "in a list" : "alone"}

), })); import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer"; import { LinkedPlayer } from "./LinkedPlayer"; import { api, type Video } from "../../lib/api"; import { notifyActionError } from "../../lib/actionErrors"; import { qk } from "../../lib/queryKeys"; const vid = (id: string) => ({ id, title: id }) as unknown as Video; function wrapper({ children }: { children: ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return {children}; } afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.mocked(notifyActionError).mockClear(); window.history.replaceState(null, "", "/"); }); describe("useOpenLinkedPlayer", () => { it("opens the item from the host's list once the list is in, and only once", async () => { window.history.replaceState(null, "", "/?watch=B"); const row = vi.spyOn(api, "videoRow"); const open = vi.fn(); const list = [vid("A"), vid("B")]; const { rerender } = renderHook( ({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open), { wrapper, initialProps: { ready: false, rows: list } }, ); expect(open).not.toHaveBeenCalled(); rerender({ ready: true, rows: list }); await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true)); // The list refetched (new rows for the same videos): the player it opened is not opened again. rerender({ ready: true, rows: [vid("A"), vid("B")] }); expect(open).toHaveBeenCalledTimes(1); expect(row).not.toHaveBeenCalled(); }); it("opens an item the list does not hold alone, its row from the catalog", async () => { window.history.replaceState(null, "", "/?watch=Z"); vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z")); const open = vi.fn(); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false)); }); it("says so when the catalog no longer has it", async () => { window.history.replaceState(null, "", "/?watch=Z"); const gone = new Error("not found"); vi.spyOn(api, "videoRow").mockRejectedValue(gone); const open = vi.fn(); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); await waitFor(() => expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"), ); expect(open).not.toHaveBeenCalled(); // Said once: the address no longer names it, so a reload or a copied link does not say it again. expect(new URLSearchParams(window.location.search).has("watch")).toBe(false); }); it("leaves a player opened while the row was on its way alone, and its address", async () => { window.history.replaceState(null, "", "/?watch=Z"); let answer: (v: Video) => void = () => {}; vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r))); const open = vi.fn(); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); // A card clicked meanwhile: its player names itself in the address. window.history.replaceState(null, "", "/?watch=Y"); answer(vid("Z")); await new Promise((r) => setTimeout(r, 20)); expect(open).not.toHaveBeenCalled(); expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y"); }); it("does not clear the address of a player opened since when its catalog read fails", async () => { window.history.replaceState(null, "", "/?watch=Z"); let refuse: (e: Error) => void = () => {}; vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r))); renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper }); window.history.replaceState(null, "", "/?watch=Y"); refuse(new Error("not found")); await new Promise((r) => setTimeout(r, 20)); expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y"); }); it("given up over a moved list, keeps the address of a player opened since", async () => { window.history.replaceState(null, "", "/?watch=Z"); const { rerender } = renderHook( ({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope), { wrapper, initialProps: { scope: 1 } }, ); window.history.replaceState(null, "", "/?watch=Y"); rerender({ scope: 2 }); await new Promise((r) => setTimeout(r, 20)); expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y"); }); it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => { window.history.replaceState(null, "", "/?watch=B"); const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B")); const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined)); const { rerender } = renderHook( ({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()), { wrapper, initialProps: { rows: true } }, ); await waitFor(() => expect(find).toHaveBeenCalled()); find.mockClear(); rerender({ rows: false }); rerender({ rows: true }); await new Promise((r) => setTimeout(r, 20)); expect(find).not.toHaveBeenCalled(); expect(row).not.toHaveBeenCalled(); }); it("opens nothing, and asks for nothing, for an address that names no player", async () => { window.history.replaceState(null, "", "/?q=talks"); const row = vi.spyOn(api, "videoRow"); const open = vi.fn(); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); await new Promise((r) => setTimeout(r, 20)); expect(open).not.toHaveBeenCalled(); expect(row).not.toHaveBeenCalled(); }); }); describe("the shell's LinkedPlayer", () => { it("plays the video alone over a page that hosts no player", async () => { window.history.replaceState(null, "", "/?page=channels&watch=Z"); vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z")); render(, { wrapper }); expect(await screen.findByText("playing Z alone")).toBeTruthy(); }); it("leaves it to a page that hosts its own, even once that page has fetched the row", async () => { // The page plays it alone, from the catalog row it asked for: the same cache entry this one reads. window.history.replaceState(null, "", "/?page=home&watch=Z"); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.videoRow("Z"), vid("Z")); render( , ); await new Promise((r) => setTimeout(r, 20)); expect(screen.queryByText(/playing/)).toBeNull(); }); it("leaves it to a page that hosts its own", async () => { window.history.replaceState(null, "", "/?watch=Z"); const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z")); render(, { wrapper }); await new Promise((r) => setTimeout(r, 20)); expect(row).not.toHaveBeenCalled(); expect(screen.queryByText(/playing/)).toBeNull(); }); });