Skip to content

Commit f2a03de

Browse files
committed
test(i18n): hold every locale to the English key set
A missing key renders its own name in one language and works in the rest - a bug only speakers of that language can see. The test walks the locale files as i18next loads them and diffs dotted key sets against en, folding plural-form suffixes away so each language's legitimate subset passes.
1 parent b6d1351 commit f2a03de

1 file changed

Lines changed: 75 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import assert from 'node:assert/strict';
2+
import { readdirSync, readFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import test from 'node:test';
6+
7+
/*
8+
* Every locale must speak the same keys. A missing key renders its own name
9+
* in one language and works in the others - a bug that only speakers of that
10+
* language can see, which is nobody who can fix it. This walks the locale
11+
* files exactly as i18next loads them and diffs key sets against `en`,
12+
* ignoring plural-form suffixes (`files_one`, `files_few`, ...), which each
13+
* language legitimately carries a different subset of.
14+
*/
15+
16+
const localesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n', 'locales');
17+
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
18+
19+
function locales() {
20+
return readdirSync(localesDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
21+
}
22+
23+
function namespaceFiles(directory) {
24+
return readdirSync(directory).filter((name) => name.endsWith('.json')).sort();
25+
}
26+
27+
/** Dotted key paths of a parsed namespace, plural-form suffixes folded away. */
28+
function keyPaths(value, prefix = '') {
29+
const keys = new Set();
30+
for (const [name, child] of Object.entries(value ?? {})) {
31+
const base = prefix ? `${prefix}.${name.replace(PLURAL_SUFFIX, '')}` : name.replace(PLURAL_SUFFIX, '');
32+
keys.add(base);
33+
if (child && typeof child === 'object' && !Array.isArray(child)) {
34+
for (const nested of keyPaths(child, base)) keys.add(nested);
35+
}
36+
}
37+
return keys;
38+
}
39+
40+
function namespaceKeys(locale, file) {
41+
const parsed = JSON.parse(readFileSync(path.join(localesDirectory, locale, file), 'utf8'));
42+
assert.ok(parsed && typeof parsed === 'object' && !Array.isArray(parsed), `${locale}/${file}: the namespace must be a JSON object`);
43+
return keyPaths(parsed);
44+
}
45+
46+
test('every locale carries exactly the keys the English one does, in every namespace', () => {
47+
const all = locales();
48+
assert.ok(all.includes('en'), 'the reference locale "en" is missing');
49+
const namespaced = new Map(all.map((locale) => {
50+
const names = new Map(namespaceFiles(path.join(localesDirectory, locale)).map((file) => [file, namespaceKeys(locale, file)]));
51+
return [locale, names];
52+
}));
53+
54+
const english = namespaced.get('en');
55+
for (const [locale, namespaces] of namespaced) {
56+
if (locale === 'en') continue;
57+
assert.deepEqual([...namespaces.keys()].sort(), [...english.keys()].sort(), `${locale}: namespace files differ from en`);
58+
for (const [file, keys] of namespaces) {
59+
const expected = english.get(file);
60+
const missing = [...expected].filter((key) => !keys.has(key));
61+
const extra = [...keys].filter((key) => !expected.has(key));
62+
assert.deepEqual(
63+
{ missing, extra },
64+
{ missing: [], extra: [] },
65+
`${locale}/${file}: key sets drift from en (missing ${missing.length}, extra ${extra.length})`,
66+
);
67+
}
68+
}
69+
});
70+
71+
test('a plural-suffixed key counts as its base key, so legitimate per-language plural forms pass', () => {
72+
const base = keyPaths({ files_one: 1, files_other: 2, title: 3 });
73+
assert.deepEqual([...base].sort(), ['files', 'title']);
74+
assert.deepEqual([...keyPaths({ nested: { rows_few: 1 } })], ['nested', 'nested.rows']);
75+
});

0 commit comments

Comments
 (0)