mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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]>
206 lines
5.7 KiB
JavaScript
206 lines
5.7 KiB
JavaScript
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'],
|
||
]);
|
||
});
|