diff --git a/src/__tests__/translate-probe-guard.test.ts b/src/__tests__/translate-probe-guard.test.ts new file mode 100644 index 000000000..19896de8a --- /dev/null +++ b/src/__tests__/translate-probe-guard.test.ts @@ -0,0 +1,96 @@ +/** + * Whether the catalogue has a sentence is asked in one place. + * + * `t()` answers a missing key with the key itself, so "does this code have a + * sentence" is a comparison of the answer with the question. Written inline it + * was copied once per surface that wanted a code's sentence or else a + * fallback. It lives in `src/lib/i18n/translate-code.ts` now + * (`translateOrNull`, `translateCode`), and this guard refuses the comparison + * anywhere else under `src/`. + * + * Two shapes are read: a translation held in a variable and compared with the + * key it was asked for (`const v = t(key); v === key`), and the inline form + * (`t(k) !== k`). The helper itself must match, so a matcher that stops + * finding the shape fails instead of passing over nothing. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); +const HELPER = "lib/i18n/translate-code.ts"; + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "__tests__" || entry.name === "generated") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path, out); + else if (/\.tsx?$/.test(entry.name) && !entry.name.includes(".test.")) + out.push(path); + } + return out; +} + +function escape(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** The probes in one source text, one line each. */ +function probes(source: string): string[] { + const found: string[] = []; + // Inline: `t(K) !== K` / `t(K, p) === K`, K an identifier or a template. + const inline = + /\bt\(\s*(`[^`]*`|[A-Za-z_$][\w$]*)\s*(?:,[^()]*)?\)\s*[!=]==\s*(`[^`]*`|[A-Za-z_$][\w$]*)/g; + for (const m of source.matchAll(inline)) { + if (m[1] === m[2]) found.push(m[0]); + } + // Held: `const v = t(K…)` (or `K ? t(K) : null`) and later `v === K` or + // `K !== v`. + const held = + /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$.]+\s*\?\s*)?t\(\s*([A-Za-z_$][\w$]*)\b/g; + for (const m of source.matchAll(held)) { + const [, value, key] = m; + const compare = new RegExp( + `\\b${escape(value)}\\s*[!=]==\\s*${escape(key)}\\b|\\b${escape(key)}\\s*[!=]==\\s*${escape(value)}\\b`, + ); + if (compare.test(source.slice(m.index))) found.push(m[0]); + } + return found; +} + +const files = walk(SRC).map((file) => ({ + file: relative(SRC, file), + source: readFileSync(file, "utf8"), +})); + +describe("the catalogue probe lives in one helper", () => { + it("recognises both shapes", () => { + expect(probes("const s = t(key);\nreturn s === key ? a : s;")).toHaveLength( + 1, + ); + expect(probes("if (t(`x.${c}`) !== `x.${c}`) go();")).toHaveLength(1); + expect( + probes("const label = t(key, params); if (key !== label) {}"), + ).toHaveLength(1); + expect( + probes("const v = key ? t(key) : null; return v && v !== key ? v : f;"), + ).toHaveLength(1); + expect(probes("const s = t(key); return s;")).toEqual([]); + expect(probes("if (row.key === key) {}")).toEqual([]); + }); + + it("finds the probe in the helper", () => { + const helper = files.find(({ file }) => file === HELPER); + expect(helper).toBeDefined(); + expect(probes(helper!.source).length).toBeGreaterThan(0); + }); + + it("finds it nowhere else", () => { + const offenders = files + .filter(({ file }) => file !== HELPER) + .flatMap(({ file, source }) => + probes(source).map((p) => `${file}: ${p}`), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index d1592e047..0cf9cdd60 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** * v1.4.32 — `/insights/workouts`. @@ -82,9 +83,7 @@ export default function InsightsWorkoutsPageClient() { const sportLabel = (sport: string) => { // A sport enum the bundle has no copy for renders as its canonical // string rather than a raw key — same fallback the list rows use. - const key = `insights.workouts.sport.${sport}`; - const label = t(key); - return label === key ? sport : label; + return translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport; }; return ( diff --git a/src/components/admin/notification-test-failures.ts b/src/components/admin/notification-test-failures.ts index caf578b50..9cf7b6726 100644 --- a/src/components/admin/notification-test-failures.ts +++ b/src/components/admin/notification-test-failures.ts @@ -2,6 +2,7 @@ import { CHANNEL_TYPE_LABELS, type ChannelType, } from "@/lib/notifications/types"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; /** One channel's row in the admin notification test response. */ export interface ChannelTestResult { @@ -27,14 +28,10 @@ export function describeChannelTestFailures( .filter((r) => !r.success) .map((r) => { const label = CHANNEL_TYPE_LABELS[r.channel as ChannelType] ?? r.channel; - const key = r.errorCode - ? `settings.testConnection.errors.${r.errorCode}` - : null; - const translated = key ? t(key) : null; const cause = - translated && translated !== key - ? translated - : (r.error ?? t("admin.notificationTestFailed")); + translateCodeOrNull(t, "settings.testConnection.errors", r.errorCode) ?? + r.error ?? + t("admin.notificationTestFailed"); const code = typeof r.upstreamStatus === "number" ? ` (HTTP ${r.upstreamStatus})` diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 640c8db11..fdbe42f25 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -89,6 +89,7 @@ import { DEFAULT_CHART_RANGE, rangeWindowDays, } from "./chart-range-tabs"; +import { translateOrNull } from "@/lib/i18n/translate-code"; // The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and // the "All" tab's ten-year fetch span live with the shared @@ -399,8 +400,7 @@ function getTypeLabel( const key = BASE_TYPE_LABEL_KEYS[type]; if (key) return t(key); const measurementKey = measurementLabelKey(type); - const label = t(measurementKey); - return label === measurementKey ? type : label; + return translateOrNull(t, measurementKey) ?? type; } /** diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 0b75c76f6..a9770c5b4 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -12,6 +12,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { iconForSport } from "@/lib/workouts/sport-icons"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** * v1.4.32 — ``. @@ -122,9 +123,8 @@ export function RecentWorkoutsTile() {