The value sweep wrapped two plain t() values in raw(), so the delete-user and the YouTube subscribe confirms read "[object Object]" where the email or the channel was. tsc cannot see that seam, so a syntax contract now refuses a notice value handed to t() and an already-said text kept in a notice. The raw() doc says what it is: text kept as fetched (a Plex title in the language it was fetched in). seasonTitleText's no-number branches are tested.
74 lines
3.3 KiB
TypeScript
74 lines
3.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|