111 lines
3.8 KiB
TypeScript
111 lines
3.8 KiB
TypeScript
|
|
/**
|
||
|
|
* i18n — locale resolution and dictionary parity.
|
||
|
|
*
|
||
|
|
* The parity walk is the point: `de.ts` is typed as `Dictionary`, so a *missing*
|
||
|
|
* key is already a compile error. What the type cannot catch is a key that was
|
||
|
|
* copied over but never translated, or a plural function that ignores its
|
||
|
|
* argument — both of which ship an English string on a German site.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect } from 'vitest';
|
||
|
|
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
|
||
|
|
import { en } from '../i18n/locales/en';
|
||
|
|
import { de } from '../i18n/locales/de';
|
||
|
|
|
||
|
|
describe('resolveLocale', () => {
|
||
|
|
it('accepts the supported locales', () => {
|
||
|
|
expect(resolveLocale('en')).toBe('en');
|
||
|
|
expect(resolveLocale('de')).toBe('de');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drops the region subtag', () => {
|
||
|
|
expect(resolveLocale('de-DE')).toBe('de');
|
||
|
|
expect(resolveLocale('de-AT')).toBe('de');
|
||
|
|
expect(resolveLocale('en_GB')).toBe('en');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is case- and whitespace-insensitive', () => {
|
||
|
|
expect(resolveLocale(' DE ')).toBe('de');
|
||
|
|
expect(resolveLocale('De-Ch')).toBe('de');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to English for languages without a dictionary', () => {
|
||
|
|
expect(resolveLocale('fr')).toBe('en');
|
||
|
|
expect(resolveLocale('ja-JP')).toBe('en');
|
||
|
|
expect(resolveLocale('')).toBe('en');
|
||
|
|
expect(resolveLocale(undefined)).toBe('en');
|
||
|
|
expect(resolveLocale(null)).toBe('en');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getDictionary', () => {
|
||
|
|
it('returns a dictionary for every supported locale', () => {
|
||
|
|
for (const locale of SUPPORTED_LOCALES) {
|
||
|
|
expect(getDictionary(locale)).toBeDefined();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('dictionaryFor resolves and looks up in one step', () => {
|
||
|
|
expect(dictionaryFor('de-DE')).toBe(de);
|
||
|
|
expect(dictionaryFor('fr')).toBe(en);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/** Every leaf of a dictionary, as `['nav.home', 'Home']` pairs. */
|
||
|
|
function leaves(obj: object, prefix = ''): [string, unknown][] {
|
||
|
|
return Object.entries(obj).flatMap(([key, value]) => {
|
||
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
||
|
|
return value !== null && typeof value === 'object'
|
||
|
|
? leaves(value, path)
|
||
|
|
: ([[path, value]] as [string, unknown][]);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Call a dictionary function with plausible arguments for its arity. */
|
||
|
|
function invoke(fn: (...args: never[]) => string): string {
|
||
|
|
const args = Array.from({ length: fn.length }, (_, i) => (i === 0 ? 2 : `arg${i}`));
|
||
|
|
return (fn as (...a: unknown[]) => string)(...args);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('dictionary parity', () => {
|
||
|
|
const enLeaves = leaves(en);
|
||
|
|
const deLeaves = new Map(leaves(de));
|
||
|
|
|
||
|
|
it('German covers every English key', () => {
|
||
|
|
expect([...deLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
|
||
|
|
});
|
||
|
|
|
||
|
|
it('matches value kinds — a string never stands in for an interpolator', () => {
|
||
|
|
for (const [path, value] of enLeaves) {
|
||
|
|
expect(typeof deLeaves.get(path), path).toBe(typeof value);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('leaves no English string untranslated', () => {
|
||
|
|
// Proper nouns and units read the same in both languages; `coverAria` is
|
||
|
|
// pure interpolation with no words of its own.
|
||
|
|
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
|
||
|
|
|
||
|
|
const identical = enLeaves
|
||
|
|
.filter(([path]) => !shared.has(path))
|
||
|
|
.filter(([path, value]) => {
|
||
|
|
const other = deLeaves.get(path);
|
||
|
|
if (typeof value === 'function') {
|
||
|
|
return invoke(other as (...a: never[]) => string) === invoke(value as never);
|
||
|
|
}
|
||
|
|
return other === value;
|
||
|
|
})
|
||
|
|
.map(([path]) => path);
|
||
|
|
|
||
|
|
expect(identical).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('plural helpers actually vary on their count', () => {
|
||
|
|
for (const dict of [en, de]) {
|
||
|
|
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
|
||
|
|
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
|
||
|
|
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|