// @vitest-environment jsdom // // The video card's half of keyboard navigation (`lib/listNav`). Every layout's ROOT carries the // list index — that is the element `j`/`k` focus and ring — and the title link is the card's `open` // element, so Enter on a selected card runs the very click a mouse would: the in-app player. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }), })); import VideoCard from "./VideoCard"; import type { Video } from "../lib/api"; import type { FeedView } from "../lib/feedView"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); const video = { id: "abc123", title: "Cicada 3301", channel_id: "UCsXVk37bltHxD1rDPwtNM8Q", channel_title: "LEMMiNO", status: "new", saved: false, position_seconds: 0, duration_seconds: 1200, published_at: "2026-01-01T00:00:00Z", watch_url: "https://www.youtube.com/watch?v=abc123", thumbnail_url: "", } as unknown as Video; beforeEach(cleanup); function renderCard(view: FeedView, navIndex?: number, onOpen = vi.fn()) { render( {}} onToggleSave={() => {}} onOpen={onOpen} /> , ); return onOpen; } // One per layout FAMILY: the stacked card, the thumbnailed row, the one-line row — three roots. const VIEWS: FeedView[] = ["cards", "rows", "rowsCompact"]; describe("VideoCard — keyboard navigation hooks", () => { for (const view of VIEWS) { it(`puts the list index on the ${view} root and opens in-app from the title`, () => { const onOpen = renderCard(view, 7); const root = screen.getByTestId("video-card"); expect(root.getAttribute("data-nav-index")).toBe("7"); const open = root.querySelectorAll("[data-nav-open]"); expect(open).toHaveLength(1); expect(open[0]!.textContent).toBe("Cicada 3301"); (open[0] as HTMLElement).click(); expect(onOpen).toHaveBeenCalledWith(video); }); } it("carries no index outside a navigable list (Home's rails)", () => { renderCard("cards"); expect(screen.getByTestId("video-card").hasAttribute("data-nav-index")).toBe(false); }); }); // The thumbnail link's only text is the duration badge, so each card's first Tab stop was // announced as "20:00". It is named by the title, like the title link beside it. describe("VideoCard — the thumbnail link", () => { it("is named by the video's title, not its duration", () => { renderCard("cards"); const links = screen.getAllByRole("link", { name: "Cicada 3301" }); expect(links.length).toBeGreaterThanOrEqual(2); expect(screen.queryByRole("link", { name: "20:00" })).toBeNull(); }); }); // "Text stays selectable": the mouseup that ends selecting the title's words fires a click on // the title; that click must not open the player (nor follow the link). describe("VideoCard — a title you select", () => { it("does not open when the click ends a selection inside it, and still opens on a plain click", () => { const onOpen = renderCard("cards"); const title = screen.getByTestId("video-card").querySelector("[data-nav-open]") as HTMLElement; // Both, or Chrome starts no selection on a link (measured in a real browser). expect(title.getAttribute("draggable")).toBe("false"); expect(title.classList.contains("select-text")).toBe(true); const range = document.createRange(); range.selectNodeContents(title); window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range); const click = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, detail: 1, }); title.dispatchEvent(click); expect(onOpen).not.toHaveBeenCalled(); expect(click.defaultPrevented).toBe(true); window.getSelection()!.removeAllRanges(); title.click(); expect(onOpen).toHaveBeenCalledWith(video); }); });