mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:54:47 +00:00
feat(ui): add i18n baseline report
Signed-off-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
committed by
Peter Steinberger
parent
d5abbd29cc
commit
ee9d471865
@@ -1743,6 +1743,7 @@
|
||||
"ui:build": "node scripts/ui.js build",
|
||||
"ui:dev": "node scripts/ui.js dev",
|
||||
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
|
||||
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
|
||||
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
|
||||
"ui:install": "node scripts/ui.js install"
|
||||
},
|
||||
|
||||
421
scripts/control-ui-i18n-report.ts
Normal file
421
scripts/control-ui-i18n-report.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import type { TranslationMap } from "../ui/src/i18n/lib/types.ts";
|
||||
|
||||
const ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const I18N_ASSETS_DIR = path.join(ROOT, "ui/src/i18n/.i18n");
|
||||
const LOCALES_DIR = path.join(ROOT, "ui/src/i18n/locales");
|
||||
const RAW_COPY_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "raw-copy-baseline.json");
|
||||
const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts");
|
||||
const DEFAULT_TOP = 10;
|
||||
const LOCALE_LABELS: Record<string, string> = {
|
||||
ar: "Arabic",
|
||||
de: "German",
|
||||
es: "Spanish",
|
||||
fa: "Persian",
|
||||
fr: "French",
|
||||
id: "Indonesian",
|
||||
it: "Italian",
|
||||
"ja-JP": "Japanese",
|
||||
ko: "Korean",
|
||||
nl: "Dutch",
|
||||
pl: "Polish",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
th: "Thai",
|
||||
tr: "Turkish",
|
||||
uk: "Ukrainian",
|
||||
vi: "Vietnamese",
|
||||
"zh-CN": "Simplified Chinese",
|
||||
"zh-TW": "Traditional Chinese",
|
||||
};
|
||||
const PATH_LABELS: Record<string, string> = {
|
||||
"ui/src/ui/chat/chat-queue.ts": "Chat queue",
|
||||
"ui/src/ui/chat/grouped-render.ts": "Chat message groups",
|
||||
"ui/src/ui/chat/side-result-render.ts": "Chat tool result panel",
|
||||
"ui/src/ui/chat/tool-cards.ts": "Chat tool cards",
|
||||
"ui/src/ui/views/agents-panels-overview.ts": "Agents overview panel",
|
||||
"ui/src/ui/views/agents-panels-tools-skills.ts": "Agents tools and skills panel",
|
||||
"ui/src/ui/views/agents-utils.ts": "Agents shared UI helpers",
|
||||
"ui/src/ui/views/chat.ts": "Chat page",
|
||||
"ui/src/ui/views/config-form.render.ts": "Config form",
|
||||
"ui/src/ui/views/config-quick.ts": "Quick config page",
|
||||
"ui/src/ui/views/config.ts": "Config page",
|
||||
"ui/src/ui/views/cron.ts": "Cron page",
|
||||
"ui/src/ui/views/usage-query.ts": "Usage filters",
|
||||
"ui/src/ui/views/usage-render-details.ts": "Usage detail view",
|
||||
"ui/src/ui/views/usage-render-overview.ts": "Usage overview",
|
||||
};
|
||||
|
||||
type RawCopyKind = "html-attribute" | "html-text" | "object-property";
|
||||
|
||||
export type RawCopyBaselineEntry = {
|
||||
count: number;
|
||||
kind: RawCopyKind;
|
||||
name: string;
|
||||
path: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type RawCopyBaseline = {
|
||||
entries: RawCopyBaselineEntry[];
|
||||
version: number;
|
||||
};
|
||||
|
||||
type LocaleMeta = {
|
||||
fallbackKeys: string[];
|
||||
generatedAt: string;
|
||||
locale: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
sourceHash: string;
|
||||
totalKeys: number;
|
||||
translatedKeys: number;
|
||||
workflow: number;
|
||||
};
|
||||
|
||||
type ReportArgs = {
|
||||
locale?: string;
|
||||
surface?: string;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export type RawCopySummary = {
|
||||
entries: number;
|
||||
occurrences: number;
|
||||
topPaths: Array<{ count: number; path: string }>;
|
||||
};
|
||||
|
||||
export type LocaleSummary = {
|
||||
fallbackKeysInScope: string[];
|
||||
meta: LocaleMeta;
|
||||
sameAsEnglishKeys: string[];
|
||||
};
|
||||
|
||||
type ReportInput = {
|
||||
locale?: LocaleSummary;
|
||||
rawCopy: RawCopySummary;
|
||||
surface?: string;
|
||||
};
|
||||
|
||||
export function parseArgs(argv: string[]): ReportArgs {
|
||||
const args: ReportArgs = { top: DEFAULT_TOP };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--surface") {
|
||||
args.surface = readOptionValue(argv, (index += 1), arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--locale") {
|
||||
args.locale = readOptionValue(argv, (index += 1), arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--top") {
|
||||
const raw = readOptionValue(argv, (index += 1), arg);
|
||||
const top = Number.parseInt(raw, 10);
|
||||
if (!Number.isSafeInteger(top) || top < 1) {
|
||||
throw new Error(`--top must be a positive integer: ${raw}`);
|
||||
}
|
||||
args.top = top;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown argument: ${arg}\n${usage()}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readOptionValue(argv: string[], index: number, flag: string) {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function filterRawCopyEntries(entries: RawCopyBaselineEntry[], surface?: string) {
|
||||
if (!surface) {
|
||||
return entries;
|
||||
}
|
||||
const normalized = normalizeToken(surface);
|
||||
return entries.filter((entry) => pathTokens(entry.path).some((token) => token === normalized));
|
||||
}
|
||||
|
||||
export function summarizeRawCopy(entries: RawCopyBaselineEntry[], top: number): RawCopySummary {
|
||||
const byPath = new Map<string, number>();
|
||||
let occurrences = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
occurrences += entry.count;
|
||||
byPath.set(entry.path, (byPath.get(entry.path) ?? 0) + entry.count);
|
||||
}
|
||||
|
||||
const rankedPaths = [...byPath.entries()]
|
||||
.map(([entryPath, count]) => ({ count, path: entryPath }))
|
||||
.toSorted(compareCountThenName((entry) => entry.path));
|
||||
|
||||
return {
|
||||
entries: entries.length,
|
||||
occurrences,
|
||||
topPaths: rankedPaths.slice(0, top),
|
||||
};
|
||||
}
|
||||
|
||||
function compareCountThenName<T>(nameOf: (value: T) => string) {
|
||||
return (left: T & { count: number }, right: T & { count: number }) =>
|
||||
right.count - left.count || nameOf(left).localeCompare(nameOf(right));
|
||||
}
|
||||
|
||||
function pathTokens(repoPath: string) {
|
||||
return repoPath
|
||||
.split("/")
|
||||
.flatMap((part) => part.replace(/\.[^.]+$/, "").split(/[^A-Za-z0-9]+/))
|
||||
.filter(Boolean)
|
||||
.map(normalizeToken);
|
||||
}
|
||||
|
||||
function normalizeToken(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function flattenTranslations(
|
||||
value: TranslationMap,
|
||||
prefix = "",
|
||||
out = new Map<string, string>(),
|
||||
) {
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof nested === "string") {
|
||||
out.set(fullKey, nested);
|
||||
continue;
|
||||
}
|
||||
flattenTranslations(nested, fullKey, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeSameAsEnglishKeys(source: Map<string, string>, target: Map<string, string>) {
|
||||
return [...target.entries()]
|
||||
.filter(([key, value]) => source.get(key) === value)
|
||||
.map(([key]) => key)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
export function filterTranslationKeysBySurface(keys: string[], surface?: string) {
|
||||
if (!surface) {
|
||||
return keys;
|
||||
}
|
||||
const normalized = normalizeToken(surface);
|
||||
return keys.filter((key) => key.split(".").some((part) => normalizeToken(part) === normalized));
|
||||
}
|
||||
|
||||
export function formatReport(input: ReportInput) {
|
||||
const lines = [
|
||||
"Control UI i18n baseline report",
|
||||
`Scope: ${formatSurfaceLabel(input.surface)}, ${
|
||||
input.locale ? formatLocaleLabel(input.locale.meta.locale) : "no locale selected"
|
||||
}`,
|
||||
"Based on: current raw-copy baseline and locale metadata. Not a drift check.",
|
||||
"",
|
||||
"Current i18n state",
|
||||
` Hardcoded UI text outside i18n: ${input.rawCopy.entries} pieces in code, ${input.rawCopy.occurrences} total occurrences.`,
|
||||
...formatLocaleState(input.locale),
|
||||
"",
|
||||
"Current issue",
|
||||
...formatIssueLines(input),
|
||||
"",
|
||||
"Focus modules",
|
||||
...formatTopPathLines(input.rawCopy.topPaths),
|
||||
"",
|
||||
"Next steps",
|
||||
...formatNextStepLines(input),
|
||||
];
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatLocaleState(locale?: LocaleSummary) {
|
||||
if (!locale) {
|
||||
return [" Existing translation keys: not checked. Add --locale <code> to include them."];
|
||||
}
|
||||
return [
|
||||
` Existing ${locale.meta.locale} translation keys (all Control UI): ${locale.meta.translatedKeys}/${locale.meta.totalKeys} filled, ${formatFallbackCount(locale.meta.fallbackKeys.length)}.`,
|
||||
];
|
||||
}
|
||||
|
||||
function formatIssueLines(input: ReportInput) {
|
||||
const scope = input.surface ? formatSurfaceLabel(input.surface) : "Control UI";
|
||||
const lines: string[] = [];
|
||||
|
||||
if (input.rawCopy.entries === 0) {
|
||||
lines.push(` No hardcoded UI text found in ${scope}.`);
|
||||
} else {
|
||||
lines.push(` ${scope} still has UI text written directly in code.`);
|
||||
}
|
||||
|
||||
if (!input.locale) {
|
||||
lines.push(" Locale-specific gaps were not checked.");
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (
|
||||
input.locale.fallbackKeysInScope.length === 0 &&
|
||||
input.locale.sameAsEnglishKeys.length === 0
|
||||
) {
|
||||
lines.push(` No ${input.locale.meta.locale} locale problems found in this scope.`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (input.locale.fallbackKeysInScope.length > 0) {
|
||||
lines.push(
|
||||
` ${input.locale.meta.locale} has ${formatMissingKeyCount(input.locale.fallbackKeysInScope.length)}.`,
|
||||
);
|
||||
}
|
||||
if (input.locale.sameAsEnglishKeys.length > 0) {
|
||||
lines.push(
|
||||
` ${formatSameAsEnglishIssue(input.locale.sameAsEnglishKeys.length, input.locale.meta.locale)}.`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatNextStepLines(input: ReportInput) {
|
||||
if (input.rawCopy.entries === 0) {
|
||||
return [" No module work needed for hardcoded text in this scope."];
|
||||
}
|
||||
return [
|
||||
" Move text from the focus modules into translation keys.",
|
||||
" Do not hand-edit generated locale, translation memory, or i18n metadata files.",
|
||||
" Run pnpm ui:i18n:sync after adding translation keys.",
|
||||
];
|
||||
}
|
||||
|
||||
function formatTopPathLines(entries: Array<{ count: number; path: string }>) {
|
||||
if (entries.length === 0) {
|
||||
return [" none"];
|
||||
}
|
||||
return entries.map((entry) => ` ${entry.count} ${formatPathLabel(entry.path)}: ${entry.path}`);
|
||||
}
|
||||
|
||||
function formatMissingKeyCount(count: number) {
|
||||
return `${count} missing ${count === 1 ? "key" : "keys"}`;
|
||||
}
|
||||
|
||||
function formatFallbackCount(count: number) {
|
||||
return `${count} ${count === 1 ? "fallback" : "fallbacks"}`;
|
||||
}
|
||||
|
||||
function formatSameAsEnglishIssue(count: number, locale: string) {
|
||||
if (count === 1) {
|
||||
return `1 ${locale} translation still matches English and needs review`;
|
||||
}
|
||||
return `${count} ${locale} translations still match English and need review`;
|
||||
}
|
||||
|
||||
function formatSurfaceLabel(surface?: string) {
|
||||
if (!surface) {
|
||||
return "all Control UI";
|
||||
}
|
||||
return toTitleWords(surface);
|
||||
}
|
||||
|
||||
function formatLocaleLabel(locale: string) {
|
||||
const label = LOCALE_LABELS[locale];
|
||||
return label ? `${label} (${locale})` : locale;
|
||||
}
|
||||
|
||||
function formatPathLabel(repoPath: string) {
|
||||
return PATH_LABELS[repoPath] ?? toTitleWords(path.basename(repoPath).replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
|
||||
function toTitleWords(value: string) {
|
||||
return value
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
async function loadRawCopyBaseline(): Promise<RawCopyBaseline> {
|
||||
const parsed = JSON.parse(await readFile(RAW_COPY_BASELINE_PATH, "utf8")) as RawCopyBaseline;
|
||||
if (!Array.isArray(parsed.entries)) {
|
||||
throw new Error("raw-copy baseline is missing entries");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function loadLocaleMeta(locale: string): Promise<LocaleMeta> {
|
||||
const metaPath = path.join(I18N_ASSETS_DIR, `${locale}.meta.json`);
|
||||
if (!existsSync(metaPath)) {
|
||||
throw new Error(`unknown locale metadata: ${locale}`);
|
||||
}
|
||||
return JSON.parse(await readFile(metaPath, "utf8")) as LocaleMeta;
|
||||
}
|
||||
|
||||
async function loadLocaleMap(locale: string): Promise<TranslationMap> {
|
||||
const filePath = locale === "en" ? SOURCE_LOCALE_PATH : path.join(LOCALES_DIR, `${locale}.ts`);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`unknown locale file: ${locale}`);
|
||||
}
|
||||
const exportName = locale.replaceAll("-", "_");
|
||||
const mod = (await import(pathToFileURL(filePath).href)) as Record<string, TranslationMap>;
|
||||
const map = mod[exportName];
|
||||
if (!map) {
|
||||
throw new Error(`locale ${locale} does not export ${exportName}`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function buildReport(args: ReportArgs) {
|
||||
const baseline = await loadRawCopyBaseline();
|
||||
const entries = filterRawCopyEntries(baseline.entries, args.surface);
|
||||
const input: ReportInput = {
|
||||
rawCopy: summarizeRawCopy(entries, args.top),
|
||||
surface: args.surface,
|
||||
};
|
||||
|
||||
if (args.locale) {
|
||||
const [meta, source, target] = await Promise.all([
|
||||
loadLocaleMeta(args.locale),
|
||||
loadLocaleMap("en"),
|
||||
loadLocaleMap(args.locale),
|
||||
]);
|
||||
input.locale = {
|
||||
fallbackKeysInScope: filterTranslationKeysBySurface(meta.fallbackKeys, args.surface),
|
||||
meta,
|
||||
sameAsEnglishKeys: filterTranslationKeysBySurface(
|
||||
computeSameAsEnglishKeys(flattenTranslations(source), flattenTranslations(target)),
|
||||
args.surface,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return formatReport(input);
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"usage: node --import tsx scripts/control-ui-i18n-report.ts [--surface <name>] [--locale <locale>] [--top <n>]",
|
||||
"example: pnpm ui:i18n:report --surface chat --locale zh-CN --top 20",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isCliEntrypoint() {
|
||||
const entrypoint = process.argv[1];
|
||||
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
|
||||
}
|
||||
|
||||
if (isCliEntrypoint()) {
|
||||
const cliArgs = process.argv.slice(2);
|
||||
if (cliArgs.includes("--help") || cliArgs.includes("-h")) {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
process.stdout.write(await buildReport(parseArgs(cliArgs)));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
150
src/scripts/control-ui-i18n-report.test.ts
Normal file
150
src/scripts/control-ui-i18n-report.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeSameAsEnglishKeys,
|
||||
filterRawCopyEntries,
|
||||
filterTranslationKeysBySurface,
|
||||
flattenTranslations,
|
||||
formatReport,
|
||||
summarizeRawCopy,
|
||||
type RawCopyBaselineEntry,
|
||||
} from "../../scripts/control-ui-i18n-report.ts";
|
||||
import type { TranslationMap } from "../../ui/src/i18n/lib/types.ts";
|
||||
|
||||
const entries: RawCopyBaselineEntry[] = [
|
||||
{
|
||||
count: 2,
|
||||
kind: "html-text",
|
||||
name: "text",
|
||||
path: "ui/src/ui/chat/render.ts",
|
||||
text: "Send",
|
||||
},
|
||||
{
|
||||
count: 1,
|
||||
kind: "object-property",
|
||||
name: "label",
|
||||
path: "ui/src/ui/views/agents-panels-tools.ts",
|
||||
text: "Tools",
|
||||
},
|
||||
{
|
||||
count: 4,
|
||||
kind: "html-attribute",
|
||||
name: "title",
|
||||
path: "ui/src/ui/views/config-form.render.ts",
|
||||
text: "Open config",
|
||||
},
|
||||
];
|
||||
|
||||
describe("control-ui-i18n report helpers", () => {
|
||||
it("filters raw-copy entries by path surface token", () => {
|
||||
expect(filterRawCopyEntries(entries, "agents")).toEqual([entries[1]]);
|
||||
expect(filterRawCopyEntries(entries, "config")).toEqual([entries[2]]);
|
||||
expect(filterRawCopyEntries(entries, "missing")).toEqual([]);
|
||||
});
|
||||
|
||||
it("summarizes raw-copy occurrences deterministically", () => {
|
||||
expect(summarizeRawCopy(entries, 2)).toEqual({
|
||||
entries: 3,
|
||||
occurrences: 7,
|
||||
topPaths: [
|
||||
{ count: 4, path: "ui/src/ui/views/config-form.render.ts" },
|
||||
{ count: 2, path: "ui/src/ui/chat/render.ts" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens locale maps and reports same-as-English keys", () => {
|
||||
const source: TranslationMap = {
|
||||
actions: { send: "Send", stop: "Stop" },
|
||||
brand: "OpenClaw",
|
||||
};
|
||||
const target: TranslationMap = {
|
||||
actions: { send: "发送", stop: "Stop" },
|
||||
brand: "OpenClaw",
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSameAsEnglishKeys(flattenTranslations(source), flattenTranslations(target)),
|
||||
).toEqual(["actions.stop", "brand"]);
|
||||
});
|
||||
|
||||
it("filters same-as-English keys by translation key surface token", () => {
|
||||
expect(
|
||||
filterTranslationKeysBySurface(
|
||||
["agents.tabs.cronJobs", "chat.composer.send", "usage.common.emptyValue"],
|
||||
"chat",
|
||||
),
|
||||
).toEqual(["chat.composer.send"]);
|
||||
});
|
||||
|
||||
it("formats pasteable report text", () => {
|
||||
const report = formatReport({
|
||||
locale: {
|
||||
fallbackKeysInScope: ["actions.cancel"],
|
||||
meta: {
|
||||
fallbackKeys: ["actions.cancel"],
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
locale: "zh-CN",
|
||||
model: "gpt-5.5",
|
||||
provider: "openai",
|
||||
sourceHash: "hash",
|
||||
totalKeys: 3,
|
||||
translatedKeys: 2,
|
||||
workflow: 1,
|
||||
},
|
||||
sameAsEnglishKeys: ["brand"],
|
||||
},
|
||||
rawCopy: summarizeRawCopy(entries, 1),
|
||||
surface: "chat",
|
||||
});
|
||||
|
||||
expect(report).toContain("Control UI i18n baseline report\n");
|
||||
expect(report).toContain("Scope: Chat, Simplified Chinese (zh-CN)\n");
|
||||
expect(report).toContain(
|
||||
"Based on: current raw-copy baseline and locale metadata. Not a drift check.\n",
|
||||
);
|
||||
expect(report).toContain(
|
||||
"Hardcoded UI text outside i18n: 3 pieces in code, 7 total occurrences.\n",
|
||||
);
|
||||
expect(report).toContain(
|
||||
"Existing zh-CN translation keys (all Control UI): 2/3 filled, 1 fallback.\n",
|
||||
);
|
||||
expect(report).toContain("Chat still has UI text written directly in code.\n");
|
||||
expect(report).toContain("zh-CN has 1 missing key.\n");
|
||||
expect(report).toContain("1 zh-CN translation still matches English and needs review.\n");
|
||||
expect(report).toContain("4 Config form: ui/src/ui/views/config-form.render.ts\n");
|
||||
expect(report).toContain("Move text from the focus modules into translation keys.\n");
|
||||
expect(report).toContain(
|
||||
"Do not hand-edit generated locale, translation memory, or i18n metadata files.\n",
|
||||
);
|
||||
expect(report).toContain("Run pnpm ui:i18n:sync after adding translation keys.\n");
|
||||
expect(report).not.toContain("raw-copy entries");
|
||||
});
|
||||
|
||||
it("keeps fallback-key issues scoped to the selected surface", () => {
|
||||
const report = formatReport({
|
||||
locale: {
|
||||
fallbackKeysInScope: [],
|
||||
meta: {
|
||||
fallbackKeys: ["usage.common.emptyValue"],
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
locale: "zh-CN",
|
||||
model: "gpt-5.5",
|
||||
provider: "openai",
|
||||
sourceHash: "hash",
|
||||
totalKeys: 3,
|
||||
translatedKeys: 2,
|
||||
workflow: 1,
|
||||
},
|
||||
sameAsEnglishKeys: [],
|
||||
},
|
||||
rawCopy: summarizeRawCopy(entries, 1),
|
||||
surface: "chat",
|
||||
});
|
||||
|
||||
expect(report).toContain(
|
||||
"Existing zh-CN translation keys (all Control UI): 2/3 filled, 1 fallback.\n",
|
||||
);
|
||||
expect(report).toContain("No zh-CN locale problems found in this scope.\n");
|
||||
expect(report).not.toContain("zh-CN has 1 missing key.\n");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user