// @vitest-environment jsdom // // The card shows its state and the everyday toggles (watched, save, add to playlist) and a // "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view // carries the same set, and a view that shows the "…" twice (the card: in the hover row on the // picture, and beside the title for touch — only one is displayed per device) points both at the // same menu. 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("../navigation/navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }), })); import VideoCard from "./VideoCard"; import type { Video } from "../../lib/api"; import { FEED_VIEW_SPEC, 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; }); beforeEach(cleanup); const video = { id: "abc123", title: "Cicada 3301", channel_id: "c1", channel_title: "LEMMiNO", status: "new", saved: false, position_seconds: 40, 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; function renderCard(view: FeedView, menuOpen = false) { const buttonProps = vi.fn(() => ({ "aria-haspopup": "menu" })); render( {}} onToggleSave={() => {}} onOpen={() => {}} menuButtonProps={buttonProps} menuOpen={menuOpen} /> , ); return { buttonProps }; } const names = () => screen.getAllByRole("button").map((b) => b.getAttribute("aria-label")); describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions", (view) => { it("are the toggles and the '…' — nothing that belongs to the menu", () => { renderCard(view); const all = names(); for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"]) expect(all).toContain(want); expect(all).toContain("card.menu.more"); for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"]) expect(all).not.toContain(gone); }); it("points every '…' at the menu, and says when it is open", () => { const { buttonProps } = renderCard(view, true); const mores = screen.getAllByRole("button", { name: "card.menu.more" }); expect(buttonProps).toHaveBeenCalledWith(video); for (const m of mores) { expect(m.getAttribute("aria-haspopup")).toBe("menu"); expect(m.getAttribute("aria-expanded")).toBe("true"); } }); }); // The owner's pick (2026-09-26): the compact row had a leading action column that sat // empty at rest. Now the title leads, the "…" is always at the row's end, and on hover the quick // toggles float in beside it over the date, which steps aside. describe("the compact row (rowsCompact)", () => { it("leads with the title and ends with the '…'; the toggles reveal beside it over a yielding date", () => { renderCard("rowsCompact"); const row = screen.getByTestId("video-card"); expect(row.firstElementChild!.textContent).toBe("Cicada 3301"); const more = screen.getByRole("button", { name: "card.menu.more" }); // the layer that floats left of the "…" (right-full) and shows on hover only const toggles = screen .getByRole("button", { name: "card.markWatched" }) .closest(".right-full"); expect(toggles!.className).toContain("reveal-hover"); // the toggles and the "…" share the row's last slot (the resume bar is drawn after it) const slot = more.parentElement!; expect(slot.contains(toggles)).toBe(true); const inFlow = [...row.children].filter((c) => !c.className.includes("absolute")); expect(inFlow[inFlow.length - 1]).toBe(slot); expect(row.querySelector(".w-48")!.className).toContain("group-hover:opacity-0"); }); }); // On a shelf (Home's strips) the card is one title line over the channel alone, so every card on // the strip is the same height (the owner's shelf density). describe("the card on a shelf", () => { function renderSmall(shelf: boolean) { render( {}} onToggleSave={() => {}} /> , ); return screen.getByTestId("video-card"); } const titleOf = (card: HTMLElement) => [...card.querySelectorAll("*")].find( (e) => e.childElementCount === 0 && e.textContent === "Cicada 3301", )!; it("cuts the title to one line and drops the views · age line", () => { const card = renderSmall(true); const title = titleOf(card).closest(".font-medium")!; expect(title.className).toContain("truncate"); expect(title.className).not.toContain("line-clamp-2"); expect(card.querySelector("time[title]")).toBeNull(); expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy(); }); it("off a shelf keeps two title lines and the age", () => { const card = renderSmall(false); expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2"); expect(card.querySelector("time[title]")).not.toBeNull(); }); });