Files
gods-eye-view/src/i18n/i18n.test.mjs
T
German Khokhlov 727825b576 Merge remote-tracking branch 'origin/main' into german/hal-638-fr
# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	scripts/package-boundaries.json
#	src/i18n/i18n.test.mjs
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/i18n/same-as-english.json
#	src/scenes/recipes.js
#	src/ui/localePicker.test.mjs
#	src/ui/templates/context.html
2026-10-29 17:55:01 +04:00

319 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
applyDocumentTranslations,
formatDate,
formatNumber,
msg,
persistLocaleAndReload,
setLocale,
t,
tn,
} from './index.js';
import {
LOCALE_STORAGE_KEY,
applyDocumentLanguage,
normalizeLocale,
resolveLocale,
writeStoredLocale,
} from './locale.js';
afterEach(() => setLocale('en'));
function memoryStorage(initial = {}) {
const data = new Map(Object.entries(initial));
return {
getItem: (key) => (data.has(key) ? data.get(key) : null),
setItem: (key, value) => data.set(key, String(value)),
data,
};
}
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
assert.equal(normalizeLocale('ru-RU'), 'ru');
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('uk'), 'uk');
assert.equal(normalizeLocale('uk-UA'), 'uk');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('fr'), 'fr');
assert.equal(normalizeLocale('fr-CA'), 'fr');
assert.equal(normalizeLocale('FR_be'), 'fr');
assert.equal(normalizeLocale('pt-BR'), 'pt');
assert.equal(normalizeLocale('pt_PT'), 'pt');
assert.equal(normalizeLocale('xx'), null);
assert.equal(normalizeLocale('it-IT'), 'it');
assert.equal(normalizeLocale('it_CH'), 'it');
// Explicit script tags take precedence over regional defaults.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant');
assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant');
assert.equal(normalizeLocale('zh-TW'), 'zh-Hant');
assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant');
assert.equal(normalizeLocale('zh_HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh-MO'), 'zh-Hant');
assert.equal(normalizeLocale(undefined), null);
});
test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => {
const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' });
assert.equal(
resolveLocale({
location: { search: '?lang=en' },
storage,
languages: ['ru'],
}),
'en',
);
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: ['en'] }),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '?lang=xx' },
storage: memoryStorage(),
languages: ['de-DE', 'ru-RU'],
}),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['de'],
}),
'en',
);
});
test('a throwing storage never escapes', () => {
const storage = {
getItem() {
throw new Error('blocked');
},
setItem() {
throw new Error('blocked');
},
};
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: [] }),
'en',
);
assert.equal(writeStoredLocale('ru', storage), false);
});
test('applyDocumentLanguage sets lang and dir', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ru-RU');
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
});
test('t() returns English text in English and translates in Russian', () => {
assert.equal(t('POWERED UP'), 'POWERED UP');
setLocale('ru');
assert.notEqual(t('POWERED UP'), 'POWERED UP');
});
test('t() interpolates named values and keeps unknown names literal', () => {
assert.equal(
t('Save failed: {detail}', { detail: 'disk full' }),
'Save failed: disk full',
);
assert.equal(
t('Hello {who} {missing}', { who: 'pilot' }),
'Hello pilot {missing}',
);
});
test('t() falls back to English for text without a translation', () => {
setLocale('ru');
assert.equal(t('No such message {n}', { n: 1 }), 'No such message 1');
});
test('tn() picks English and Russian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
assert.equal(label(1), 'POWER UP · 1 KEY WAITING');
assert.equal(label(3), 'POWER UP · 3 KEYS WAITING');
setLocale('ru');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯЁ])/);
assert.match(label(3), / 3 КЛЮЧА(?![А-ЯЁ])/);
assert.match(label(5), / 5 КЛЮЧЕЙ(?![А-ЯЁ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('tn() picks the Portuguese one, many and other forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('pt');
assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE');
assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE');
assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES');
assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES');
});
test('tn() picks the four Ukrainian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('uk');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/);
assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/);
});
test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru');
assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
});
test('msg() returns its text unchanged', () => {
assert.equal(msg('Live Flights'), 'Live Flights');
});
test('Intl formatters follow the active locale', () => {
const date = new Date(Date.UTC(2026, 0, 15));
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
assert.equal(formatDate(date, options), 'January 15, 2026');
assert.equal(formatNumber(1234.5), '1,234.5');
setLocale('ru');
assert.equal(formatDate(date, options), '15 января 2026 г.');
assert.equal(formatNumber(1234.5), '1 234,5');
});
function fakeElement({ text = '', attributes = {} } = {}) {
const attrs = new Map(Object.entries(attributes));
return {
textContent: text,
getAttribute: (name) => (attrs.has(name) ? attrs.get(name) : null),
setAttribute: (name, value) => attrs.set(name, value),
attrs,
};
}
test('applyDocumentTranslations translates opted-in text and attributes in place', () => {
const label = fakeElement({ text: '\n POWERED\n UP ' });
const pin = fakeElement({ attributes: { title: 'POWERED UP' } });
const unknown = fakeElement({ text: 'No such message' });
const doc = {
querySelectorAll(selector) {
if (selector === '[data-i18n]') return [label, unknown];
if (selector === '[data-i18n-title]') return [pin];
return [];
},
};
assert.equal(
applyDocumentTranslations(doc),
0,
'English leaves markup alone',
);
setLocale('ru');
assert.equal(applyDocumentTranslations(doc), 2);
assert.equal(label.textContent, t('POWERED UP'));
assert.equal(pin.attrs.get('title'), t('POWERED UP'));
assert.equal(unknown.textContent, 'No such message');
});
test('applyDocumentTranslations reads data-i18n-context for element text', () => {
const option = fakeElement({
text: 'All',
attributes: { 'data-i18n-context': 'radio tag' },
});
const doc = {
querySelectorAll: (selector) =>
selector === '[data-i18n]' ? [option] : [],
};
setLocale('pt');
applyDocumentTranslations(doc);
assert.equal(option.textContent, t('All', { context: 'radio tag' }));
assert.notEqual(option.textContent, t('All'));
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
href: 'https://gev.local/?x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
const storage = {
setItem() {
throw new Error('blocked');
},
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
['reload'],
]);
});
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
const storage = memoryStorage();
const calls = [];
const location = {
href: 'https://gev.local/?lang=en&x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru');
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1#scene=a'],
['reload'],
]);
});
test('tn() shows a caller-formatted count and still picks the form by number', () => {
const label = (n) =>
tn(n, '{count} map object', '{count} map objects', {
count: formatNumber(n),
});
assert.equal(label(12345), '12,345 map objects');
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
setLocale('ru');
try {
assert.equal(label(12345), '12 345 объектов на карте');
assert.equal(label(1), '1 объект на карте');
} finally {
setLocale('en');
}
});