Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged. Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
164 lines
10 KiB
JavaScript
164 lines
10 KiB
JavaScript
// i18n core (shared/i18n.js) and the UI string table (public/i18n/en.json): t() / tc() fallback and params, plurals,
|
|
// lists, data names, language state, the server message wire format (msg → wireMessage → translateWire), and the
|
|
// tooling's msgid derivation (tools/i18n.mjs). docs/I18N.md.
|
|
|
|
import { test, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
t, tc, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver,
|
|
tName, dn, msg, renderMessage, wireMessage, translateWire, DEFAULT_LANG,
|
|
} from '../shared/i18n.js';
|
|
import { templateMsgid, paramName, quote, codemodSource, scanSource } from '../tools/i18n.mjs';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const EN = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
|
|
|
|
afterEach(() => { setLang('zh'); setMessages('en', {}); setNameResolver(null); });
|
|
|
|
test('t(): Chinese is the default and the msgid; a missing translation falls back to the Chinese text', () => {
|
|
assert.equal(getLang(), DEFAULT_LANG);
|
|
addMessages('en', { 整备区已满: 'Bench is full' });
|
|
assert.equal(t('整备区已满'), '整备区已满', 'zh shows the msgid');
|
|
setLang('en');
|
|
assert.equal(t('整备区已满'), 'Bench is full');
|
|
assert.equal(t('没有翻译的句子'), '没有翻译的句子', 'untranslated → Chinese');
|
|
assert.equal(t(null), '');
|
|
assert.equal(t(42), '42');
|
|
assert.equal(hasMessage('整备区已满'), true);
|
|
assert.equal(hasMessage('没有翻译的句子'), false);
|
|
assert.equal(hasMessage('没有翻译的句子', 'zh'), true, 'the source language has every msgid');
|
|
});
|
|
|
|
test('params: {name}, positional {0}, plural {n|one|other}, lists and missing values', () => {
|
|
addMessages('en', { '还剩 {n} 秒': '{n} {n|second|seconds} left', '{0}博士对敌方领袖造成的伤害超过20%!': 'Dr. {0} dealt over 20%!' });
|
|
assert.equal(t('还剩 {n} 秒', { n: 3 }), '还剩 3 秒');
|
|
setLang('en');
|
|
assert.equal(t('还剩 {n} 秒', { n: 1 }), '1 second left');
|
|
assert.equal(t('还剩 {n} 秒', { n: 5 }), '5 seconds left');
|
|
assert.equal(t('{0}博士对敌方领袖造成的伤害超过20%!', ['阿米娅']), 'Dr. 阿米娅 dealt over 20%!', 'array params fill {0}');
|
|
assert.equal(format('{a} / {b}', { a: 'x' }), 'x / {b}', 'a missing value keeps the placeholder');
|
|
assert.equal(format('{names}', { names: ['甲', '乙'] }, { lang: 'zh' }), '甲、乙');
|
|
assert.equal(format('{names}', { names: ['A', 'B'] }, { lang: 'en' }), 'A, B');
|
|
assert.equal(format('官方{0:0%}', { 0: 1 }), '官方{0:0%}', 'official format placeholders are not ours: untouched');
|
|
assert.equal(format('{constructor}', {}), '{constructor}', 'no prototype lookups');
|
|
});
|
|
|
|
test('tc(): a context key wins, else the plain msgid; Chinese shows the msgid', () => {
|
|
addMessages('en', { 'toggle::关闭': 'Off', 关闭: 'Close' });
|
|
assert.equal(tc('toggle', '关闭'), '关闭');
|
|
setLang('en');
|
|
assert.equal(tc('toggle', '关闭'), 'Off');
|
|
assert.equal(tc('dialog', '关闭'), 'Close');
|
|
assert.equal(t('关闭'), 'Close');
|
|
assert.equal(N_('标记'), '标记', 'N_ only marks a msgid');
|
|
});
|
|
|
|
test('data names: { dn } params and tName go through the resolver, only outside Chinese; player names never do', () => {
|
|
setNameResolver((name) => ({ 琳琅诗怀雅: 'Swire the Elegant Wit', 阿米娅: 'Amiya' })[name] || name);
|
|
addMessages('en', { '{names}只能部署在召唤者攻击范围内,已退回整备区': '{names} were returned to the Bench', '{name}博士中途退出了模拟': 'Dr. {name} left the simulation' });
|
|
const m = msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: ['琳琅诗怀雅', '无名'].map(dn) });
|
|
assert.equal(translateWire(wireMessage(m)), '琳琅诗怀雅、无名只能部署在召唤者攻击范围内,已退回整备区', 'zh: names as sent');
|
|
setLang('en');
|
|
assert.equal(translateWire(wireMessage(m)), 'Swire the Elegant Wit, 无名 were returned to the Bench');
|
|
// a player called 阿米娅 stays 阿米娅 (plain string param, not dn)
|
|
assert.equal(translateWire(wireMessage(msg('{name}博士中途退出了模拟', { name: '阿米娅' }))), 'Dr. 阿米娅 left the simulation');
|
|
assert.equal(tName('阿米娅'), 'Amiya');
|
|
setLang('zh');
|
|
assert.equal(tName('阿米娅'), '阿米娅');
|
|
});
|
|
|
|
test('language state: setLang normalizes, notifies listeners once per change; normalizeLang', () => {
|
|
const seen = [];
|
|
const off = onLangChange((l, prev) => seen.push(`${prev}>${l}`));
|
|
assert.equal(setLang('en-US'), true);
|
|
assert.equal(setLang('en'), false, 'no change, no event');
|
|
assert.equal(setLang('xx'), true, 'unknown → default zh');
|
|
off();
|
|
setLang('en');
|
|
assert.deepEqual(seen, ['zh>en', 'en>zh']);
|
|
assert.equal(normalizeLang('zh_CN'), 'zh');
|
|
assert.equal(normalizeLang('EN'), 'en');
|
|
assert.equal(normalizeLang('fr'), null);
|
|
assert.equal(normalizeLang(undefined), null);
|
|
});
|
|
|
|
test('server wire format: text keeps the Chinese rendering for older clients; params are sanitized', () => {
|
|
assert.deepEqual(wireMessage('整备区已满,获得的装备已销毁'), { text: '整备区已满,获得的装备已销毁' });
|
|
const w = wireMessage(msg('【{label}】层数达成,获得{gain}资金', { label: dn('奇迹'), gain: 20, evil: { x: 1 }, 'bad key': 1 }));
|
|
assert.deepEqual(w, { text: '【奇迹】层数达成,获得20资金', msgid: '【{label}】层数达成,获得{gain}资金', params: { label: { dn: '奇迹' }, gain: 20, evil: null } });
|
|
assert.equal(renderMessage(msg('{who}:获得{name}', { who: dn('歌蕾蒂娅'), name: dn('斯卡蒂') })), '歌蕾蒂娅:获得斯卡蒂');
|
|
// a frame from an older server (no msgid): its text is the msgid
|
|
addMessages('en', { 隐秘核心已解锁: 'Hidden Core unlocked' });
|
|
setLang('en');
|
|
assert.equal(translateWire({ text: '隐秘核心已解锁' }), 'Hidden Core unlocked');
|
|
assert.equal(translateWire({ text: 'Doctor 7 博士的目标生命值已耗尽' }), 'Doctor 7 博士的目标生命值已耗尽', 'unknown text shows as is');
|
|
assert.equal(translateWire(null), '');
|
|
});
|
|
|
|
test('public/i18n/en.json: valid, English values, placeholders kept, the pilot screens and server messages translated', () => {
|
|
const entries = Object.entries(EN).filter(([k]) => !k.startsWith('_'));
|
|
assert.ok(entries.length > 800, `seeded from PR #70: ${entries.length}`);
|
|
for (const [k, v] of entries) {
|
|
assert.equal(typeof v, 'string', k);
|
|
assert.ok(v.trim(), `empty translation for ${k}`);
|
|
assert.ok(!/[一-鿿]/.test(v.replace(/\{[^{}]*\}/g, '')), `Chinese left in the translation of ${k}: ${v}`);
|
|
const want = new Set([...k.matchAll(/\{([A-Za-z0-9_$]+)\}/g)].map((m) => m[1]));
|
|
const have = new Set([...v.matchAll(/\{([A-Za-z0-9_$]+)(?:\|[^{}]*)?\}/g)].map((m) => m[1]));
|
|
for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`);
|
|
}
|
|
for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满',
|
|
'{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) {
|
|
assert.ok(EN[k], `en.json lacks ${k}`);
|
|
}
|
|
});
|
|
|
|
test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
|
|
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
|
|
'public/js/screens/diy.js',
|
|
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
|
|
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
|
|
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
|
|
'server/sim/content/bonds/addon/meta.js', 'server/sim/content/bonds/core.js'];
|
|
const missing = [];
|
|
for (const f of files) {
|
|
const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f);
|
|
const server = f.startsWith('server/');
|
|
for (const m of msgids) if ((!server || m.via === 'msg' || m.via === 'server') && !EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`);
|
|
if (!server) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`);
|
|
}
|
|
assert.deepEqual(missing, []);
|
|
});
|
|
|
|
test('tools/i18n.mjs: msgids of template literals name their params; the codemod wraps text, attributes and nested strings', async () => {
|
|
assert.equal(paramName('poolN', 0), 'poolN');
|
|
assert.equal(paramName('info.rounds', 1), 'rounds');
|
|
assert.equal(paramName('ids.length', 0), 'n');
|
|
assert.equal(paramName('MAX_SEATS - 1', 2), '2');
|
|
assert.deepEqual(templateMsgid(['战场随机(共', '张)'], ['ids.length']), { msgid: '战场随机(共{n}张)', params: [{ name: 'n', src: 'ids.length' }] });
|
|
assert.equal(templateMsgid(['', ' / ', ''], ['a.x', 'b.x']).msgid, '{x} / {x2}', 'same name from two expressions: numbered');
|
|
assert.equal(quote("it's\n"), "'it\\'s\\n'");
|
|
const src = [
|
|
"import { html } from './components.js';",
|
|
"const TABLE = { a: '甲' };",
|
|
"export function V({ name, n }) {",
|
|
" if (name === '比较') return null;",
|
|
" toast(`剩余 ${n} 名`, 'warn');",
|
|
" return html`<p title=\"提示 ${n}\">你好 ${name || '博士'} 世界<b>${n}</b> 回合</p>`;",
|
|
" const sample = '字体样例'; // i18n-ignore",
|
|
"}",
|
|
].join('\n');
|
|
const out = await codemodSource(src, path.join(ROOT, 'public/js/ui/x.js'));
|
|
assert.match(out.src, /import \{ t, N_ \} from '\.\.\/\.\.\/\.\.\/shared\/i18n\.js';/);
|
|
assert.match(out.src, /const TABLE = \{ a: N_\('甲'\) \};/, 'module level: marked, not translated');
|
|
assert.match(out.src, /name === '比较'/, 'compared literals are left alone');
|
|
assert.match(out.src, /toast\(t\('剩余 \{n\} 名', \{ n \}\), 'warn'\)/);
|
|
assert.match(out.src, /title=\$\{t\('提示 \{n\}', \{ n \}\)\}/);
|
|
// a child that is not a plain value splits the run; the string inside it is wrapped too
|
|
assert.match(out.src, />\$\{t\('你好'\)\} \$\{name \|\| t\('博士'\)\} \$\{t\('世界'\)\}<b>/);
|
|
assert.match(out.src, /<\/b> \$\{t\('回合'\)\}<\/p>/);
|
|
assert.equal(out.manual.length, 1);
|
|
});
|