Files
siftlode/frontend/src/components/PlayerModal.dom.test.tsx
T
peter 77638f7a0b fix(player): a link that opens the cut text is scrolled back into view
Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes.
2026-10-09 23:46:40 +02:00

781 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
// had tests; what the modal DOES with them had none — a review during the 2026-09 bug hunt found
// that deleting the lines that apply them left every lane green.
//
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
// its promise in a module-level singleton, so this file must not share a module instance with a
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
// fake records what it was asked for — that is how these tests see where playback starts.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
i18n: { language: "en" },
}),
};
});
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
// Their own queries and popovers are not what is under test here.
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
vi.mock("./DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { ConfirmProvider } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { api, type Video } from "../lib/api";
import { clearAll, notify } from "../lib/notifications";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
// --- the fake IFrame API ------------------------------------------------------------------------
const clock = { current: 0, duration: 3000 };
const started: { videoId: string; start: number | undefined }[] = [];
const base = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
getVideoData: () => null,
};
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
// a thenable would be swallowed by the first `await` that touched it.
const fakePlayer = new Proxy(base, {
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
});
window.YT = {
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
return fakePlayer;
} as never,
};
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,
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
function renderModal(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
started.length = 0;
});
const lastStart = () => started[started.length - 1];
describe("PlayerModal — where each item starts", () => {
it("resumes an item played earlier in the SAME session where this session left it", async () => {
// The launch queue is frozen at mount, so its rows keep the position they were snapshotted
// with (748). Stepping next→prev back to an item already watched to 830 used to start it at
// 748 — and the next checkpoint then wrote 748 over the 830 the server held.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: id === "A" ? 830 : 0,
}));
const A = vid("A", 748);
const B = vid("B", 0);
clock.current = 830;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
expect(lastStart()?.start).toBe(830);
});
});
// --- "remove from this playlist", from inside the player ----------------------------------------
const trash = (title: string) =>
screen.getByRole("button", { name: `playlists.removeItemOf:Video ${title}` });
const next = () => fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
const prev = () => fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
const settle = () => act(async () => {});
describe("PlayerModal — removing the playing video from its playlist", () => {
const [A, B, C, D] = ["A", "B", "C", "D"].map((id) => vid(id, 0));
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
clock.current = 0;
});
it("steps to the item that followed, and the removed one is gone from the queue", async () => {
renderModal(
<PlayerModal
video={B!}
queue={[A!, B!, C!]}
startIndex={1}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={async () => true}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fireEvent.click(trash("B"));
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
prev(); // …straight back to A: B is no longer there to step onto
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("closes the player when the last item goes", async () => {
const onClose = vi.fn();
renderModal(
<PlayerModal
video={A!}
queue={[A!]}
startIndex={0}
onClose={onClose}
onState={() => {}}
onRemoveFromList={async () => true}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("leaves playback and the queue alone when the removal did not happen", async () => {
const onClose = vi.fn();
renderModal(
<PlayerModal
video={A!}
queue={[A!, B!]}
startIndex={0}
onClose={onClose}
onState={() => {}}
onRemoveFromList={async () => false}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
await settle();
expect(onClose).not.toHaveBeenCalled();
expect(started.map((s) => s.videoId)).toEqual(["A"]);
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
prev(); // A is still in the queue
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("keeps playback where the viewer went while the confirm was open", async () => {
// prev/next keep working under the confirm dialog; deciding from the cursor as it was when the
// button was pressed threw the viewer back onto a video they had already moved past.
let answer!: (ok: boolean) => void;
renderModal(
<PlayerModal
video={A!}
queue={[A!, B!, C!, D!]}
startIndex={0}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
await act(async () => answer(true));
expect(lastStart()?.videoId).toBe("C");
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
prev();
await settle();
expect(lastStart()?.videoId).toBe("B");
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
});
it("gives focus back to the Remove button when the confirm is cancelled", async () => {
// The button used to be disabled for the confirm's whole lifetime, so the dialog's focus
// restore landed on a disabled element — a no-op — and a keyboard user was left on <body>,
// outside the aria-modal player.
function WithConfirm() {
const confirm = useConfirm();
return (
<PlayerModal
video={A!}
queue={[A!, B!]}
startIndex={0}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={(v) =>
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
}
/>
);
}
renderModal(
<ConfirmProvider>
<WithConfirm />
</ConfirmProvider>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
trash("A").focus();
fireEvent.click(trash("A"));
fireEvent.click(await screen.findByRole("button", { name: "common.cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
});
});
describe("PlayerModal — toasts raised while it is open", () => {
afterEach(() => act(() => clearAll()));
it("shows them inside the player, not under its scrim", async () => {
// The app's Toaster is in a stacking context below this body-level portal: a failed Remove
// reported by a toast was drawn under the 80% black scrim, i.e. still silent.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
});
it("shows them inside the stage in true fullscreen — the element holding the lock", async () => {
// Anything outside the fullscreen element is painted under it. The player's host sat beside the
// card, so in fullscreen a lost connection was reported to nobody.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector(".player-stage")!;
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try {
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
// This was once the one `aria-modal` shell with no accessible name, no Tab trap and no focus
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
// Escape left a keyboard user on <body>, their place in a long feed lost.
describe("PlayerModal — the dialog contract", () => {
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
return renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("is named by the video's title", () => {
open();
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
});
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
open();
const active = document.activeElement as HTMLElement;
expect(active.tagName).not.toBe("BUTTON");
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
});
it("gives the focus back to what opened it when it closes", () => {
const opener = document.createElement("button");
document.body.appendChild(opener);
opener.focus();
try {
const view = open();
expect(document.activeElement).not.toBe(opener);
view.unmount();
expect(document.activeElement).toBe(opener);
} finally {
opener.remove();
}
});
it("keeps Tab inside the player", () => {
open();
const dialog = screen.getByTestId("player-modal");
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
const last = tabbables[tabbables.length - 1]!;
last.focus();
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
// element an offsetParent for the duration of this check.
const spy = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
try {
fireEvent.keyDown(window, { key: "Tab" });
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.activeElement).not.toBe(last);
} finally {
spy.mockRestore();
}
});
});
// --- the watch toggle belongs to the item it describes -------------------------------------------
// A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the
// commit that already advanced `active`, and within the finish margin it auto-marks that item
// watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item
// as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored.
describe("PlayerModal — the watch toggle after a queue advance", () => {
const A = vid("A", 0);
const B = vid("B", 0);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
});
it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => {
const onState = vi.fn();
clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
await settle();
// A really is marked watched — that part is correct and must keep working.
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
// B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
});
it("still reflects a watch toggled on the item you are actually on", async () => {
const onState = vi.fn();
clock.current = 0;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
// C3: the description used to open on hovering the title, which a finger never does. It opens from
// an ⓘ beside the title now, by click or tap, and a hover over the title opens nothing.
describe("PlayerModal — the description", () => {
it("opens from the ⓘ, not from hovering the title", async () => {
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" }));
fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" }));
await new Promise((r) => setTimeout(r, 500));
expect(screen.queryByText("A long description")).toBeNull();
const info = screen.getByRole("button", { name: "player.description" });
fireEvent.click(info);
expect(await screen.findByText("A long description")).toBeTruthy();
expect(info.getAttribute("aria-expanded")).toBe("true");
});
});
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
// width (owner): the panel's transport row and Shift+← / → step on their own.
describe("PlayerModal — no side strips", () => {
it("steps from the panel only, and the card keeps no gutter for strips", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.className).not.toMatch(/px-14/);
});
it("closes from one icon-only button", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
const close = screen.getByTestId("player-close");
expect(close.getAttribute("aria-label")).toBe("player.close");
expect(close.textContent).toBe("");
fireEvent.click(close);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom
// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the
// top. A cutout given only as a share of the height lay over that button on a short player (a small
// window, a browser zoomed to 150 %): the click paused instead.
describe("PlayerModal — the click layer over the video", () => {
const layers = () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
return screen.getAllByTestId("yt-click-layer");
};
const box = (el: HTMLElement) => {
const { left, right, width, top, bottom } = el.style;
return { left, right, width, top, bottom };
};
it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => {
const [picture, column] = layers();
expect(box(picture!)).toEqual({
left: "0px",
right: "88px",
width: "",
top: "max(12%, 56px)",
bottom: "max(22%, 104px)",
});
expect(box(column!)).toEqual({
left: "",
right: "0px",
width: "88px",
top: "max(12%, 56px)",
bottom: "max(22%, 140px)",
});
});
// In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's
// bottom, so a box measured from the stage's would start that bar's height too low again.
it.each([
["the windowed card", false, /(^|\s)relative(\s|$)/],
["true fullscreen", true, /(^|\s)bottom-14(\s|$)/],
])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => {
const els = layers();
const stage = document.querySelector(".player-stage")!;
if (fullscreen) {
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
get: () => stage,
});
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
}
try {
for (const el of screen.getAllByTestId("yt-click-layer")) {
const wrapper = el.parentElement!;
expect(wrapper).not.toBe(stage);
expect(wrapper.parentElement).toBe(stage);
expect(wrapper.className).toMatch(inset);
expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/);
}
expect(els).toHaveLength(2);
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// row. The card's width is capped from the window height less what sits under the video, measured.
describe("PlayerModal — fits the window's height", () => {
it("caps the card's width by the chrome measured under the video", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.classList.contains("player-stage") ? 470 : 0;
});
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.querySelector(":scope > .player-stage") ? 630 : 0;
});
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
// The owner's pick: on a wide window the video was capped at ~1008 px of a 1920 screen, with
// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side
// panel beside the video, and the video takes the window's height.
describe("PlayerModal — the side panel on a wide window", () => {
const wideWindow = (matches: boolean) =>
vi.spyOn(window, "matchMedia").mockImplementation(
(q: string) =>
({
matches: matches && q === "(min-width: 1280px)",
media: q,
addEventListener: () => {},
removeEventListener: () => {},
}) as unknown as MediaQueryList,
);
// jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or
// every later test's media query would read that bare stub.
let added = false;
beforeEach(() => {
if (typeof window.matchMedia === "function") return;
window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia;
added = true;
});
afterEach(() => {
// The spy goes first: restoring it later would put the bare stub back.
vi.restoreAllMocks();
if (added) delete (window as { matchMedia?: unknown }).matchMedia;
added = false;
});
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
const info = screen.getByTestId("player-info");
// The card is a row: the video, then the panel holding the title and the channel.
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
expect(card.contains(info) && !stage.contains(info)).toBe(true);
expect(info.contains(screen.getByRole("heading", { name: "Video A" }))).toBe(true);
expect(stage.compareDocumentPosition(info) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
// The row's width: the video at the window's height, plus the panel.
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
expect(name.className).toMatch(/\bmin-w-0\b/);
expect(name.className).toMatch(/\bflex-1\b/);
expect(name.className).not.toMatch(/\bshrink-0\b/);
// The description is cut to three lines, with a More once it is cut.
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
});
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
expect(screen.queryByTestId("player-info")).toBeNull();
const strip = screen.getByTestId("player-strip");
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
expect(document.activeElement).toBe(
within(strip).getByRole("button", { name: "player.expandPanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
// The strip keeps the way out and the stepping.
expect(within(strip).getByTestId("player-close")).toBeTruthy();
// The dialog keeps its name with the title out of sight: it is named by that heading's id.
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy();
fireEvent.click(
within(screen.getByTestId("player-strip")).getByRole("button", {
name: "player.expandPanel",
}),
);
expect(screen.getByTestId("player-info")).toBeTruthy();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "player.collapsePanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
});
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const text = await screen.findByText("A long description");
const panel = screen.getByTestId("player-info");
let top = 0;
Object.defineProperty(panel, "scrollTop", { get: () => top, configurable: true });
Object.defineProperty(panel, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(panel, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element, deltaY: number) => {
const e = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented; // prevented = the wheel went to the volume
};
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
top = 400;
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
// A panel that fits has nothing to scroll: the wheel is the volume there.
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
expect(spin(text, 100)).toBe(true);
});
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element) => {
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented;
};
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
expect(spin(stage)).toBe(true);
});
// A failed load read "No description." in the always-open panel — an error drawn as empty.
it("says the description failed to load, with a retry, instead of 'No description'", async () => {
wideWindow(true);
const detail = vi
.spyOn(api, "videoDetail")
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue({ description: "A long description" } as Awaited<
ReturnType<typeof api.videoDetail>
>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const panel = screen.getByTestId("player-description");
expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy();
expect(within(panel).queryByText("player.noDescription")).toBeNull();
fireEvent.click(within(panel).getByRole("button", { name: "common.retry" }));
expect(await within(panel).findByText("A long description")).toBeTruthy();
expect(detail).toHaveBeenCalledTimes(2);
});
// The popover below 1280 px said it the same way only by a copy nobody tested.
it("says it in the description popover below 1280 px too", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByRole("button", { name: "player.description" }));
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy();
expect(screen.queryByText("player.noDescription")).toBeNull();
});
it("keeps the details under the video below 1280 px", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const card = document.querySelector<HTMLElement>(".player-stage")!.parentElement!;
expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/);
expect(screen.getByRole("button", { name: "player.description" })).toBeTruthy();
expect(screen.queryByTestId("player-description")).toBeNull();
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's
// true-fullscreen button did nothing there. The window-filling view is then the biggest one.
describe("PlayerModal — the docked bar's fullscreen", () => {
const maximised = (api_: boolean) => {
if (api_)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
// Brave's stand-in otherwise: a <video>-only requestFullscreen on HTMLElement.prototype.
else
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByLabelText("player.fullscreen"));
return screen.getAllByLabelText("player.fullscreen").length;
};
afterEach(() => {
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
it("is offered where the browser can fullscreen an element", () => {
expect(maximised(true)).toBe(2);
});
it("is left out where it cannot", () => {
expect(maximised(false)).toBe(1);
});
});