Files
siftlode/frontend/src/components/PlayerModal.dom.test.tsx
T
peter b40440b825 fix(ui): review round 2 — reveal floor, failed next page, count states, row Play
- DataTable: the reveal floor holds only in the view it was made for (Channels
  never clears its follow, so every later sort mounted rows up to it)
- Feed + Plex browse: a failed next page stops the read-ahead and shows a row
  that retries, instead of re-requesting it once a round-trip
- the YouTube Load more click uses nextPageOnClick; a contract pins NEXT_PAGE
  to the read-aheads and the click sites to nextPageOnClick
- ItemCount: a failed count says so; a stale one says updating to a screen reader
- the narrow-picture Play lift applies only where a hover row is there to clear
- PlayerModal: one description ladder for the popover and the panel, both tested
- plexImage contract reads every img src, any attribute order
- Tabs comment names who persists the sub-tab
2026-10-03 02:25:15 +02:00

592 lines
25 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 deep-review of 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) -----------------------------------------
// Until E11 this was 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 (deep-review round 7).
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");
});
});
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
describe("PlayerModal — the side arrows", () => {
it("stand inside the row's gutter", () => {
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 arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
(b) => b.className.includes("w-12"),
)!;
expect(arrow).toBeTruthy();
expect(arrow.className).toMatch(/\bright-0\b/);
expect(arrow.className).not.toMatch(/left-full/);
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
});
});
// E31 S1: 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(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
});
});
// E31 (the owner's pick "A"): 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,
);
beforeEach(() => {
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
});
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 and the side strips.
expect(card.parentElement!.style.maxWidth).toBe(
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
);
});
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", 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(true); // at its top: nothing to scroll up, so the volume
top = 400;
expect(spin(text, 100)).toBe(true); // at its end: the volume
expect(spin(text, -100)).toBe(false);
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
});
// E31 S5: 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);
});
// E31 review 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();
});
});