mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-05-01 23:16:31 +02:00
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:
@@ -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 || ''}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
178
frontend/src/tests/missingTranslations.test.ts
Normal file
178
frontend/src/tests/missingTranslations.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user