diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 1f7e1be9..77068885 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -160,7 +160,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
- {t("downloads.share.views", { n: link.view_count })} + {t("downloads.share.views", { count: link.view_count })} {badges.map((b) => ( {d === null ? t("downloads.share.expiryNever") - : t("downloads.share.days", { n: d })} + : t("downloads.share.days", { count: d })} ))}
diff --git a/frontend/src/components/useVideoActions.dom.test.tsx b/frontend/src/components/useVideoActions.dom.test.tsx new file mode 100644 index 00000000..e6a63d9b --- /dev/null +++ b/frontend/src/components/useVideoActions.dom.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// +// E28 review J4: the video menu's download row was labelled with the download's STATE ("Downloaded", +// "In queue") — a row that reads as a status yet opens the download dialog. It is "Download…" now, +// the state riding along as the row's hint. +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k }), +})); +vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) })); +vi.mock("./comfortPin", () => ({ + pinVideo: vi.fn(), + useComfortPin: () => ({ item: () => [] }), +})); + +import { api, type Video } from "../lib/api"; +import { useVideoActions } from "./useVideoActions"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const video = { id: "v1", title: "A video", status: "new", position_seconds: 0 } as Video; + +function downloadRow(state: string | undefined) { + vi.spyOn(api, "downloadIndex").mockResolvedValue( + (state ? { v1: state } : {}) as Awaited>, + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook( + () => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true), + { wrapper }, + ); + return () => result.current.items(video, null).find((i) => i.id === "download")!; +} + +describe("the video menu's download row", () => { + it("says what it does, whatever the state, and carries the state as its hint", async () => { + const done = downloadRow("done"); + await waitFor(() => expect(done().title).toBe("downloads.button.downloaded")); + expect(done().label).toBe("downloads.menu.download"); + cleanup(); + const none = downloadRow(undefined); + await waitFor(() => expect(none().label).toBe("downloads.menu.download")); + expect(none().title).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/useVideoActions.tsx b/frontend/src/components/useVideoActions.tsx index 8f90026b..bcd1fb2f 100644 --- a/frontend/src/components/useVideoActions.tsx +++ b/frontend/src/components/useVideoActions.tsx @@ -74,12 +74,14 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { const hidden = v.status === "hidden"; const inProgress = v.position_seconds > 0 && !watched; const dl = index.data?.[v.id]; - const dlLabel = + // The row opens the download dialog, so it says so; the download's state is its hint, not its + // label — "Downloaded" read as a status, not as a row that opens something (E28 review J4). + const dlState = dl === "done" ? t("downloads.button.downloaded") : dl === "queued" || dl === "running" || dl === "paused" ? t("downloads.button.queued") - : t("downloads.button.label"); + : undefined; // The two panels open beside the object; a menu opened with no element to anchor to (none of // the triggers does that today) leaves them out rather than opening them at the corner. const at = (kind: "playlist" | "details") => @@ -140,7 +142,8 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { ? [ { id: "download", - label: dlLabel, + label: t("downloads.menu.download"), + title: dlState, icon: , onSelect: () => setFollowUp({ kind: "download", video: v }), }, diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 59e51318..a385c9ce 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -1,5 +1,8 @@ { "navLabel": "Downloads", + "menu": { + "download": "Download…" + }, "button": { "label": "Download", "queued": "In queue", @@ -124,13 +127,15 @@ "allowDownload": "Allow download", "expiry": "Expires", "expiryNever": "Never", - "days": "{{n}} days", + "days_one": "{{count}} day", + "days_other": "{{count}} days", "password": "Password (optional)", "copy": "Copy link", "copyFailed": "Couldn’t copy", "revoke": "Revoke", "revokeConfirm": "This link will stop working immediately.", - "views": "{{n}} views", + "views_one": "{{count}} view", + "views_other": "{{count}} views", "expiresOn": "expires {{date}}", "hasPassword": "password", "downloadable": "downloadable", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 9871919a..0e9c92e9 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -1,5 +1,8 @@ { "navLabel": "Letöltések", + "menu": { + "download": "Letöltés…" + }, "button": { "label": "Letöltés", "queued": "Sorban", @@ -124,13 +127,15 @@ "allowDownload": "Letöltés engedélyezése", "expiry": "Lejárat", "expiryNever": "Soha", - "days": "{{n}} nap", + "days_one": "{{count}} nap", + "days_other": "{{count}} nap", "password": "Jelszó (opcionális)", "copy": "Link másolása", "copyFailed": "Nem sikerült másolni", "revoke": "Visszavonás", "revokeConfirm": "A link azonnal használhatatlanná válik.", - "views": "{{n}} megtekintés", + "views_one": "{{count}} megtekintés", + "views_other": "{{count}} megtekintés", "expiresOn": "lejár: {{date}}", "hasPassword": "jelszó", "downloadable": "letölthető", diff --git a/frontend/src/i18n/plurals.test.ts b/frontend/src/i18n/plurals.test.ts new file mode 100644 index 00000000..24c3adc2 --- /dev/null +++ b/frontend/src/i18n/plurals.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +// +// E28 review J5: a count in copy goes through i18next's plural forms (`count`), never a bare `n` — +// the share dialog's expiry read "1 days". +import { describe, expect, it } from "vitest"; + +import i18n from "./index"; + +describe("counted copy", () => { + it("the share link's expiry is singular for one day", async () => { + await i18n.changeLanguage("en"); + expect(i18n.t("downloads.share.days", { count: 1 })).toBe("1 day"); + expect(i18n.t("downloads.share.days", { count: 7 })).toBe("7 days"); + await i18n.changeLanguage("hu"); + expect(i18n.t("downloads.share.days", { count: 1 })).toBe("1 nap"); + await i18n.changeLanguage("en"); + }); + + it("a link's view count is singular for one view", async () => { + await i18n.changeLanguage("en"); + expect(i18n.t("downloads.share.views", { count: 1 })).toBe("1 view"); + expect(i18n.t("downloads.share.views", { count: 0 })).toBe("0 views"); + }); +});