Files
siftlode/frontend/src/components/VideoCard.actions.dom.test.tsx
T
peter bd6aaa1e43 feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
2026-09-27 05:46:17 +02:00

156 lines
5.9 KiB
TypeScript

// @vitest-environment jsdom
//
// E28 C1: 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("./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");
}
});
});
// E28 S3h, the owner's pick B (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 (E28 S3h, 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();
});
});