// @vitest-environment jsdom // // A video's related videos, as the player shows them. What is held: a row says when the user follows // its channel; the region the user picks is asked for and kept on the account; a video marked seen // here leaves the list (and comes back when the server refuses the mark); reading further sends the // cursor; and a failure says so rather than drawing an empty list. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); vi.mock("../navigation/navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }), })); vi.mock("../video/AddToPlaylist", () => ({ default: () => null })); import { RelatedVideos } from "./RelatedVideos"; import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api"; import { clearAll, getNotifications } from "../../lib/notifications"; import { qk } from "../../lib/queryKeys"; import { setHintsEnabled } from "../../lib/hints"; import { writeVideoState } from "../video/videoWrites"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); clearAll(); }); const row = (id: string, extra: Partial = {}): RelatedVideo => ({ id, title: `Video ${id}`, channel_id: `UC${id}`, channel_title: `Channel ${id}`, watch_url: `https://www.youtube.com/watch?v=${id}`, status: "new", saved: false, position_seconds: 0, followed: false, ...extra, }) as RelatedVideo; const page = (items: RelatedVideo[], next: string | null = null) => ({ items, has_more: next != null, next_cursor: next, }); function setup( onPlay: (v: Video, startAt: number | null) => void = () => {}, onWrite?: (write: Promise) => void, qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }), ) { vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never); vi.spyOn(api, "downloadIndex").mockResolvedValue({}); const saved = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never); render( , ); return { saved, qc }; } // A row's title (its picture is a link of the same name). const title = async (name: string) => (await screen.findAllByRole("link", { name })).find((a) => a.hasAttribute("data-nav-open"))!; const titles = () => screen .queryAllByRole("link") .filter((a) => a.hasAttribute("data-nav-open")) .map((a) => a.textContent); // The menu's surface is visibility:hidden in jsdom: an item is matched on its text. const menuItem = (label: string) => screen.queryAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!; describe("RelatedVideos", () => { it("marks the rows whose channel the user follows, and only those", async () => { vi.spyOn(api, "related").mockResolvedValue( page([row("A", { followed: true }), row("B"), row("C", { followed: true })]), ); setup(); await title("Video A"); const marks = screen.getAllByRole("img", { name: "player.related.followed" }); const owners = marks.map((m) => m.closest("[data-video-id]")?.getAttribute("data-video-id")); expect(owners).toEqual(["A", "C"]); }); it("asks for the region picked, and keeps the pick on the account", async () => { const asked = vi.spyOn(api, "related").mockResolvedValue(page([row("A")])); const { saved } = setup(); await title("Video A"); expect(asked.mock.calls).toEqual([["SRC", "topical", null]]); fireEvent.click(screen.getByRole("button", { name: "player.related.hu" })); await waitFor(() => expect(asked.mock.calls).toContainEqual(["SRC", "hu", null])); expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" }); }); it("sets the region switch at the toolbar rung, not the 10px caption one", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A")])); setup(); await title("Video A"); const track = screen.getByRole("group", { name: "player.related.region" }); expect(track.className).toMatch(/\btext-xs\b/); }); // A plain pick resumes (null: the player knows the freshest position); the row's own position // travels on the video it hands over. it("plays a row from where the user left it", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })])); const onPlay = vi.fn(); setup(onPlay); fireEvent.click(await title("Video A")); expect(onPlay).toHaveBeenCalledWith( expect.objectContaining({ id: "A", position_seconds: 95 }), null, ); }); it("drops a video hidden here at once, and brings it back when the server refuses", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")])); let refuse: (e: Error) => void = () => {}; const write = vi .spyOn(api, "setState") .mockReturnValue(new Promise((_, reject) => (refuse = reject)) as never); setup(); fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!); fireEvent.click(menuItem("card.hide")); await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden")); expect(titles()).toEqual(["Video B"]); await act(async () => refuse(new Error("offline"))); expect(titles()).toEqual(["Video A", "Video B"]); }); it("keeps a video marked watched out of the list once the server has it", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")])); vi.spyOn(api, "setState").mockResolvedValue(undefined as never); setup(); fireEvent.contextMenu((await title("Video B")).closest("[data-video-id]")!); fireEvent.click(menuItem("card.markWatched")); await waitFor(() => expect(titles()).toEqual(["Video A"])); }); it("reads further with the cursor the last page gave", async () => { const asked = vi .spyOn(api, "related") .mockResolvedValueOnce(page([row("A")], "CUR1")) .mockResolvedValueOnce(page([row("B")])); setup(); await title("Video A"); fireEvent.click(screen.getByRole("button", { name: "player.related.more" })); await title("Video B"); expect(asked.mock.calls[1]).toEqual(["SRC", "topical", "CUR1"]); expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull(); }); it("says so when YouTube refuses, rather than drawing an empty list", async () => { vi.spyOn(api, "related").mockRejectedValue( new HttpError(422, "YouTube isn't reachable", undefined), ); setup(); expect((await screen.findByRole("alert")).textContent).toContain("YouTube isn't reachable"); expect(screen.queryByText("player.related.empty")).toBeNull(); }); // A deferred answer, settled when the test says. const deferred = () => { let resolve: () => void = () => {}; let reject: (e: Error) => void = () => {}; const promise = new Promise((res, rej) => { resolve = res as () => void; reject = rej; }); return { promise, resolve, reject }; }; it("keeps a row hidden while its hide is on its way, though an earlier save landed", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")])); const save = deferred(); const saving = vi.spyOn(api, "watchLaterAdd").mockReturnValue(save.promise as never); const hide = deferred(); const hiding = vi.spyOn(api, "setState").mockReturnValue(hide.promise as never); setup(); const card = (await title("Video A")).closest("[data-video-id]")!; fireEvent.contextMenu(card); fireEvent.click(menuItem("card.saveForLater")); await waitFor(() => expect(saving).toHaveBeenCalledWith("A")); fireEvent.contextMenu(card); fireEvent.click(menuItem("card.hide")); await waitFor(() => expect(hiding).toHaveBeenCalledWith("A", "hidden")); expect(titles()).toEqual(["Video B"]); await act(async () => save.resolve()); expect(titles()).toEqual(["Video B"]); }); it("announces a hide with the video it names, for the notification center to find", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A")])); vi.spyOn(api, "setState").mockResolvedValue(undefined as never); const writes: Promise[] = []; setup(undefined, (w) => void writes.push(w)); fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!); fireEvent.click(menuItem("card.hide")); await waitFor(() => expect(getNotifications().length).toBeGreaterThan(0)); expect(getNotifications().map((n) => n.meta)).toContainEqual({ kind: "video-hidden", videoId: "A", title: "Video A", channelId: "UCA", channelName: "Channel A", }); // And the write reached the host's session. expect(writes).toHaveLength(1); }); it("flips a row's bookmark at once, and back when the server refuses", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A")])); const save = deferred(); vi.spyOn(api, "watchLaterAdd").mockReturnValue(save.promise as never); setup(); const card = (await title("Video A")).closest("[data-video-id]")!; // What the menu offers is the row's state: un-save once saved. const offers = (label: string) => { fireEvent.contextMenu(card); return !!menuItem(label); }; expect(offers("card.saveForLater")).toBe(true); fireEvent.click(menuItem("card.saveForLater")); await waitFor(() => expect(offers("card.menu.unsave")).toBe(true)); fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); await act(async () => save.reject(new Error("offline"))); await waitFor(() => expect(offers("card.saveForLater")).toBe(true)); }); it("is kept for the session, not collected when the list closes", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A")])); const { qc } = setup(); await title("Video A"); const [cached] = qc.getQueryCache().findAll({ queryKey: qk.related() }); expect(cached!.options.gcTime).toBe(Infinity); }); it("says the next page failed under the rows it has, and retries that page", async () => { const asked = vi .spyOn(api, "related") .mockResolvedValueOnce(page([row("A")], "CUR1")) .mockRejectedValueOnce(new HttpError(422, "YouTube isn't reachable", undefined)) .mockResolvedValueOnce(page([row("B")])); setup(); await title("Video A"); fireEvent.click(screen.getByRole("button", { name: "player.related.more" })); expect((await screen.findByRole("alert")).textContent).toContain("YouTube isn't reachable"); expect(titles()).toEqual(["Video A"]); fireEvent.click(screen.getByRole("button", { name: "common.retry" })); await title("Video B"); expect(asked.mock.calls.map((c) => c[2])).toEqual([null, "CUR1", "CUR1"]); }); it("with nothing unseen read so far, says so above Show more, and retries the page it failed", async () => { const asked = vi .spyOn(api, "related") .mockResolvedValueOnce(page([], "CUR1")) .mockRejectedValueOnce(new HttpError(422, "YouTube isn't reachable", undefined)) .mockResolvedValueOnce(page([row("B")])); setup(); expect(await screen.findByText("player.related.emptySoFar")).toBeTruthy(); expect(screen.queryByText("player.related.empty")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "player.related.more" })); await screen.findByRole("alert"); fireEvent.click(screen.getByRole("button", { name: "common.retry" })); await title("Video B"); expect(asked.mock.calls.map((c) => c[2])).toEqual([null, "CUR1", "CUR1"]); }); it.each([ ["the day's cap", { code: "refused", reason: "related_daily_limit", limit: 5 }], ["turned off", { code: "refused", reason: "related_disabled" }], ["the shared budget", { code: "youtube", reason: "quota" }], ])("offers no retry a retry cannot answer: %s", async (_name, detail) => { vi.spyOn(api, "related").mockRejectedValue(new HttpError(429, "Refused", detail)); setup(); expect((await screen.findByRole("alert")).textContent).toContain("Refused"); expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull(); }); it("offers a retry where trying again can help", async () => { vi.spyOn(api, "related").mockRejectedValue( new HttpError(422, "YouTube isn't reachable", { code: "youtube", reason: "unavailable" }), ); setup(); await screen.findByRole("alert"); expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy(); }); it("names the followed mark on hover", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })])); setup(); await title("Video A"); const mark = screen.getByRole("img", { name: "player.related.followed" }); fireEvent.mouseEnter(mark.parentElement!); fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" }); expect(await screen.findByText("player.related.followed")).toBeTruthy(); }); // An infinite query builds its next page from the pages it read when that fetch started: a mark // that landed in between was dropped with them, for the session. it("keeps a hide that landed while Show more was on its way", async () => { let land: (p: ReturnType) => void = () => {}; vi.spyOn(api, "related") .mockResolvedValueOnce(page([row("A"), row("C")], "CUR1")) .mockReturnValueOnce(new Promise((r) => (land = r)) as never); const write = vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never); setup(); await title("Video A"); fireEvent.click(screen.getByRole("button", { name: "player.related.more" })); fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!); fireEvent.click(menuItem("card.hide")); await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden")); await act(async () => {}); await act(async () => land(page([row("B")]))); await title("Video B"); expect(titles()).toEqual(["Video C", "Video B"]); }); // Hidden in this session, then un-hidden on another device: a list read after the mark carries // the server's word, and the mark does not paint over it. it("shows a list read after a mark as the server has it", async () => { vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never); vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")])); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await writeVideoState(qc, "A", "hidden"); await new Promise((r) => setTimeout(r, 5)); // the list is read a moment after the mark setup(undefined, undefined, qc); await title("Video B"); expect(titles()).toEqual(["Video A", "Video B"]); }); // The Notification Center's undo, the feed's reset, a card elsewhere: every write reaches it. it("follows a video's state written anywhere else", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")])); vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never); const { qc } = setup(); await title("Video A"); await act(async () => void (await writeVideoState(qc, "A", "hidden"))); await waitFor(() => expect(titles()).toEqual(["Video B"])); await act(async () => void (await writeVideoState(qc, "A", "new"))); await waitFor(() => expect(titles()).toEqual(["Video A", "Video B"])); }); // Show more is the request Retry would make: refused for good, it is not offered either. it("offers no Show more once the next page was refused for good", async () => { vi.spyOn(api, "related") .mockResolvedValueOnce(page([row("A")], "CUR1")) .mockRejectedValueOnce( new HttpError(429, "Limit", { code: "refused", reason: "related_daily_limit", limit: 3 }), ); setup(); await title("Video A"); fireEvent.click(screen.getByRole("button", { name: "player.related.more" })); expect((await screen.findByRole("alert")).textContent).toContain("Limit"); expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull(); expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull(); expect(titles()).toEqual(["Video A"]); }); it("names the followed mark on hover with hints turned off too", async () => { setHintsEnabled(false); try { vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })])); setup(); await title("Video A"); const mark = screen.getByRole("img", { name: "player.related.followed" }); fireEvent.mouseEnter(mark.parentElement!); fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" }); expect(await screen.findByText("player.related.followed")).toBeTruthy(); } catch (e) { setHintsEnabled(true); throw e; } setHintsEnabled(true); }); it("says when nothing is left that the user has not seen", async () => { vi.spyOn(api, "related").mockResolvedValue(page([])); setup(); expect(await screen.findByText("player.related.empty")).toBeTruthy(); expect(screen.queryByRole("alert")).toBeNull(); }); });