feedback5: merge UI-TW — the Traditional Chinese interface (all msgids converted with OpenCC cn → twp, the official TW terms, a manual pass) and the machine-translation mark (_meta.machineTranslated → the pack index → a note under the language switch in 设置); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 12:07:07 +08:00
commit d0fc57ff4c
11 files changed
+1218 -41

No files matched your search

+69 -6
View File
@@ -3,9 +3,10 @@
// 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), 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).
// (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';
@@ -19,8 +20,8 @@ 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 } from '../shared/i18nPacks.js';
import { normalizeManifest, appVersionMatches, isVersionRange, readPackIndex, langMetaOf, PACK_TYPES, isPackPath } from '../shared/packs.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';
@@ -30,6 +31,9 @@ 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');
@@ -172,6 +176,44 @@ test('manifest: common fields, type fields, app ranges; English is a pack like a
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);
@@ -346,10 +388,31 @@ test('client: the index registers the language packs; the menu lists them (butto
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, numberUnits: null });
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()] });
+27 -6
View File
@@ -7,8 +7,9 @@
// game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. Language packs
// (docs/I18N.md "Adding a language", docs/PACKS.md): a pack file dropped into public/i18n/ and a pack folder dropped
// into packs/ while the server runs show in the menu and switch, with English filling what they lack; a removed pack
// sends a stored choice back to Chinese. The shipped Japanese, Korean and Traditional Chinese packs (the owner's decision of
// 2026-10-07) switch to their official game texts with the UI of their fallback (English; Simplified Chinese for zh-TW).
// sends a stored choice back to Chinese. The shipped Japanese, Korean and Traditional Chinese packs (the owner's decisions of
// 2026-10-07) switch to their official game texts and their own UI strings (what a pack lacks shows its fallback: English
// for ja / ko, the Simplified Chinese for zh-TW); a pack marked `machineTranslated` says so in 设置 (none in Chinese).
// With more than four languages the menu is a list (ui/lang.js SEGMENTED_MAX): the helpers read and pick either form.
// No console / page / request errors. docs/I18N.md.
@@ -50,6 +51,23 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
else await page.click(`[data-testid="lang-toggle"] button[data-lang="${code}"]`);
};
const SHIPPED = [['zh', '中文'], ['en', 'English'], ['ja', '日本語'], ['ko', '한국어'], ['zh-TW', '繁體中文']];
const pack = (code) => JSON.parse(fs.readFileSync(path.join(ROOT, `public/i18n/${code}.json`), 'utf8'));
/** What a shipped pack shows for a msgid: its own string, else its fallbacks' (English for ja / ko), else the Chinese msgid. */
const uiText = (code, msgid) => {
const own = pack(code);
for (const p of [own, ...(own._meta?.fallback || []).map(pack)]) if (typeof p[msgid] === 'string' && p[msgid]) return p[msgid];
return msgid;
};
const MT_NOTE = '当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。';
/** The note under the language switch in 设置 (null when there is none); the dialog opens from the title screen and closes. */
const settingsNote = async (page) => {
await page.click('.title-settings');
await page.waitForSelector('.modal .set-list');
const note = await page.$eval('.modal [data-testid="lang-mt-note"]', (el) => el.textContent.trim()).catch(() => null);
await page.click('.modal__actions .btn--primary'); // 完成 (an Esc right after the dialog shows may beat its key listener)
await page.waitForSelector('.modal', { hidden: true });
return note;
};
/** A game text of a shipped pack's overlay (data/i18n/<code>.json; a leaf is a string or { _s }). */
const gameText = (code) => { const v = JSON.parse(fs.readFileSync(path.join(ROOT, `data/i18n/${code}.json`), 'utf8')).files.config.modes.mode_multi_abyss.name; return typeof v === 'string' ? v : v._s; };
@@ -139,7 +157,7 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
await page.close();
}
});
test('the shipped 日本語 / 한국어 / 繁體中文 packs: the official game texts, the UI of their fallback (English; Simplified Chinese for zh-TW)', async () => {
test('the shipped 日本語 / 한국어 / 繁體中文 packs: the official game texts, their UI strings, the 设置 note of a machine-translated pack', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
@@ -148,13 +166,16 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
page.on('response', (r) => { if (r.status() >= 400 && !/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.title-screen [data-testid="lang-toggle"]');
for (const [code, ui, htmlLang] of [['ja', 'Start', 'ja'], ['ko', 'Start', 'ko'], ['zh-TW', '开始', 'zh-TW']]) {
assert.equal(await settingsNote(page), null, 'Chinese: no machine-translation note');
for (const code of ['ja', 'ko', 'zh-TW']) {
await pick(page, code);
await page.waitForFunction((c) => globalThis.__SP__?.data?.locale() === c, { timeout: 8000 }, code);
assert.equal(await page.evaluate(() => globalThis.__SP__.data.get('config').modes.mode_multi_abyss.name), gameText(code), `${code}: the official game text`);
assert.equal(await text(page, '.title-login .btn--primary'), ui, `${code}: the fallback UI`);
assert.equal(await page.evaluate(() => document.documentElement.lang), htmlLang);
assert.equal(await text(page, '.title-login .btn--primary'), uiText(code, '开始'), `${code}: the UI`);
assert.equal(await page.evaluate(() => document.documentElement.lang), code);
assert.equal(await settingsNote(page), pack(code)._meta.machineTranslated === true ? uiText(code, MT_NOTE) : null, `${code}: the 设置 note`);
}
assert.equal(uiText('zh-TW', '开始'), '開始', 'zh-TW: its own Traditional Chinese UI');
assert.deepEqual(problems, []);
await page.close();
});