diff --git a/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx b/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx index 62d7fc95..0a24a64c 100644 --- a/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx +++ b/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx @@ -74,6 +74,8 @@ describe("a season's name", () => { ["Season 1", 1, "plex.seasonTitle(1)"], ["Specials", 0, "plex.specials"], ["Book One: Water", 1, "Book One: Water"], + ["Season 4", null, "Season 4"], + ["", null, "plex.playlist.seasonUnknown"], ])("Plex's %j (season %s) reads %j", (title, n, shown) => { render(); expect(screen.getByText(shown)).toBeTruthy(); diff --git a/frontend/src/lib/notifText.contract.test.ts b/frontend/src/lib/notifText.contract.test.ts new file mode 100644 index 00000000..82f53a4e --- /dev/null +++ b/frontend/src/lib/notifText.contract.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import ts from "typescript"; +import { readSources } from "./rawSources.testkit"; + +// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says +// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is +// unknown, and i18next's options take any value — so the slips are caught here, on the syntax: +// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]"; +// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`) +// stays in the language it was raised in. + +const isT = (e: ts.Expression) => + (ts.isIdentifier(e) && e.text === "t") || + (ts.isPropertyAccessExpression(e) && e.name.text === "t"); +const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression => + ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text); +const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression); + +function mixedNotifText(name: string, src: string): string[] { + const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const out: string[] = []; + const at = (n: ts.Node, what: string) => + out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`); + const visit = (n: ts.Node) => { + if (isTCall(n)) { + for (const arg of n.arguments.slice(1)) { + if (!ts.isObjectLiteralExpression(arg)) continue; + for (const p of arg.properties) { + if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"])) + at(p, "a notice value handed to t()"); + } + } + } + if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0])) + at(n, "a said text kept in a notice"); + if ( + ts.isPropertyAssignment(n) && + ts.isIdentifier(n.name) && + n.name.text === "detail" && + isTCall(n.initializer) + ) + at(n, "a said text kept as a notice's detail"); + ts.forEachChild(n, visit); + }; + visit(file); + return out; +} + +describe("a notice's text and a plain t() do not mix", () => { + it("holds across src", () => { + const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]); + expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app + expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]); + }); + + it.each([ + ['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"], + ['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"], + ['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"], + ['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"], + ['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"], + ])("sees %s", (src, what) => { + expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]); + }); + + it.each([ + 'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })', + 't("channels.discovery.confirmSubscribe", { name: nameOf(c) })', + "sayNotif(msg(key), t)", + ])("lets %s through", (src) => { + expect(mixedNotifText("x.tsx", src)).toEqual([]); + }); +}); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 993abbf7..f2ff2875 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -12,10 +12,11 @@ import { log, type Level } from "./log"; // What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in // the reader's language each time it is shown. The text a caller translated at raise time was kept // in the history, so a language switch left every notice in the language it was raised in. `raw` is -// text that has no translation (a video's title, a name); `detail` is a server refusal, localized at -// display like the key. `notify` takes nothing else, and a value is no plain string either (a name -// goes in as `raw`, a translated word as its own `msg`), so a pre-translated string does not -// type-check anywhere in a notice — only `raw(t(…))` would say it once, and that reads as what it is. +// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched +// in, since the card holds only that one); `detail` is a server refusal, localized at display like +// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as +// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`, +// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses. // A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way. export type NotifValue = number | NotifText | { number: number } | { bytes: number }; export type NotifText = @@ -26,7 +27,7 @@ export type NotifText = /** The notice text for `t(key, values)`, said when it is shown. */ export const msg = (key: string, values?: Record): NotifText => values ? { key, values } : { key }; -/** Text with no translation of its own (a title, a name), shown as it is. */ +/** Text kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */ export const raw = (text: string): NotifText => ({ raw: text }); /** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */ diff --git a/frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx b/frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx new file mode 100644 index 00000000..42176e17 --- /dev/null +++ b/frontend/src/modules/channels/useWatchedChannelActions.confirm.dom.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +// +// Subscribing changes the reader's real YouTube account, so the confirm names the channel it +// subscribes to — in both languages, through the real i18n. A notice's value (`raw`, `msg`) is said +// only by sayNotif: handed to a plain `t()` it reads "[object Object]". +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import i18n from "../../i18n"; + +const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) })); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); + +import { useWatchedChannelActions } from "./useWatchedChannelActions"; +import type { WatchedChannel } from "../../lib/api"; + +afterEach(async () => { + cleanup(); + h.confirm.mockClear(); + await i18n.changeLanguage("en"); +}); + +const channel = { id: "UC1", title: "Kurzgesagt" } as WatchedChannel; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("the subscribe confirm", () => { + it.each(["en", "hu"])("names the channel (%s)", async (lng) => { + await i18n.changeLanguage(lng); + const { result } = renderHook(() => useWatchedChannelActions(() => {}), { wrapper }); + await act(() => result.current.onSubscribe(channel)); + const said = h.confirm.mock.lastCall![0].message; + expect(said).toContain("Kurzgesagt"); + expect(said).not.toContain("[object Object]"); + }); +}); diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts index 9017cc0e..d5c35772 100644 --- a/frontend/src/modules/channels/useWatchedChannelActions.ts +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -54,7 +54,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) { const onSubscribe = async (c: WatchedChannel): Promise => { const ok = await confirm({ title: t("channels.discovery.subscribe"), - message: t("channels.discovery.confirmSubscribe", { name: raw(nameOf(c)) }), + message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }), confirmLabel: t("channels.discovery.subscribe"), }); if (ok) subscribe.mutate(c); diff --git a/frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx b/frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx new file mode 100644 index 00000000..55da4e78 --- /dev/null +++ b/frontend/src/modules/users/AdminUsers.confirm.dom.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +// +// Deleting a user cannot be undone, so its confirm names the account — in both languages, through +// the real i18n. A notice's value (`raw`, `msg`) is said only by sayNotif: handed to a plain `t()` +// it reads "[object Object]". +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 i18n from "../../i18n"; + +const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) })); +vi.mock("../../lib/useMe", () => ({ + useMe: () => ({ + id: 1, + email: "admin@x", + role: "admin", + pending_invites: 0, + pending_activations: 0, + }), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); + +import AdminUsers from "./AdminUsers"; +import { api, type AdminUserRow } from "../../lib/api"; + +afterEach(async () => { + cleanup(); + vi.restoreAllMocks(); + h.confirm.mockClear(); + await i18n.changeLanguage("en"); +}); + +const row = { + id: 2, + email: "someone@example.org", + role: "user", + is_suspended: false, + email_verified: true, + is_active: true, +} as unknown as AdminUserRow; + +describe("the delete-user confirm", () => { + it.each(["en", "hu"])("names the account (%s)", async (lng) => { + await i18n.changeLanguage(lng); + vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never); + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: i18n.t("users.rowMenu") })); + fireEvent.click(await screen.findByText(i18n.t("users.delete.action"))); + await waitFor(() => expect(h.confirm).toHaveBeenCalled()); + const said = h.confirm.mock.lastCall![0].message; + expect(said).toContain("someone@example.org"); + expect(said).not.toContain("[object Object]"); + }); +}); diff --git a/frontend/src/modules/users/AdminUsers.tsx b/frontend/src/modules/users/AdminUsers.tsx index 54f1e7e3..bafdc152 100644 --- a/frontend/src/modules/users/AdminUsers.tsx +++ b/frontend/src/modules/users/AdminUsers.tsx @@ -209,7 +209,7 @@ function UsersRoles({ me }: { me: Me }) { const onDelete = async (u: AdminUserRow) => { const ok = await confirm({ title: t("users.delete.title"), - message: t("users.delete.confirm", { email: raw(u.email) }), + message: t("users.delete.confirm", { email: u.email }), confirmLabel: t("users.delete.action"), danger: true, });