Files
gods-eye-view/src/i18n/i18n.test.mjs
T
German KhokhlovandClaude Opus 5.5 13efd411b6 refactor(i18n): key messages by English text; add Russian
Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:48:56 +04:00

206 lines
5.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('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null);
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('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('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'],
]);
});