test: tighten i18n allowlist, add substring and brand-leak guards
Remove 7 now-stale IDENTICAL_VALUE_ALLOWLIST entries (audit.title, auth.roleOperationsManager, auth.roleRentalEmployee, demo.scenarios.startScenario, demo.scenarios.roles.operations_manager/ rental_employee, navigation.items.audit) now that they are genuinely translated -- their old comments describing them as "deliberately untranslated" were no longer true. Add two new checks: one closing the embedded-English/Dutch-substring blind spot the whole-string identity test structurally cannot catch (a mid-sentence phrase surviving inside otherwise-translated prose), one asserting no locale file contains "MobilityOps" or the word "PoC".
This commit is contained in:
@@ -97,6 +97,24 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const raw = JSON.stringify(loadNamespace(language, namespace));
|
||||||
|
expect(
|
||||||
|
raw.includes("MobilityOps"),
|
||||||
|
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
|
||||||
|
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
/\bPoC\b/.test(raw),
|
||||||
|
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
|
||||||
|
`in user-facing copy`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
||||||
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
||||||
// the literal English string under the right key and still pass). For every "real prose"
|
// the literal English string under the right key and still pass). For every "real prose"
|
||||||
@@ -110,19 +128,13 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
|
|||||||
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
||||||
// hide an unrelated real mistranslation under the same key in a different namespace.
|
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||||
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
||||||
"audit.title", // "Audit trail" kept as an established cross-language compliance term
|
|
||||||
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
||||||
"auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles)
|
|
||||||
"auth.roleRentalEmployee",
|
|
||||||
"demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels
|
|
||||||
"demo.scenarios.roles.rental_employee",
|
|
||||||
"common.language.nl-BE", // language-picker options show each language's own endonym
|
"common.language.nl-BE", // language-picker options show each language's own endonym
|
||||||
"common.language.fr-BE",
|
"common.language.fr-BE",
|
||||||
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||||
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||||
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||||
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
||||||
"demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch
|
|
||||||
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
||||||
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||||
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
||||||
@@ -131,7 +143,6 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
|||||||
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
||||||
"knowledge.retrievalFlow.question",
|
"knowledge.retrievalFlow.question",
|
||||||
"knowledge.questionLabelExchange",
|
"knowledge.questionLabelExchange",
|
||||||
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
|
|
||||||
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -185,6 +196,61 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
|
||||||
|
// The whole-string identity check above only catches a value that is IDENTICAL to
|
||||||
|
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
|
||||||
|
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
|
||||||
|
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
|
||||||
|
// goedgekeurd." This scan flags known English fragments appearing literally inside any
|
||||||
|
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
|
||||||
|
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
|
||||||
|
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
|
||||||
|
const FORBIDDEN_ENGLISH_FRAGMENTS = [
|
||||||
|
"Operations Manager",
|
||||||
|
"Operations Managers",
|
||||||
|
"Rental Employee",
|
||||||
|
"Rental Employees",
|
||||||
|
"Audit trail",
|
||||||
|
"Start scenario",
|
||||||
|
];
|
||||||
|
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
|
||||||
|
"Operationsmanager",
|
||||||
|
"Verhuurmedewerker",
|
||||||
|
"Auditgeschiedenis",
|
||||||
|
"Scenario starten",
|
||||||
|
];
|
||||||
|
|
||||||
|
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
|
||||||
|
if (typeof value === "string") return [{ path: prefix, value }];
|
||||||
|
if (value === null || typeof value !== "object") return [];
|
||||||
|
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||||
|
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
|
||||||
|
const findings: string[] = [];
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const nl = loadNamespace("nl-BE", namespace);
|
||||||
|
const fr = loadNamespace("fr-BE", namespace);
|
||||||
|
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
|
||||||
|
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
|
||||||
|
if (value.includes(fragment)) {
|
||||||
|
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
|
||||||
|
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
|
||||||
|
if (value.includes(fragment)) {
|
||||||
|
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(findings, findings.join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Hardcoded JSX text (section 11D) ---
|
// --- Hardcoded JSX text (section 11D) ---
|
||||||
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
||||||
// `{...}` expression) containing two or more real words are almost always user-facing
|
// `{...}` expression) containing two or more real words are almost always user-facing
|
||||||
|
|||||||
Reference in New Issue
Block a user