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.
781 lines
34 KiB
TypeScript
781 lines
34 KiB
TypeScript
// @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);
|
||
});
|
||
});
|