Files

476 lines
37 KiB
JavaScript

// Language packs as content packs (docs/I18N.md "Adding a language", docs/PACKS.md): discovery (server/packs.js — the
// language folders and the pack folders, a pack dropped in shows without a restart; GET /packs/index.json and the file
// allowlist), the manifest (shared/packs.js: types, app ranges), the fallback chain (shared/i18n.js: base, fallback, the
// Chinese msgid), placeholder validation, plural categories (Intl.PluralRules: English unchanged, Russian named forms),
// the per-text fallback of game texts (shared/i18nData.js onto, data.js chains), the client loader and menu
// (ui/lang.js), the translator tools (tools/i18n.mjs template / check, tools/packs.mjs index), the machine-translation
// mark (`_meta.machineTranslated`: the manifest, the index, the client's registry, the 设置 note), and that the Chinese
// and English output of t() is what it was before packs. The example packs live only here, in temporary folders ('qaa'
// … 'qtz' are the ISO 639 codes reserved for local use).
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { APP_VERSION } from '../shared/constants.js';
import {
t, tc, tParts, setLang, getLang, addMessages, setMessages, setI18nWarn, registerLangs, getLangs, langChain, langInfo, normalizeLang,
checkTranslation, parsePluralForms, pluralCategory, format,
} from '../shared/i18n.js';
import { canonicalLang, computeChain, scriptOf, langFields, packMeta } from '../shared/i18nPacks.js';
import { normalizeManifest, appVersionMatches, isVersionRange, readPackIndex, langMetaOf, PACK_TYPES, isPackPath, packIndexEntry } from '../shared/packs.js';
import { buildRecordOverlay, applyFileOverlay, applyRecordOverlay } from '../shared/i18nData.js';
import { buildOverlay, LANG_SOURCES } from '../tools/build-i18n.mjs';
import { scanPacks, createPackRegistry, packIndexOf } from '../server/packs.js';
import { createDataStore } from '../public/js/data.js';
import { checkPack, packTemplate } from '../tools/i18n.mjs';
import { writePackIndex } from '../tools/packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const EN = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
const ZH_TW = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/i18n/zh-TW.json'), 'utf8'));
/** The 设置 note of a pack marked as machine translation (ui/lang.js machineTranslationNote). */
const MT_NOTE = '当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。';
afterEach(() => {
setLang('zh');
setI18nWarn(null);
for (const l of ['en', 'qaa', 'qab', 'pt', 'pt-BR', 'zh-TW', 'ru']) setMessages(l, {});
});
const tmpdir = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-packs-${tag}-`));
const put = (dir, rel, body) => {
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
};
/** A checkout-shaped temporary root with example packs of both layouts and a few broken ones. */
function exampleRoot() {
const dir = tmpdir('root');
put(dir, 'public/i18n/en.json', { _meta: { name: 'English', complete: true }, 开始: 'Start', 设置: 'Settings' });
put(dir, 'public/i18n/qaa.json', { _meta: { name: 'Qaa-Sprache', englishName: 'Test language A', fallback: ['en'], app: '>=0.2.0', authors: 'Tester' }, 开始: 'Los' });
put(dir, 'data/i18n/qaa.json', { version: 1, lang: 'qaa', names: {}, files: {} });
put(dir, 'public/i18n/zh.json', { 开始: '开始' }); // the source language is never a pack
put(dir, 'public/i18n/QAC.json', { 开始: 'x' }); // not in its usual case
put(dir, 'public/i18n/qad.json', '{ not json');
put(dir, 'packs/qab/pack.json', { type: 'lang', lang: 'qab', name: 'Qab', version: '1.0.0', app: '0.1.x', files: { ui: 'ui.json', data: 'texts/game.json' } });
put(dir, 'packs/qab/ui.json', { 开始: 'Qab-Start', 设置: 'Qab-Settings' });
put(dir, 'packs/qab/texts/game.json', { version: 1, lang: 'qab', names: { 阿米娅: 'Qab-Amiya' }, files: {} });
put(dir, 'packs/qab/notes.html', '<script>alert(1)</script>');
put(dir, 'packs/art/pack.json', { type: 'assets', name: 'HD art' });
put(dir, 'packs/dup/pack.json', { type: 'lang', lang: 'qaa', files: { ui: 'ui.json' } });
put(dir, 'packs/dup/ui.json', { 开始: 'dup' });
put(dir, 'packs/broken/pack.json', { type: 'lang', files: { ui: '../escape.json' } });
put(dir, 'packs/nomanifest/ui.json', { 开始: 'x' });
return dir;
}
const dirsOf = (root) => ({ publicDir: path.join(root, 'public'), dataDir: path.join(root, 'data'), packsDir: path.join(root, 'packs') });
test('discovery: single-file language packs and folder packs, one registry; broken, planned and duplicate packs are skipped with a reason', () => {
const root = exampleRoot();
try {
const scan = scanPacks(dirsOf(root), { app: '0.2.0' });
assert.deepEqual(scan.packs.map((p) => [p.id, p.type, p.layout, p.manifest.lang]), [['en', 'lang', 'file', 'en'], ['qaa', 'lang', 'file', 'qaa'], ['qab', 'lang', 'folder', 'qab']]);
const why = Object.fromEntries(scan.skipped.map((s) => [s.where, s.problems.join('; ')]));
assert.match(why['public/i18n/zh.json'], /source language/);
assert.match(why['public/i18n/QAC.json'], /not a language code in its usual case/);
assert.match(why['public/i18n/qad.json'], /not valid JSON/);
assert.match(why['packs/art/'], /type "assets" is planned/);
assert.match(why['packs/dup/'], /language qaa is already provided by public\/i18n\/qaa\.json/);
assert.match(why['packs/broken/'], /no "lang".*"files\.ui": "\.\.\/escape\.json" is not a path inside the pack folder/);
assert.match(why['packs/nomanifest/'], /no pack\.json/);
assert.ok(scan.warnings.some((w) => w.where === 'packs/qab/' && /made for app 0\.1\.x; this is 0\.2\.0/.test(w.warning)));
const index = packIndexOf(scan.packs, { app: '0.2.0' });
const qaa = index.packs.find((p) => p.id === 'qaa');
assert.deepEqual(qaa, {
id: 'qaa', type: 'lang', name: 'Qaa-Sprache', englishName: 'Test language A', app: '>=0.2.0', compatible: true, authors: ['Tester'],
lang: 'qaa', fallback: ['en'], strings: 1, files: { ui: '/i18n/qaa.json', data: '/data/i18n/qaa.json' },
});
const qab = index.packs.find((p) => p.id === 'qab');
assert.equal(qab.compatible, false, 'outside its app range: listed, flagged');
assert.deepEqual(qab.files, { ui: '/packs/qab/ui.json', data: '/packs/qab/texts/game.json' });
assert.equal(index.packs.find((p) => p.id === 'en').files.data, undefined, 'no data/i18n/en.json in this root');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('discovery: a pack dropped into the folder shows on the next look (no restart), a removed one leaves; only named files are servable', () => {
const root = exampleRoot();
try {
const reg = createPackRegistry(dirsOf(root), { recheckMs: 0 });
assert.ok(!reg.index().packs.some((p) => p.id === 'qae'));
put(root, 'public/i18n/qae.json', { _meta: { name: 'Neu' }, 开始: 'Neu-Start' });
assert.ok(reg.index().packs.some((p) => p.id === 'qae' && p.name === 'Neu'), 'appears without a restart');
put(root, 'packs/qaf/pack.json', { type: 'lang', lang: 'qaf', files: { ui: 'ui.json' } });
put(root, 'packs/qaf/ui.json', { 开始: 'F' });
assert.ok(reg.index().packs.some((p) => p.id === 'qaf' && p.files.ui === '/packs/qaf/ui.json'));
fs.rmSync(path.join(root, 'public/i18n/qae.json'));
assert.ok(!reg.index().packs.some((p) => p.id === 'qae'), 'removed');
assert.equal(reg.servable('qab', 'ui.json'), path.join(root, 'packs/qab/ui.json'));
assert.equal(reg.servable('qab', 'texts/game.json'), path.join(root, 'packs/qab/texts/game.json'));
assert.equal(reg.servable('qab', 'notes.html'), null, 'a file the manifest does not name');
assert.equal(reg.servable('qab', 'pack.json'), null);
assert.equal(reg.servable('art', 'pack.json'), null, 'a planned type is never served');
assert.equal(reg.servable('qaa', 'qaa.json'), null, 'a single-file pack is served by the language folders');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('the server: GET /packs/index.json lists the packs live, GET /packs/<id>/<file> serves only what a manifest names', async () => {
const root = exampleRoot();
const { startServer } = await import('../server/index.js');
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(root, 'public'), packsDir: path.join(root, 'packs') });
try {
const get = (u) => fetch(srv.url + u);
const res = await get('/packs/index.json');
assert.equal(res.status, 200);
assert.equal(res.headers.get('cache-control'), 'no-store');
const body = await res.json();
assert.equal(body.version, 1);
assert.deepEqual(readPackIndex(body, 'lang').map((p) => p.id), ['en', 'qaa', 'qab']);
assert.equal((await get('/packs/qab/ui.json')).status, 200);
assert.deepEqual(await (await get('/packs/qab/texts/game.json')).json(), { version: 1, lang: 'qab', names: { 阿米娅: 'Qab-Amiya' }, files: {} });
for (const u of ['/packs/qab/notes.html', '/packs/qab/pack.json', '/packs/art/pack.json', '/packs/qab/', '/packs/nope/ui.json']) {
assert.equal((await get(u)).status, 404, u);
}
// a raw path (fetch would normalize the dots away)
const raw = await new Promise((resolve, reject) => {
http.get({ host: '127.0.0.1', port: srv.port, path: '/packs/qab/../qab/ui.json' }, (r) => { r.resume(); resolve(r.statusCode); }).on('error', reject);
});
assert.equal(raw, 404);
assert.equal((await get('/i18n/qaa.json')).status, 200, 'a single-file pack: the language folder');
put(root, 'public/i18n/qag.json', { 开始: 'G' });
await new Promise((r) => setTimeout(r, 1100)); // the registry looks again after a second
assert.ok((await (await get('/packs/index.json')).json()).packs.some((p) => p.id === 'qag'), 'a dropped pack shows on the next request');
} finally {
await srv.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('manifest: common fields, type fields, app ranges; English is a pack like any other', () => {
assert.deepEqual(Object.entries(PACK_TYPES).map(([k, v]) => [k, v.status]), [['lang', 'supported'], ['assets', 'planned'], ['data', 'planned']]);
for (const [range, v, ok] of [['>=0.2.0', '0.2.0-dev', true], ['>=0.2.0', '0.1.4', false], ['0.2.x', '0.2.7', true], ['0.2.x', '0.3.0', false],
['^0.2.1', '0.2.9', true], ['^0.2.1', '0.3.0', false], ['^1.2.0', '1.9.0', true], ['~0.2.1', '0.2.0', false], ['>=0.2.0 <0.3.0', '0.2.5', true],
['>=0.2.0 <0.3.0', '0.3.0', false], ['0.1.x || 0.2.x', '0.2.0', true], ['*', '9.9.9', true], ['', '0.0.1', true], ['0.2.0', '0.2.1', false]]) {
assert.equal(appVersionMatches(range, v), ok, `${range} ∋ ${v}`);
}
assert.equal(isVersionRange('soon'), false);
assert.equal(appVersionMatches('soon', '0.2.0'), true, 'an unreadable range never blocks');
const { manifest, problems, warnings } = normalizeManifest({ type: 'lang', lang: 'pt_br', id: 'other', name: 'Português', fallback: ['pt', 'zh', 'en'], base: 'zh', numberUnits: ['a'] }, { id: 'pt-BR', folder: true, app: '0.2.0' });
assert.deepEqual(problems, ['no "files.ui"']);
assert.equal(manifest, null);
assert.ok(warnings.some((w) => /"id": "other" is not "pt-BR"/.test(w)));
const ok = normalizeManifest({ type: 'lang', lang: 'pt-BR', fallback: ['pt', 'zh', 'en'], base: 'zh', files: { ui: 'ui.json', extra: 'x.json' } }, { id: 'pt-BR', folder: true, app: '0.2.0' });
assert.deepEqual([ok.manifest.lang, ok.manifest.base, ok.manifest.fallback, ok.manifest.files], ['pt-BR', null, ['pt', 'en'], { ui: 'ui.json' }], 'the source is never a base or a fallback');
assert.ok(ok.warnings.some((w) => /files\.extra/.test(w)));
assert.match(normalizeManifest({ type: 'theme' }, { id: 'x', folder: true }).problems.join(), /unknown type "theme"/);
assert.match(normalizeManifest({ type: 'assets' }, { id: 'x', type: 'lang', lang: 'qaa' }).problems.join(), /"type": "assets" where a lang pack belongs/);
assert.deepEqual(['ui.json', 'a/b.json', '../x.json', '/abs.json', 'a//b.json', '.hidden.json', 'a\\b.json'].map(isPackPath), [true, true, false, false, false, false, false]);
// English: a single-file pack whose `_meta` is the manifest — complete, no fallback, nothing special in the client
const en = normalizeManifest(EN._meta, { id: 'en', type: 'lang', lang: 'en', app: '0.2.0' });
assert.deepEqual(en.problems, []);
assert.deepEqual(en.warnings, []);
assert.deepEqual([en.manifest.name, en.manifest.complete, en.manifest.fallback, en.manifest.numberUnits], ['English', true, [], null]);
assert.ok(en.manifest.authors.some((a) => a.includes('@YuriRestia')), 'PR #70 credited');
});
test('manifest: machineTranslated — a boolean, carried into the index entry like complete and into the client\'s registry (from the index or the pack\'s own _meta); the shipped zh-TW pack has it', () => {
const ctx = { id: 'qam', type: 'lang', lang: 'qam', app: '0.2.0' };
const on = normalizeManifest({ name: 'Qam', machineTranslated: true }, ctx);
assert.deepEqual([on.problems, on.warnings, on.manifest.machineTranslated], [[], [], true]);
assert.equal(normalizeManifest({ name: 'Qam' }, ctx).manifest.machineTranslated, false, 'optional: absent reads as false');
const odd = normalizeManifest({ name: 'Qam', machineTranslated: 'yes' }, ctx);
assert.deepEqual(odd.problems, [], 'a warning, not a problem: the pack still loads');
assert.deepEqual(odd.warnings, ['"machineTranslated": "yes" is not true or false (read as false)']);
assert.equal(odd.manifest.machineTranslated, false);
assert.match(normalizeManifest({ machineTranslated: 1 }, ctx).warnings.join(), /"machineTranslated": 1 is not true or false/);
// the index entry: only when true (like complete)
const files = { ui: '/i18n/qam.json' };
assert.equal(packIndexEntry(on.manifest, { files, strings: 1 }).machineTranslated, true);
assert.equal(Object.hasOwn(packIndexEntry(odd.manifest, { files }), 'machineTranslated'), false);
// a checkout's packs → the index → readPackIndex / langMetaOf → the client's registry (langInfo)
const root = tmpdir('mt');
try {
put(root, 'public/i18n/qam.json', { _meta: { name: 'Qam', fallback: ['en'], machineTranslated: true }, 开始: 'Qam-Start' });
put(root, 'public/i18n/qan.json', { _meta: { name: 'Qan' }, 开始: 'Qan-Start' });
const index = packIndexOf(scanPacks(dirsOf(root), { app: '0.2.0' }).packs, { app: '0.2.0' });
const entries = readPackIndex(index, 'lang');
assert.deepEqual(entries.map((e) => [e.id, e.machineTranslated]), [['qam', true], ['qan', undefined]]);
registerLangs(entries.map(langMetaOf));
assert.deepEqual([langInfo('qam').machineTranslated, langInfo('qan').machineTranslated, langInfo('zh').machineTranslated], [true, false, false]);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
// without an index the pack's own `_meta` registers it (addMessages → packMeta)
assert.equal(packMeta('qao', { _meta: { machineTranslated: true } }).machineTranslated, true);
addMessages('qao', { _meta: { name: 'Qao', machineTranslated: true }, 开始: 'Qao-Start' });
assert.equal(langInfo('qao').machineTranslated, true);
// the shipped Traditional Chinese pack: a machine conversion (OpenCC, the official TW terms, a manual pass), complete,
// no fallback (the Simplified Chinese msgids), the note in Traditional Chinese
const tw = normalizeManifest(ZH_TW._meta, { id: 'zh-TW', type: 'lang', lang: 'zh-TW', app: '0.2.0' });
assert.deepEqual([tw.problems, tw.warnings], [[], []]);
assert.deepEqual([tw.manifest.machineTranslated, tw.manifest.complete, tw.manifest.fallback, tw.manifest.numberUnits], [true, true, [], ['萬', '億']]);
assert.equal(ZH_TW[MT_NOTE], '目前語言的介面文字為機器翻譯,可能不夠準確,歡迎在 GitHub 上指正。');
assert.ok(EN[MT_NOTE], 'English has the note');
});
test('fallback chain: the pack, its base (pt-BR → pt; zh-TW none), its fallbacks, then the Chinese msgid; cycles end', () => {
const metas = { 'pt-BR': { fallback: ['en'] }, pt: { fallback: ['es'] }, es: {}, en: {}, 'zh-TW': { fallback: [] }, a: { fallback: ['b'] }, b: { fallback: ['a'] } };
const known = (c) => Object.hasOwn(metas, c);
assert.deepEqual(computeChain('pt-BR', (c) => metas[c], known), ['pt-BR', 'pt', 'es', 'en']);
assert.deepEqual(computeChain('zh-TW', (c) => metas[c], known), ['zh-TW'], 'zh is the source: no base');
assert.deepEqual(computeChain('pt-BR', (c) => metas[c], (c) => c !== 'pt' && known(c)), ['pt-BR', 'en'], 'a base without a pack is skipped');
assert.deepEqual(computeChain('a', (c) => metas[c], known), ['a', 'b']);
assert.deepEqual(computeChain('pt-BR', () => ({ base: null }), known), ['pt-BR'], 'base: null switches it off');
// through t() / tc(): a partial pack falls through per string
registerLangs([{ code: 'pt-BR', name: 'Português (Brasil)', fallback: ['en'] }, { code: 'pt', name: 'Português' }, { code: 'en', name: 'English' }]);
addMessages('en', { 开始: 'Start', 设置: 'Settings', 关闭: 'Close', 'toggle::关闭': 'Off', 准备: 'Ready' });
addMessages('pt', { 设置: 'Configurações', 关闭: 'Fechar' });
addMessages('pt-BR', { 开始: 'Iniciar' });
assert.deepEqual(langChain('pt-BR'), ['pt-BR', 'pt', 'en']);
assert.equal(normalizeLang('PT_br'), 'pt-BR');
assert.equal(normalizeLang('pt-PT'), 'pt', 'a region without a pack takes the language');
setLang('pt-BR');
assert.deepEqual([t('开始'), t('设置'), t('准备'), t('没有的')], ['Iniciar', 'Configurações', 'Ready', '没有的']);
assert.equal(tc('toggle', '关闭'), 'Fechar', 'per language: pt\'s plain msgid before en\'s context key');
setLang('en');
assert.equal(tc('toggle', '关闭'), 'Off');
assert.equal(t('开始'), 'Start');
});
test('placeholders: a translation that drops one or uses one the caller does not pass is skipped (reported once), the chain goes on', () => {
assert.deepEqual(checkTranslation('还剩 {n} 秒', '{n} seconds left'), { ok: true, problems: [], extras: [] });
assert.deepEqual(checkTranslation('还剩 {n} 秒', 'seconds left').problems, ['drops {n}']);
assert.deepEqual(checkTranslation('还剩 {n} 秒', '{n|second|seconds} left').problems, ['drops {n}'], 'a plural form does not show the number');
assert.deepEqual(checkTranslation('{n} 名', '{n} {count|unit|units}').extras, ['count']);
assert.deepEqual(checkTranslation('{n}', '{n} {n|a|b|c}').problems, ['malformed plural {n|a|b|c}']);
registerLangs([{ code: 'qaa', fallback: ['en'] }, { code: 'en' }]);
addMessages('en', { '还剩 {n} 秒': '{n} seconds left', '临时整备区 {n} 个单位待处理': 'Bench: {n} {count|unit|units}' });
addMessages('qaa', { '还剩 {n} 秒': 'nur noch Sekunden', '临时整备区 {n} 个单位待处理': '{n} {count|Einheit|Einheiten} {extra}' });
const seen = [];
setI18nWarn((w) => seen.push(w));
setLang('qaa');
assert.equal(t('还剩 {n} 秒', { n: 3 }), '3 seconds left', 'qaa drops {n}: English shows');
assert.equal(t('还剩 {n} 秒', { n: 4 }), '4 seconds left');
assert.equal(t('临时整备区 {n} 个单位待处理', { n: 2, count: 2 }), 'Bench: 2 units', '{extra} is not passed');
assert.equal(t('临时整备区 {n} 个单位待处理', { n: 2, count: 2, extra: '!' }), '2 Einheiten !', 'passed: fine');
assert.deepEqual(seen.map((w) => [w.lang, w.key, w.problems.join()]), [
['qaa', '还剩 {n} 秒', 'drops {n}'],
['qaa', '临时整备区 {n} 个单位待处理', 'uses {extra}, which the caller does not pass'],
], 'once per language and key');
setMessages('en', {});
assert.equal(t('还剩 {n} 秒', { n: 3 }), '还剩 3 秒', 'nothing fits: the Chinese msgid');
});
test('plurals: {n|one|other} by Intl.PluralRules (English as before); named categories for other languages (Russian)', () => {
assert.deepEqual(parsePluralForms('|second|seconds'), { one: 'second', other: 'seconds' });
assert.deepEqual(parsePluralForms('|one:день|few:дня|many:дней|other:дня'), { one: 'день', few: 'дня', many: 'дней', other: 'дня' });
assert.equal(parsePluralForms('|one:a|few:b'), null, 'named forms need other');
assert.equal(parsePluralForms('|one:a|b'), null, 'no mixing');
assert.equal(parsePluralForms('|a|b|c'), null, 'three plain forms: the category order is not stable');
assert.deepEqual([0, 1, 2, 5, 21, 1.5].map((n) => pluralCategory('en', n)), ['other', 'one', 'other', 'other', 'other', 'other']);
assert.deepEqual([0, 1, 2, 5, 21, 1.5].map((n) => pluralCategory('ru', n)), ['many', 'one', 'few', 'many', 'one', 'other']);
assert.deepEqual([0, 1, 2].map((n) => pluralCategory('qaa', n)), ['other', 'one', 'other'], 'a language the runtime does not know: one for exactly 1');
registerLangs([{ code: 'ru', name: 'Русский' }, { code: 'en' }]);
addMessages('en', { '还剩 {n} 秒': '{n} {n|second|seconds} left' });
addMessages('ru', { '还剩 {n} 秒': 'осталось {n} {n|one:секунда|few:секунды|many:секунд|other:секунды}', '{n} 名干员': '{n} {n|оператор|операторов}' });
setLang('en');
assert.deepEqual([0, 1, 2].map((n) => t('还剩 {n} 秒', { n })), ['0 seconds left', '1 second left', '2 seconds left']);
setLang('ru');
assert.deepEqual([1, 2, 5, 21, 1.5].map((n) => t('还剩 {n} 秒', { n })), ['осталось 1 секунда', 'осталось 2 секунды', 'осталось 5 секунд', 'осталось 21 секунда', 'осталось 1.5 секунды']);
assert.deepEqual([1, 3].map((n) => t('{n} 名干员', { n })), ['1 оператор', '3 операторов'], 'two plain forms: one / the rest');
const b = { type: 'b' }; // a vnode stand-in: no number, so the form of 'other'
assert.deepEqual(tParts('还剩 {n} 秒', { n: b }), ['осталось ', b, ' секунды']);
assert.equal(format('{n|one:a|other:b}', { n: 1 }, { lang: 'ru' }), 'a');
});
test('zh and English output of t() / tc() / tParts() is what it was before language packs (every msgid of public/i18n/en.json)', () => {
// the renderer of 0.2.0 before packs, frozen: one catalog, `{n|one|other}` by Number(v) === 1, lists ', ' / '、'
const OLD = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g;
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
const val = (v, lang) => (v == null ? '' : Array.isArray(v) ? v.map((x) => val(x, lang)).filter((x) => x !== '').join(lang === 'en' ? ', ' : '、') : typeof v === 'object' ? (own(v, 'dn') ? String(v.dn) : '') : String(v));
const oldFormat = (s, params, lang) => (!params || !s.includes('{') ? s : s.replace(OLD, (m, k, one, other) => (!own(params, k) ? m : one !== undefined ? (Number(params[k]) === 1 ? one : other) : val(params[k], lang))));
addMessages('en', EN);
const samples = [0, 1, 2, 5, 21, 'x', ['甲', '乙']];
let compared = 0;
for (const lang of ['zh', 'en']) {
setLang(lang);
for (const [key, tr] of Object.entries(EN)) {
if (key.startsWith('_')) continue;
const sep = key.indexOf('::');
const ctx = sep >= 0 ? key.slice(0, sep) : null;
const msgid = sep >= 0 ? key.slice(sep + 2) : key;
const names = [...new Set([...`${msgid} ${tr}`.matchAll(OLD)].map((m) => m[1]))];
for (let k = 0; k < samples.length; k++) {
const params = names.length ? Object.fromEntries(names.map((nm, i) => [nm, samples[(i + k) % samples.length]])) : undefined;
const want = oldFormat(lang === 'en' ? tr : msgid, params, lang);
const got = ctx ? tc(ctx, msgid, params) : t(msgid, params);
assert.equal(got, want, `${lang} ${key} ${JSON.stringify(params)}`);
compared++;
if (!names.length) break;
}
}
}
assert.ok(compared > 3000, `${compared} renders compared`);
assert.equal(getLang(), 'en');
});
test('game texts fall back per text: the pack\'s overlay, then its fallback\'s, then Chinese (data.js chains, i18nData onto)', async () => {
const zh = { a: { name: '甲', desc: '一', skills: [{ name: '技' }] }, b: { name: '乙' } };
const ov = (lang, leaves) => ({ version: 1, lang, names: {}, files: { chess: leaves } });
const qaa = ov('qaa', { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A-qaa' }]) });
const en = ov('en', { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A' }, { path: ['desc'], zh: '一', en: 'one' }, { path: ['skills', 0, 'name'], zh: '技', en: 'Skill' }]), b: buildRecordOverlay([{ path: ['name'], zh: '乙', en: 'B' }]) });
en.names = { 甲: 'A' };
qaa.names = { 乙: 'B-qaa' };
// the chained apply: lowest priority first, each over the last, hashes always against the Chinese
let v = applyFileOverlay(zh, en.files.chess).value;
v = applyFileOverlay(zh, qaa.files.chess, v).value;
assert.deepEqual(v, { a: { name: 'A-qaa', desc: 'one', skills: [{ name: 'Skill' }] }, b: { name: 'B' } });
const stale = applyFileOverlay({ ...zh, a: { ...zh.a, name: '变了' } }, qaa.files.chess, v);
assert.equal(stale.value.a.name, 'A-qaa', 'a stale leaf keeps what the overlays before it left');
assert.equal(stale.stale, 1);
const files = { '/data/chess.json': zh, '/packs/qaa/game.json': qaa, '/data/i18n/en.json': en };
const calls = [];
const d = createDataStore({ retryDelays: [], fetch: async (url) => { calls.push(url); return files[url] ? { ok: true, status: 200, json: async () => files[url] } : { ok: false, status: 404, json: async () => null }; } });
await d.load('chess');
assert.equal(await d.setLocale('qaa', [{ code: 'qaa', url: '/packs/qaa/game.json' }, 'en']), 'qaa');
assert.deepEqual(calls.filter((u) => u.includes('i18n') || u.includes('packs')).sort(), ['/data/i18n/en.json', '/packs/qaa/game.json']);
assert.deepEqual(d.lookup('chess', 'a'), { name: 'A-qaa', desc: 'one', skills: [{ name: 'Skill' }] });
assert.equal(d.lookup('chess', 'b').name, 'B');
assert.deepEqual([d.localeName('乙'), d.localeName('甲'), d.localeName('丙')], ['B-qaa', 'A', '丙'], 'names: the first overlay of the chain that knows one');
assert.deepEqual(d.localeChain(), ['qaa', 'en']);
assert.equal(await d.setLocale('qab', []), 'qab', 'a pack without game texts switches with the Chinese ones');
assert.equal(d.lookup('chess', 'a').name, '甲');
const w = console.warn;
console.warn = () => {};
try {
assert.equal(await d.setLocale('qac', ['qac']), 'qab', 'every overlay of the chain missing: no switch (as before packs)');
} finally { console.warn = w; }
});
test('client: the index registers the language packs; the menu lists them (buttons up to four, a list beyond); packs load by their URLs', async () => {
const lang = await import('../public/js/ui/lang.js');
const index = { version: 1, packs: [
{ id: 'en', type: 'lang', lang: 'en', name: 'English', englishName: 'English', fallback: [], files: { ui: '/i18n/en.json', data: '/data/i18n/en.json' } },
{ id: 'qab', type: 'lang', lang: 'qab', name: 'Qab', englishName: 'Test B', fallback: ['en'], numberUnits: ['W', 'Y'], files: { ui: '/packs/qab/ui.json' } },
{ id: 'art', type: 'assets', name: 'x', files: {} },
{ id: 'evil', type: 'lang', lang: 'qay', name: 'x', files: { ui: 'https://example.com/x.json' } },
] };
const got = [];
const fetchFake = async (url) => {
got.push(url);
const body = url === '/packs/index.json' ? index : url === '/packs/qab/ui.json' ? { 开始: 'Qab-Start' } : url === '/i18n/en.json' ? { 开始: 'Start', 设置: 'Settings' } : null;
return body ? { ok: true, status: 200, json: async () => body } : { ok: false, status: 404, json: async () => null };
};
assert.equal(await lang.loadLangIndex(fetchFake), true);
assert.deepEqual(readPackIndex(index, 'lang').map((e) => e.id), ['en', 'qab'], 'other types and off-site URLs are dropped');
assert.deepEqual(getLangs().map((m) => m.code).filter((c) => ['zh', 'en', 'qab'].includes(c)), ['zh', 'en', 'qab'], 'the source first, then by code');
assert.equal(langInfo('qab').ui, '/packs/qab/ui.json');
const menu = lang.langMenuModel(getLangs().filter((m) => ['zh', 'en', 'qab'].includes(m.code)), 'en');
assert.equal(menu.kind, 'buttons');
assert.deepEqual(menu.items.map((i) => [i.code, i.label, i.htmlLang, i.on]), [['zh', '中文', 'zh-CN', false], ['en', 'English', 'en', true], ['qab', 'Qab', 'qab', false]]);
assert.equal(menu.items[2].title, 'Test B', 'the English name as a tooltip when it reads differently');
const many = ['a1', 'a2', 'a3', 'a4', 'a5'].map((c) => ({ code: c, name: c, englishName: c }));
assert.equal(lang.langMenuModel(many, 'a1').kind, 'select');
assert.equal(await lang.loadLangChain('qab', fetchFake), true);
assert.ok(got.includes('/packs/qab/ui.json') && got.includes('/i18n/en.json'), 'the pack and its fallback, by their URLs');
assert.deepEqual(lang.dataChain('qab'), [{ code: 'en', url: '/data/i18n/en.json' }], 'only packs with game texts');
setLang('qab');
assert.deepEqual([t('开始'), t('设置')], ['Qab-Start', 'Settings']);
const { fmtNum } = await import('../public/js/ui/gameLogic.js');
assert.deepEqual([fmtNum(150_000), fmtNum(2.5e8)], ['15.0W', '2.5Y'], 'the pack\'s own number units');
setLang('en');
assert.deepEqual([fmtNum(150_000), fmtNum(2.5e8)], ['150K', '250M'], 'English: K / M / B as before');
setLang('zh');
assert.deepEqual([fmtNum(150_000), fmtNum(2.5e8)], ['15.0万', '2.5亿'], 'Chinese as before');
assert.deepEqual(lang.initialLang('?lang=QAB', () => null), { lang: 'qab', fromUrl: true });
assert.deepEqual(lang.initialLang('', () => 'qzz'), { lang: 'zh', fromUrl: false }, 'a stored pack that is gone: Chinese');
assert.deepEqual(lang.initialLang('', () => 'qzz', { tentative: true }), { lang: 'qzz', fromUrl: false }, 'without an index: tried by its code');
assert.deepEqual([scriptOf('Stronghold Protocol'), scriptOf('卫戍协议'), scriptOf('堅守協定'), scriptOf('위수 협의'), scriptOf('Протокол')], ['alphabetic', 'cjk', 'cjk', 'cjk', 'alphabetic']);
assert.deepEqual(langMetaOf(readPackIndex(index, 'lang')[1]).numberUnits, ['W', 'Y']);
assert.deepEqual(langFields('qab', { base: 'zh', fallback: 'en' }), { lang: 'qab', base: null, fallback: ['en'], complete: false, machineTranslated: false, numberUnits: null });
assert.equal(canonicalLang('zh-hant-tw'), 'zh-Hant-TW');
});
test('设置: the machine-translation note shows while the current language\'s pack is marked (ui/lang.js machineTranslationNote), in that language; none for Chinese, English or an unmarked pack', async () => {
const { machineTranslationNote } = await import('../public/js/ui/lang.js');
registerLangs([{ code: 'qap', name: 'Qap', fallback: ['en'], machineTranslated: true }, { code: 'qaq', name: 'Qaq', fallback: ['en'] }, { code: 'en', name: 'English' }]);
addMessages('en', { [MT_NOTE]: EN[MT_NOTE] });
addMessages('qap', { [MT_NOTE]: 'Qap-Hinweis' });
assert.equal(machineTranslationNote(), null, 'Chinese: no note');
setLang('qap');
assert.equal(machineTranslationNote(), 'Qap-Hinweis', 'the marked pack, in its own words');
setMessages('qap', {});
assert.equal(machineTranslationNote(), EN[MT_NOTE], 'a marked pack without the string: the next language of its chain');
setLang('qaq');
assert.equal(machineTranslationNote(), null, 'a pack that is not marked');
setLang('en');
assert.equal(machineTranslationNote(), null, 'English is not marked');
// the shipped zh-TW pack (no index here: its `_meta` registers it)
addMessages('zh-TW', ZH_TW);
setLang('zh-TW');
assert.equal(machineTranslationNote(), ZH_TW[MT_NOTE]);
assert.equal(t('设置'), '設定', 'and the rest of the interface in Traditional Chinese');
});
test('tools: template writes a skeleton (or adds the missing msgids to a pack), check reports coverage and errors per pack; packs index writes the static index', () => {
const used = new Map([['开始', { where: 'a.js:1', params: [] }], ['还剩 {n} 秒', { where: 'b.js:2', params: new Set(['n']) }], ['{n} 名', { where: 'c.js:3', params: new Set(['n', 'count']) }], ['表里的', { where: 'd.js:4', params: null }]]);
const skel = packTemplate('qaa', { msgids: [...used.keys()] });
assert.deepEqual(Object.keys(skel.json), ['_meta', '开始', '还剩 {n} 秒', '{n} 名', '表里的']);
assert.deepEqual([skel.json._meta.type, skel.json._meta.lang, skel.json._meta.fallback, skel.json._meta.app], ['lang', 'qaa', ['en'], `>=${APP_VERSION.replace(/-.*$/, '')}`], 'this release and later (tools/i18n.mjs appRange)');
assert.equal(packTemplate('zh-TW', { msgids: [] }).json._meta.fallback.length, 0, 'a Chinese variant falls back to the Chinese msgid');
assert.equal(skel.json['开始'], '');
const again = packTemplate('qaa', { existing: { _meta: { name: 'Mine' }, 开始: 'Los', 旧的: 'alt' }, msgids: [...used.keys()], fill: { 还剩: 'x' } });
assert.deepEqual([again.json._meta, again.json['开始'], again.json['旧的'], again.kept, again.added], [{ name: 'Mine' }, 'Los', 'alt', 1, 3], 'translations and _meta kept, the rest added');
const r = checkPack({ 开始: 'Los', '还剩 {n} 秒': 'Sekunden', '{n} 名': '{n} {count|Name|Namen} {who}', 表里的: '{any}', 旧的: 'alt', 'x::y': 3 }, used, new Set([...used.keys()]));
assert.deepEqual([r.total, r.translated, r.missing.length, r.obsolete], [4, 2, 0, ['旧的']]);
assert.deepEqual(r.errors.map((e) => e.split(':')[0]), ['not a string', 'drops {n}', 'uses {who}, which no call site passes']);
// the CLI on a temporary checkout's packs (the msgids come from this checkout's code)
const root = tmpdir('cli');
try {
put(root, 'public/i18n/en.json', EN);
const run = (...args) => spawnSync(process.execPath, [path.join(ROOT, 'tools/i18n.mjs'), ...args, '--root', root], { encoding: 'utf8' });
const tpl = run('template', 'qaa');
assert.equal(tpl.status, 0, tpl.stderr);
assert.match(tpl.stdout, /wrote public\/i18n\/qaa\.json: \d+ msgids — 0 translations kept, \d+ added \(empty\)/);
const pack = JSON.parse(fs.readFileSync(path.join(root, 'public/i18n/qaa.json'), 'utf8'));
assert.equal(Object.keys(pack).length - 1, Object.keys(EN).filter((k) => !k.startsWith('_')).length, 'every msgid of the complete pack');
pack['开始'] = 'Los';
fs.writeFileSync(path.join(root, 'public/i18n/qaa.json'), JSON.stringify(pack));
const ok = run('check', 'qaa', '--strict');
assert.equal(ok.status, 0, `${ok.stdout}${ok.stderr}`);
assert.match(ok.stdout, /public\/i18n\/qaa\.json \(qaa, qaa\): \d+ msgids used, 1 translated \([\d.]+ %\), \d+ missing, 0 errors/);
pack['还剩 {n} 秒'] = 'x';
pack['第 {r} 回合 · 最终攻势'] = 'Runde';
fs.writeFileSync(path.join(root, 'public/i18n/qaa.json'), JSON.stringify(pack));
const bad = run('check', 'qaa', '--strict');
assert.equal(bad.status, 1);
assert.match(bad.stdout, /error {4}public\/i18n\/qaa\.json: drops \{r\}: 第 \{r\} 回合 · 最终攻势 {2}→ {2}Runde/);
const all = run('check', '--all');
assert.match(all.stdout, /public\/i18n\/en\.json \(English, en, complete\): (\d+) msgids used, \1 translated \(100 %\), 0 missing, 0 errors/);
const body = writePackIndex(root);
assert.deepEqual(body.packs.map((p) => p.id), ['en', 'qaa']);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'packs/index.json'), 'utf8')), body);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('build-i18n --lang: another official client (a text equal to the Chinese is no translation, no English composites); --dict for a language without one', () => {
assert.deepEqual(Object.keys(LANG_SOURCES), ['en', 'ja', 'ko', 'zh-TW']);
assert.match(LANG_SOURCES.ja.sources.assets.url, /ArknightsAssets\/ArknightsGamedata\/master\/jp\/gamedata\/$/);
const zh = [{ chars: { char_x: { name: '测试员', description: '攻击造成法术伤害' } } }];
const ja = [{ chars: { char_x: { name: 'テスター', description: '攻击造成法术伤害' } } }];
const data = { chess: { chess_x_a: { charId: 'char_x', name: '测试员', trait: { desc: '攻击造成法术伤害' } } }, stages: { m1: { name: '战场#05(下半) 测试员' } } };
const { overlay, report } = buildOverlay({ zh, en: ja, data, lang: 'ja' });
assert.equal(overlay.lang, 'ja');
const c = applyRecordOverlay(data.chess.chess_x_a, overlay.files.chess.chess_x_a).value;
assert.deepEqual([c.name, c.trait.desc], ['テスター', '攻击造成法术伤害'], 'the JP text equal to the Chinese one stays untranslated (falls back at run time)');
assert.equal(overlay.files.stages, undefined, 'the remake\'s stage-name composite is English wording only');
assert.equal(overlay.names['测试员'], 'テスター');
assert.equal(report.coverage.operators.translated, 1);
const fr = buildOverlay({ zh: [], en: [], data, lang: 'fr', fallback: { dict: { 测试员: 'Testeur', 攻击造成法术伤害: 'Inflige des dégâts magiques' } } });
const f = applyRecordOverlay(data.chess.chess_x_a, fr.overlay.files.chess.chess_x_a).value;
assert.deepEqual([fr.overlay.lang, f.name, f.trait.desc, fr.overlay.names['测试员']], ['fr', 'Testeur', 'Inflige des dégâts magiques', 'Testeur']);
assert.deepEqual([fr.report.coverage.operators.dict, fr.report.coverage.traits.dict], [1, 1], 'counted as the dictionary\'s');
});