Add test for missing translations (#4696)

# Description of Changes
Adds a test to scan the code for any static translation keys which are
not present in the GB translations file. The test won't catch every
missing translation present in our code, but it should greatly help us
keep the translations file up to date.
This commit is contained in:
James Brunton
2025-10-17 16:50:04 +01:00
committed by GitHub
parent 5354f08766
commit 3e6236d957
5 changed files with 588 additions and 64 deletions

View File

@@ -43,7 +43,7 @@ const AddPageNumbersPositionSettings = ({
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
<Tooltip content={t('pageSelectionPrompt', 'Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={parameters.pagesToNumber || ''}

View File

@@ -35,7 +35,7 @@ const CropCoordinateInputs = ({
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X Position")}
label={t("crop.coordinates.x.label", "X Position")}
description={showAutomationInfo ? t("crop.coordinates.x.desc", "Left edge (points)") : undefined}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => onCoordinateChange('x', value)}
@@ -47,7 +47,7 @@ const CropCoordinateInputs = ({
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.y", "Y Position")}
label={t("crop.coordinates.y.label", "Y Position")}
description={showAutomationInfo ? t("crop.coordinates.y.desc", "Bottom edge (points)") : undefined}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => onCoordinateChange('y', value)}
@@ -62,7 +62,7 @@ const CropCoordinateInputs = ({
<Group grow>
<NumberInput
label={t("crop.coordinates.width", "Width")}
label={t("crop.coordinates.width.label", "Width")}
description={showAutomationInfo ? t("crop.coordinates.width.desc", "Crop width (points)") : undefined}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => onCoordinateChange('width', value)}
@@ -74,7 +74,7 @@ const CropCoordinateInputs = ({
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.height", "Height")}
label={t("crop.coordinates.height.label", "Height")}
description={showAutomationInfo ? t("crop.coordinates.height.desc", "Crop height (points)") : undefined}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => onCoordinateChange('height', value)}

View File

@@ -33,6 +33,6 @@ export const useReplaceColorOperation = () => {
return useToolOperation<ReplaceColorParameters>({
...replaceColorOperationConfig,
getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.'))
getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the colour replacement.'))
});
};
};

View File

@@ -0,0 +1,178 @@
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
import { describe, expect, test } from 'vitest';
const REPO_ROOT = path.join(__dirname, '../../../')
const SRC_ROOT = path.join(__dirname, '..');
const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json');
const IGNORED_DIRS = new Set([
'tests',
'__mocks__',
]);
const IGNORED_FILE_PATTERNS = [
/\.d\.ts$/,
/\.test\./,
/\.spec\./,
/\.stories\./,
];
const IGNORED_KEYS = new Set<string>([
// If the script has found a false-positive that shouldn't be in the translations, include it here
]);
type FoundKey = {
key: string;
file: string;
line: number;
column: number;
};
const flattenKeys = (node: unknown, prefix = '', acc = new Set<string>()): Set<string> => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
if (prefix) {
acc.add(prefix);
}
return acc;
}
for (const [childKey, value] of Object.entries(node as Record<string, unknown>)) {
const next = prefix ? `${prefix}.${childKey}` : childKey;
flattenKeys(value, next, acc);
}
return acc;
};
const listSourceFiles = (): string[] => {
const files = ts.sys.readDirectory(SRC_ROOT, ['.ts', '.tsx', '.js', '.jsx'], undefined, [
'**/*',
]);
return files
.filter((file) => !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)))
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
};
const getScriptKind = (file: string): ts.ScriptKind => {
if (file.endsWith('.tsx')) {
return ts.ScriptKind.TSX;
}
if (file.endsWith('.ts')) {
return ts.ScriptKind.TS;
}
if (file.endsWith('.jsx')) {
return ts.ScriptKind.JSX;
}
return ts.ScriptKind.JS;
};
/**
* Find all of the static first keys for translation functions that we can.
* Ignores dynamic strings because we can't know what the actual translation key will be.
*/
const extractKeys = (file: string): FoundKey[] => {
const code = fs.readFileSync(file, 'utf8');
const sourceFile = ts.createSourceFile(
file,
code,
ts.ScriptTarget.Latest,
true,
getScriptKind(file),
);
const found: FoundKey[] = [];
const record = (node: ts.Node, key: string) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
found.push({ key, file, line: line + 1, column: character + 1 });
};
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const callee = node.expression;
const arg = node.arguments.at(0);
const isT =
(ts.isIdentifier(callee) && callee.text === 't') ||
(ts.isPropertyAccessExpression(callee) && callee.name.text === 't');
if (isT && arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) {
record(arg, arg.text);
}
}
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
for (const attr of node.attributes.properties) {
if (
!ts.isJsxAttribute(attr) ||
attr.name.getText(sourceFile) !== 'i18nKey' ||
!attr.initializer
) {
continue;
}
const init = attr.initializer;
if (ts.isStringLiteral(init)) {
record(init, init.text);
continue;
}
if (
ts.isJsxExpression(init) &&
init.expression &&
ts.isStringLiteral(init.expression)
) {
record(init.expression, init.expression.text);
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
return found;
};
describe('Missing translation coverage', () => {
test('fails if any en-GB translation key used in source is missing', () => {
expect(fs.existsSync(EN_GB_FILE)).toBe(true);
const localeContent = fs.readFileSync(EN_GB_FILE, 'utf8');
const enGb = JSON.parse(localeContent);
const availableKeys = flattenKeys(enGb);
const usedKeys = listSourceFiles()
.flatMap(extractKeys)
.filter(({ key }) => !IGNORED_KEYS.has(key));
expect(usedKeys.length).toBeGreaterThan(100); // Sanity check
const missingKeys = usedKeys.filter(({ key }) => !availableKeys.has(key));
const annotations = missingKeys.map(({ key, file, line, column }) => {
const workspaceRelativeRaw = path.relative(REPO_ROOT, file);
const workspaceRelativeFile = workspaceRelativeRaw.replace(/\\/g, '/');
return {
key,
file: workspaceRelativeFile,
line,
column,
};
});
// Output errors in GitHub Annotations format so they appear tagged in the code in CI
for (const { key, file, line, column } of annotations) {
process.stderr.write(
`::error file=${file},line=${line},col=${column}::Missing en-GB translation for ${key}\n`,
);
}
expect(missingKeys).toEqual([]);
});
});