Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Download Center's confirm dialogs name their ACTION on the confirm button (E11.5): the shared
|
|
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
|
|
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { DownloadJob } from "../lib/api";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
jobs: [] as unknown[],
|
|
confirm: vi.fn(async (_opts: { title: string; confirmLabel?: string }) => false),
|
|
}));
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
};
|
|
});
|
|
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
|
vi.mock("../lib/useLiveQuery", () => ({
|
|
useLiveQuery: (key: readonly unknown[]) => ({
|
|
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
|
isError: false,
|
|
isLoading: false,
|
|
refetch: vi.fn(),
|
|
}),
|
|
}));
|
|
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
|
|
// The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of.
|
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
|
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
import DownloadCenter from "./DownloadCenter";
|
|
|
|
const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
|
|
id,
|
|
status,
|
|
progress: 0,
|
|
speed_bps: null,
|
|
eta_s: null,
|
|
phase: null,
|
|
error: null,
|
|
error_code: null,
|
|
error_detail: null,
|
|
queue_pos: 0,
|
|
created_at: null,
|
|
source_kind: "youtube",
|
|
source_ref: `v${id}`,
|
|
source_url: null,
|
|
poster_url: null,
|
|
profile_id: null,
|
|
spec: {
|
|
mode: "av",
|
|
max_height: null,
|
|
container: null,
|
|
audio_format: null,
|
|
vcodec: null,
|
|
embed_subs: false,
|
|
embed_chapters: false,
|
|
embed_thumbnail: false,
|
|
sponsorblock: false,
|
|
},
|
|
display_name: `Clip ${id}`,
|
|
display_uploader: null,
|
|
display_uploader_url: null,
|
|
extra_links: [],
|
|
can_download: status === "done",
|
|
expired: false,
|
|
asset: null,
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
mocks.confirm.mockClear();
|
|
});
|
|
|
|
/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom
|
|
* (floating-ui never positions it), so the row is matched on its text. */
|
|
async function pick(row: string, label: string) {
|
|
const more = screen
|
|
.getByText(row)
|
|
.closest<HTMLElement>("[data-nav-index]")!
|
|
.querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
|
|
fireEvent.click(more);
|
|
const item = await waitFor(() => {
|
|
const found = screen
|
|
.getAllByRole("menuitem", { hidden: true })
|
|
.find((b) => b.textContent === label);
|
|
expect(found).toBeTruthy();
|
|
return found!;
|
|
});
|
|
fireEvent.click(item);
|
|
}
|
|
|
|
describe("DownloadCenter confirm dialogs", () => {
|
|
it("label the confirm button with the action, not the dialog's question", async () => {
|
|
mocks.jobs = [job(1, "running"), job(2, "done")];
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadCenter />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
// E28 C1: the destructive and rare row buttons live in the row's "…".
|
|
await pick("Clip 1", "downloads.actions.cancel");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
// The tab's label shares its button with a count badge.
|
|
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
|
|
await screen.findByText("Clip 2");
|
|
await pick("Clip 2", "downloads.actions.redownload");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
|
|
await pick("Clip 2", "downloads.actions.delete");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
|
|
|
|
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
|
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
|
|
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
|
|
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
|
]);
|
|
});
|
|
});
|