- PlexInfo's collection strip gives each card the related strip's action set: the "…" menu (with Restart for a touch reader), the watched toggle and the playlist add. - `onPlayItem` is required, so no variant draws a Play that does nothing; the player's info overlay plays the sibling in the running player (`go(id, _, fromZero)` for its Restart).
534 lines
21 KiB
TypeScript
534 lines
21 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
|
|
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
|
|
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
|
|
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
|
|
// PlexPlayer.session.dom.test.tsx, and where the player asks a session to start is the observable.
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/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, o?: { n?: number; d?: string }) =>
|
|
o?.d != null ? `${key}:${o.d}` : o?.n != null ? `${key}:${o.n}` : key,
|
|
i18n: { language: "en" },
|
|
}),
|
|
};
|
|
});
|
|
|
|
import PlexPlayer from "./PlexPlayer";
|
|
import { api, type PlexItemDetail } from "../lib/api";
|
|
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
|
|
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
// jsdom has no PointerEvent, and the player tells a finger from a mouse by `pointerType`.
|
|
class PointerEventStub extends MouseEvent {
|
|
pointerType: string;
|
|
constructor(type: string, init: PointerEventInit = {}) {
|
|
super(type, init);
|
|
this.pointerType = init.pointerType ?? "mouse";
|
|
}
|
|
}
|
|
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
|
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
|
configurable: true,
|
|
get(this: Media) {
|
|
return this._t ?? 0;
|
|
},
|
|
set(this: Media, v: number) {
|
|
this._t = v;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
|
|
configurable: true,
|
|
get(this: Media) {
|
|
return this._paused ?? true;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
|
configurable: true,
|
|
value(this: Media) {
|
|
this._t = 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
|
configurable: true,
|
|
get: () => ({ length: 0, end: () => 0 }),
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
|
configurable: true,
|
|
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
|
});
|
|
});
|
|
|
|
let play: ReturnType<typeof vi.fn>;
|
|
let pause: ReturnType<typeof vi.fn>;
|
|
beforeEach(() => {
|
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
|
play = vi.fn(function (this: Media) {
|
|
this._paused = false;
|
|
this.dispatchEvent(new Event("play"));
|
|
return Promise.resolve();
|
|
});
|
|
pause = vi.fn(function (this: Media) {
|
|
this._paused = true;
|
|
this.dispatchEvent(new Event("pause"));
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value: play });
|
|
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value: pause });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
sessionStorage.clear();
|
|
localStorage.clear();
|
|
});
|
|
|
|
const item = (id: string, position = 0, next: string | null = null): PlexItemDetail =>
|
|
({
|
|
id,
|
|
title: `Title ${id}`,
|
|
type: "movie",
|
|
duration_seconds: 3000,
|
|
status: "new",
|
|
position_seconds: position,
|
|
next_id: next,
|
|
prev_id: null,
|
|
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
|
subtitle_streams: [],
|
|
markers: [],
|
|
}) as unknown as PlexItemDetail;
|
|
|
|
const direct = (id: string) =>
|
|
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
|
ReturnType<typeof api.plexSession>
|
|
>;
|
|
|
|
function mount(opts: {
|
|
details: Record<string, PlexItemDetail>;
|
|
queue?: string[];
|
|
first?: string;
|
|
fromStart?: boolean;
|
|
}) {
|
|
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
|
const session = vi
|
|
.spyOn(api, "plexSession")
|
|
.mockImplementation((id: string) => Promise.resolve(direct(id)));
|
|
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { session };
|
|
}
|
|
|
|
const video = () => document.querySelector("video") as Media;
|
|
|
|
/** Which item the player last asked a session for, and where in it. */
|
|
const lastStart = (session: { mock: { calls: unknown[][] } }) =>
|
|
session.mock.calls[session.mock.calls.length - 1]?.slice(0, 2);
|
|
|
|
async function playingAt(src: string, t: number) {
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
|
const v = video();
|
|
v._t = t;
|
|
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
|
}
|
|
|
|
/** The bottom band (the seek bar and the row): hidden = faded AND out of the pointer's way. */
|
|
const bandHidden = () =>
|
|
screen
|
|
.getByLabelText("plex.player.playPause")
|
|
.closest("[class*='bottom-0']")!
|
|
.className.includes("opacity-0");
|
|
|
|
/** A press on the picture, from a finger or a mouse, at `x` of a 900px-wide picture. */
|
|
function pressPicture(pointerType: "touch" | "mouse", x = 450) {
|
|
const v = video();
|
|
vi.spyOn(v, "getBoundingClientRect").mockReturnValue({
|
|
left: 0,
|
|
top: 0,
|
|
width: 900,
|
|
height: 500,
|
|
right: 900,
|
|
bottom: 500,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
});
|
|
fireEvent.pointerDown(v, { pointerType });
|
|
fireEvent.click(v, { clientX: x, clientY: 250 });
|
|
}
|
|
|
|
// A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has
|
|
// measured it (never, under jsdom), and a hidden element's accessible name is empty.
|
|
const menuItem = (label: string) =>
|
|
waitFor(() => {
|
|
const row = screen
|
|
.queryAllByRole("menuitem", { hidden: true })
|
|
.find((b) => b.textContent === label);
|
|
expect(row).toBeDefined();
|
|
return row!;
|
|
});
|
|
|
|
describe("a tap on the picture (C3)", () => {
|
|
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
const plays = play.mock.calls.length;
|
|
const pauses = pause.mock.calls.length;
|
|
|
|
pressPicture("touch");
|
|
expect(bandHidden()).toBe(true);
|
|
pressPicture("touch");
|
|
expect(bandHidden()).toBe(false);
|
|
expect(play.mock.calls.length).toBe(plays);
|
|
expect(pause.mock.calls.length).toBe(pauses);
|
|
|
|
pressPicture("mouse");
|
|
expect(play.mock.calls.length + pause.mock.calls.length).toBe(plays + pauses + 1);
|
|
});
|
|
|
|
it("the hide timer starts with playback, not with the first touch", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
vi.useFakeTimers();
|
|
video()._paused = true;
|
|
act(() => void video().play());
|
|
expect(bandHidden()).toBe(false);
|
|
act(() => vi.advanceTimersByTime(3000));
|
|
expect(bandHidden()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// E28 review E6: the volume slider counted as a text field, so its arrows never woke the bands — the
|
|
// bar faded from under a keyboard user adjusting it — and nothing held the bands while a key-focused
|
|
// control sat in them.
|
|
describe("keyboard focus in the bands", () => {
|
|
it("an arrow on the volume slider wakes the bands", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
pressPicture("touch"); // hide
|
|
expect(bandHidden()).toBe(true);
|
|
fireEvent.keyDown(screen.getByLabelText("plex.player.volume"), { key: "ArrowUp" });
|
|
expect(bandHidden()).toBe(false);
|
|
});
|
|
|
|
it("the bands stay while a key-focused control is in them, and fade 3 s after it leaves", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
vi.useFakeTimers();
|
|
video()._paused = true;
|
|
act(() => void video().play()); // playing: the timer may hide the bands
|
|
const vol = screen.getByLabelText("plex.player.volume");
|
|
fireEvent.keyDown(document.body, { key: "Tab" }); // the keyboard is in use: focus shows
|
|
act(() => vol.focus());
|
|
fireEvent.keyDown(vol, { key: "ArrowUp" });
|
|
act(() => vi.advanceTimersByTime(5000));
|
|
expect(bandHidden()).toBe(false);
|
|
act(() => vol.blur());
|
|
act(() => vi.advanceTimersByTime(3000));
|
|
expect(bandHidden()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("a double-tap on a side third skips 10 s (C5)", () => {
|
|
it("skips from where it plays, again for each further tap, and takes back the first tap's toggle", async () => {
|
|
const { session } = mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 1500);
|
|
expect(bandHidden()).toBe(false);
|
|
|
|
pressPicture("touch", 800);
|
|
expect(bandHidden()).toBe(true); // the first tap alone is a toggle…
|
|
pressPicture("touch", 800);
|
|
expect(bandHidden()).toBe(false); // …which the second takes back
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1510]));
|
|
expect(screen.getByText("plex.player.tapForward:10 sec")).toBeTruthy();
|
|
|
|
pressPicture("touch", 800);
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
|
|
expect(screen.getByText("plex.player.tapForward:20 sec")).toBeTruthy();
|
|
|
|
pressPicture("touch", 50); // the other side, a fresh first tap: a toggle, no skip
|
|
expect(lastStart(session)).toEqual(["A", 1520]);
|
|
});
|
|
|
|
it("the middle third never skips: a quick second tap there just toggles back", async () => {
|
|
const { session } = mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 1500);
|
|
const calls = session.mock.calls.length;
|
|
pressPicture("touch", 450);
|
|
pressPicture("touch", 450);
|
|
expect(bandHidden()).toBe(false);
|
|
expect(session.mock.calls.length).toBe(calls);
|
|
});
|
|
|
|
// E28 review E5: the tap that brings the bands back must not hand the SECOND tap to them — a double-tap
|
|
// at the bottom would land on the seek bar (a seek to wherever the finger was) instead of the skip.
|
|
it("a tap that reveals the bands keeps them out of the finger's way for the double-tap window", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 1500);
|
|
const band = () =>
|
|
screen.getByLabelText("plex.player.playPause").closest("[class*='bottom-0']")!.className;
|
|
pressPicture("touch", 450); // hide
|
|
vi.useFakeTimers();
|
|
pressPicture("touch", 450); // reveal
|
|
expect(bandHidden()).toBe(false);
|
|
expect(band()).toContain("pointer-events-none");
|
|
act(() => vi.advanceTimersByTime(300));
|
|
expect(band()).not.toContain("pointer-events-none");
|
|
});
|
|
|
|
it("the left third skips back", async () => {
|
|
const { session } = mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 1500);
|
|
pressPicture("touch", 100);
|
|
pressPicture("touch", 100);
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1490]));
|
|
expect(screen.getByText("plex.player.tapBack:10 sec")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// E28 review D1: the info overlay drew its collection siblings with a Play that did nothing — the
|
|
// overlay had no way to play one. It plays it in this player, at its resume point or from the start.
|
|
describe("a collection sibling in the info overlay", () => {
|
|
const withSibling = (id: string): PlexItemDetail => ({
|
|
...item(id),
|
|
cast: [],
|
|
genres: [],
|
|
directors: [],
|
|
collections: [
|
|
{
|
|
id: "c1",
|
|
title: "Saga",
|
|
source: "plex",
|
|
items: [
|
|
{
|
|
id: "B",
|
|
type: "movie",
|
|
title: "Title B",
|
|
thumb: "",
|
|
status: "in_progress",
|
|
position_seconds: 300,
|
|
duration_seconds: 3000,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
it("plays its Restart in this player, from the start", { timeout: 10000 }, async () => {
|
|
const { session } = mount({ details: { A: withSibling("A"), B: item("B", 300) } });
|
|
await playingAt("/media/A", 100);
|
|
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
|
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
|
// The overlay is a lazy chunk: its first import takes a while under the test runner.
|
|
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
|
|
fireEvent.click(restart);
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
|
|
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
|
|
});
|
|
});
|
|
|
|
describe("the C5 row", () => {
|
|
it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
for (const gone of [
|
|
"plex.player.stop",
|
|
"plex.player.rewind10",
|
|
"plex.player.forward10",
|
|
"plex.player.stats.title",
|
|
"plex.player.infoBtn",
|
|
"plex.player.help",
|
|
"plex.player.download",
|
|
"plex.player.settings",
|
|
])
|
|
expect(screen.queryByLabelText(gone), gone).toBeNull();
|
|
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
|
const rows = [
|
|
"plex.player.settings",
|
|
"plex.player.infoBtn",
|
|
"plex.player.stats.title",
|
|
"plex.player.help",
|
|
"plex.player.download",
|
|
];
|
|
const menu = await screen.findByRole("menu", { hidden: true });
|
|
expect(
|
|
[...menu.querySelectorAll('[role="menuitem"]')].map(
|
|
(b) => b.getAttribute("aria-label") ?? b.textContent,
|
|
),
|
|
).toEqual(rows);
|
|
// A row closes the menu and opens its tool: Settings → the tuning panel.
|
|
fireEvent.click(await menuItem("plex.player.settings"));
|
|
await screen.findByRole("dialog", { name: "plex.player.settings" });
|
|
expect(
|
|
screen
|
|
.queryAllByRole("menuitem", { hidden: true })
|
|
.find((b) => b.textContent === "plex.player.stats.title") ?? null,
|
|
).toBeNull();
|
|
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
// E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
|
|
// closed it with the focus left on <body>. It is a menu on the popover engine now.
|
|
it("walks the ⋯ with the arrows, and Escape gives the focus back to the ⋯", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
const more = screen.getByLabelText("plex.player.more");
|
|
more.focus();
|
|
fireEvent.click(more);
|
|
const menu = await screen.findByRole("menu", { hidden: true });
|
|
const rows = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
|
|
await waitFor(() => expect(document.activeElement).toBe(rows[0]));
|
|
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
|
|
expect(document.activeElement).toBe(rows[1]);
|
|
fireEvent.keyDown(document.activeElement!, { key: "End" });
|
|
expect(document.activeElement).toBe(rows[rows.length - 1]);
|
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
|
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
|
await waitFor(() => expect(document.activeElement).toBe(more));
|
|
});
|
|
|
|
it("gives the focus back to the ⋯ when the tuning panel closes from inside", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
const more = screen.getByLabelText("plex.player.more");
|
|
fireEvent.click(more);
|
|
fireEvent.click(await menuItem("plex.player.settings"));
|
|
const panel = await screen.findByRole("dialog", { name: "plex.player.settings" });
|
|
// Into the panel on open…
|
|
await waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
|
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
// …and back on the ⋯ when it goes, not on <body>.
|
|
await waitFor(() => expect(document.activeElement).toBe(more));
|
|
});
|
|
|
|
// E28 review E4: an episode's Next comes from its detail, which is unknown while the next episode
|
|
// loads — the button unmounted from under the focus that had just pressed it (focus on <body>).
|
|
it("Next keeps the focus while the next item's detail loads", async () => {
|
|
let arrive: (d: PlexItemDetail) => void = () => {};
|
|
mount({ details: { A: item("A", 0, "B") } });
|
|
vi.mocked(api.plexItem).mockImplementation((id: string) =>
|
|
id === "A"
|
|
? Promise.resolve(item("A", 0, "B"))
|
|
: new Promise<PlexItemDetail>((r) => (arrive = r)),
|
|
);
|
|
await playingAt("/media/A", 100);
|
|
const next = screen.getByLabelText("plex.player.next");
|
|
act(() => next.focus());
|
|
fireEvent.click(next);
|
|
await waitFor(() => expect(api.plexItem).toHaveBeenCalledWith("B", expect.anything()));
|
|
expect(document.activeElement).toBe(next);
|
|
expect(next.getAttribute("aria-disabled")).toBe("true");
|
|
await act(async () => arrive(item("B", 0, "C")));
|
|
expect(document.activeElement).toBe(screen.getByLabelText("plex.player.next"));
|
|
});
|
|
|
|
it("Next shows only when there is a next item", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
expect(screen.queryByLabelText("plex.player.next")).toBeNull();
|
|
cleanup();
|
|
mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"] });
|
|
await playingAt("/media/A", 100);
|
|
expect(screen.getByLabelText("plex.player.next")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("Back goes by position (C5)", () => {
|
|
it("more than 3 s in: back to the start of this item", async () => {
|
|
// B opened at its resume point, so a session asked at 0 can only be the restart.
|
|
const { session } = mount({
|
|
details: { A: item("A"), B: item("B", 900) },
|
|
queue: ["A", "B"],
|
|
first: "B",
|
|
});
|
|
await playingAt("/media/B", 40);
|
|
fireEvent.click(screen.getByLabelText("plex.player.restart"));
|
|
// Inside the loaded media the restart is a native seek: the clock goes to 0, on B.
|
|
await waitFor(() => expect(video()._t).toBe(0));
|
|
expect(lastStart(session)?.[0]).toBe("B");
|
|
});
|
|
|
|
it("within the first 3 s: the previous item, at its resume point", async () => {
|
|
const { session } = mount({
|
|
details: { A: item("A", 700), B: item("B") },
|
|
queue: ["A", "B"],
|
|
first: "B",
|
|
});
|
|
await playingAt("/media/B", 2);
|
|
fireEvent.click(screen.getByLabelText("plex.player.prev"));
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["A", 700]));
|
|
});
|
|
|
|
it("the first item has only the restart", async () => {
|
|
const { session } = mount({ details: { A: item("A", 900), B: item("B") }, queue: ["A", "B"] });
|
|
await playingAt("/media/A", 2);
|
|
expect(screen.queryByLabelText("plex.player.prev")).toBeNull();
|
|
fireEvent.click(screen.getByLabelText("plex.player.restart"));
|
|
await waitFor(() => expect(video()._t).toBe(0));
|
|
expect(lastStart(session)?.[0]).toBe("A");
|
|
});
|
|
});
|
|
|
|
describe("the card's Restart (C5)", () => {
|
|
it("opens the item at 0 instead of its resume point", async () => {
|
|
const { session } = mount({ details: { A: item("A", 900) }, fromStart: true });
|
|
await waitFor(() => expect(session).toHaveBeenCalled());
|
|
expect(session.mock.calls[0]![1]).toBe(0);
|
|
});
|
|
|
|
it("without it, the item resumes", async () => {
|
|
const { session } = mount({ details: { A: item("A", 900) } });
|
|
await waitFor(() => expect(session).toHaveBeenCalled());
|
|
expect(session.mock.calls[0]![1]).toBe(900);
|
|
});
|
|
|
|
it("only for the item it opened with: the next one resumes", async () => {
|
|
const { session } = mount({
|
|
details: { A: item("A", 900), B: item("B", 300) },
|
|
queue: ["A", "B"],
|
|
fromStart: true,
|
|
});
|
|
await playingAt("/media/A", 100);
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["B", 300]));
|
|
});
|
|
});
|
|
|
|
describe("the seek bar (C3)", () => {
|
|
it("is a 2.75rem hit area a finger drags without panning, with the head shown on touch", async () => {
|
|
mount({ details: { A: item("A") } });
|
|
await playingAt("/media/A", 100);
|
|
const hit = document.querySelector<HTMLElement>(".touch-none.h-11");
|
|
expect(hit).not.toBeNull();
|
|
const head = hit!.querySelector("[class*='rounded-full'][class*='w-3']");
|
|
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
|
});
|
|
});
|