Files
siftlode/frontend/src/features/player/RelatedVideos.dom.test.tsx
T
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00

411 lines
18 KiB
TypeScript

// @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<typeof import("react-i18next")>();
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> = {}): 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<unknown>) => 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(
<QueryClientProvider client={qc}>
<FloatingTree>
<RelatedVideos videoId="SRC" onPlay={onPlay} onWrite={onWrite} />
</FloatingTree>
</QueryClientProvider>,
);
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<never>((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<unknown>[] = [];
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<typeof page>) => 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();
});
});