A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
// @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<typeof import("react-i18next")>()),
|
|
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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoCard
|
|
video={video}
|
|
view={view}
|
|
width={0}
|
|
onState={() => {}}
|
|
onToggleSave={() => {}}
|
|
onOpen={() => {}}
|
|
menuButtonProps={buttonProps}
|
|
menuOpen={menuOpen}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
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<HTMLElement>(".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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoCard
|
|
video={video}
|
|
view="cardsSmall"
|
|
width={0}
|
|
shelf={shelf}
|
|
onState={() => {}}
|
|
onToggleSave={() => {}}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
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();
|
|
});
|
|
});
|