feedback5: merge W5I — real i18n phase 1: shared/i18n.js (Chinese msgids, params, data names), English game texts from the official EN client data (data/i18n/en.json), the 中文 / English switch on the title screen and in 设置, title / lobby / room / settings converted, server toasts and ticker lines with msgid + params, tools/i18n.mjs, docs/I18N.md (PR #70's translations by @YuriRestia reused, credited)
Conflict: server/match/player/basics.js imports (W5G's checkNotOwned + W5I's msg / dn). data/i18n/en.json rebuilt on the merged data (tools/build-i18n.mjs --offline): the 自选 data adds texts of CN-only operators and modules the EN client lacks, so coverage is 97.6 % (skills 95.9, talents 95.1); the coverage floors in test/i18n-data.test.js and docs/I18N.md say so. Golden unchanged; i18n / lobby / match / ui suites 1144/1144 after the rebuild, i18n e2e 1/1.
This commit is contained in:
commit
a611cb013c
38 files changed
+4043
-194
No files matched your search
@@ -0,0 +1,209 @@
|
||||
// Localized game data: the overlay format (shared/i18nData.js), its build (tools/build-i18n.mjs → data/i18n/en.json:
|
||||
// shape, freshness against data/*.json, coverage, rich text) and the client accessors (public/js/data.js setLocale).
|
||||
// docs/I18N.md.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildRecordOverlay, applyRecordOverlay, applyFileOverlay, walkOverlay, getPath, textHash2 } from '../shared/i18nData.js';
|
||||
import { buildOverlay, inverseFormat, shapeKey, DATA_FILES, stripRich } from '../tools/build-i18n.mjs';
|
||||
import { createDataStore } from '../public/js/data.js';
|
||||
import { parseRichText, richTextPlain } from '../public/js/ui/richText.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const OVERLAY = readJson('data/i18n/en.json');
|
||||
const HAN = /[一-鿿]/;
|
||||
|
||||
test('overlay records: build → apply round trip, copy-on-write, array and string records', () => {
|
||||
const base = { name: '炎', n: 3, skills: [{ name: '甲', desc: '一' }, { name: '乙', desc: '二' }], keep: { x: '中' } };
|
||||
const ov = buildRecordOverlay([
|
||||
{ path: ['name'], zh: '炎', en: 'Yan' },
|
||||
{ path: ['skills', 1, 'desc'], zh: '二', en: 'two' },
|
||||
]);
|
||||
assert.deepEqual(ov.skills, [null, { desc: 'two' }], 'arrays are index-aligned, null keeps the entry');
|
||||
assert.equal(ov._h, textHash2('炎') + textHash2('二'));
|
||||
const r = applyRecordOverlay(base, ov);
|
||||
assert.equal(r.applied, 2);
|
||||
assert.equal(r.value.name, 'Yan');
|
||||
assert.equal(r.value.skills[1].desc, 'two');
|
||||
assert.equal(r.value.skills[0], base.skills[0], 'untouched branches are shared');
|
||||
assert.equal(r.value.keep, base.keep);
|
||||
assert.equal(base.name, '炎', 'the input is never modified');
|
||||
// an array record (config tips) and a plain-string record (config seasonName)
|
||||
const tips = [{ tip: '甲' }, { tip: '乙' }];
|
||||
const tov = buildRecordOverlay([{ path: [1, 'tip'], zh: '乙', en: 'B' }]);
|
||||
assert.deepEqual(applyRecordOverlay(tips, tov).value, [{ tip: '甲' }, { tip: 'B' }]);
|
||||
const sov = buildRecordOverlay([{ path: [], zh: '卫戍协议:盟约', en: 'Stronghold Protocol: Alliance' }]);
|
||||
assert.equal(applyRecordOverlay('卫戍协议:盟约', sov).value, 'Stronghold Protocol: Alliance');
|
||||
});
|
||||
|
||||
test('overlay records: a leaf whose Chinese source changed stays Chinese (no stale translation)', () => {
|
||||
const ov = buildRecordOverlay([{ path: ['desc'], zh: '攻击力+15%', en: 'ATK +15%' }, { path: ['name'], zh: '维式重锤', en: 'Victorian Hammer' }]);
|
||||
const r = applyRecordOverlay({ name: '维式重锤', desc: '攻击力+20%' }, ov);
|
||||
assert.deepEqual(r.value, { name: 'Victorian Hammer', desc: '攻击力+20%' });
|
||||
assert.equal(r.applied, 1);
|
||||
assert.equal(r.stale, 1);
|
||||
const f = applyFileOverlay({ a: { name: '甲' }, b: { name: '乙' } }, { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A' }]), gone: { name: 'x', _h: '00' } });
|
||||
assert.deepEqual(f.value, { a: { name: 'A' }, b: { name: '乙' } }, 'records missing from the data are skipped');
|
||||
});
|
||||
|
||||
test('build-i18n matching: exact, blackboard templates, plain from raw, composites, fallback order, notes, names', () => {
|
||||
const zh = [{ chars: { char_x: { name: '测试员', description: '攻击造成<@ba.kw>法术伤害</>', skill: { levels: [{ name: '强攻', description: '攻击力<@ba.vup>+{atk:0%}</>,持续{duration}秒' }] } } } }];
|
||||
const en = [{ chars: { char_x: { name: 'Tester', description: 'Attacks deal <@ba.kw>Arts damage</>', skill: { levels: [{ name: 'Assault', description: 'ATK <@ba.vup>+{atk:0%}</> for {duration} seconds' }] } } } }];
|
||||
const data = {
|
||||
chess: {
|
||||
chess_x_a: {
|
||||
charId: 'char_x', name: '测试员',
|
||||
trait: { desc: '攻击造成法术伤害', descRaw: '攻击造成<@ba.kw>法术伤害</>' },
|
||||
skill: { skillId: 'sk_x', name: '强攻', descRaw: '攻击力<@ba.vup>+80%</>,持续30秒', desc: '攻击力+80%,持续30秒', bb: { atk: 0.8 }, duration: 30 },
|
||||
spec: { note: '研究笔记' },
|
||||
},
|
||||
},
|
||||
stages: { m1: { name: '战场#05(下半) 测试员' }, m2: { name: '战场#04 活性源石' } },
|
||||
config: { seasonName: '卫戍协议:盟约', tips: [{ tip: '无官方的提示' }, { tip: '界面里的话' }] },
|
||||
};
|
||||
const { overlay, report } = buildOverlay({
|
||||
zh, en, data,
|
||||
fallback: { remake: { 活性源石: 'Active Originium', '卫戍协议:盟约': 'Stronghold Protocol: Alliance' }, pr70: { 无官方的提示: 'A tip', 界面里的话: 'PR wording' }, ui: { 界面里的话: 'UI wording' } },
|
||||
});
|
||||
const c = applyRecordOverlay(data.chess.chess_x_a, overlay.files.chess.chess_x_a).value;
|
||||
assert.equal(c.name, 'Tester');
|
||||
assert.equal(c.trait.descRaw, 'Attacks deal <@ba.kw>Arts damage</>');
|
||||
assert.equal(c.trait.desc, 'Attacks deal Arts damage', 'the plain text is the stripped raw translation');
|
||||
assert.equal(c.skill.descRaw, 'ATK <@ba.vup>+80%</> for 30 seconds', 'numbers read back from the Chinese and filled in');
|
||||
assert.equal(c.skill.desc, 'ATK +80% for 30 seconds');
|
||||
assert.equal(c.spec.note, '研究笔记', 'research notes are not translated');
|
||||
const st = applyFileOverlay(data.stages, overlay.files.stages).value;
|
||||
assert.equal(st.m1.name, 'Battlefield #05 (Second Half) Tester');
|
||||
assert.equal(st.m2.name, 'Battlefield #04 Active Originium');
|
||||
const cfg = applyFileOverlay(data.config, overlay.files.config).value;
|
||||
assert.equal(cfg.seasonName, 'Stronghold Protocol: Alliance');
|
||||
assert.deepEqual(cfg.tips.map((x) => x.tip), ['A tip', 'PR wording'], 'remake > PR #70 > UI table');
|
||||
assert.equal(overlay.names['测试员'], 'Tester');
|
||||
assert.equal(report.coverage.operators.translated, 1);
|
||||
assert.equal(report.coverage.skills.template, 2);
|
||||
assert.equal(report.coverage.operators.notes, 1);
|
||||
assert.deepEqual(inverseFormat('攻击力+{atk:0%},{-x}格', '攻击力+80%,-2格'), { bb: { atk: 0.8, x: 2 }, bbStr: {} });
|
||||
assert.equal(inverseFormat('攻击力+{atk:0%}', '攻击力+80'), null);
|
||||
assert.equal(shapeKey('攻击力<@ba.vup>+{atk:0%}</>'), shapeKey('攻击力+80%'));
|
||||
});
|
||||
|
||||
test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and the names map', () => {
|
||||
assert.equal(OVERLAY.version, 1);
|
||||
assert.equal(OVERLAY.lang, 'en');
|
||||
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
|
||||
// the 自选 data (0.2.0) brings 72 owned 6★, some of them (and some newer modules) not in the EN client yet: their
|
||||
// names, skills, talents and modules stay Chinese — measured 97.6 % in all, skills 95.9 / talents 95.1 % (2026-10-05)
|
||||
assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`);
|
||||
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
|
||||
const c = OVERLAY.meta.coverage[kind];
|
||||
assert.ok(c && c.texts > 0, `coverage of ${kind}`);
|
||||
assert.ok(c.pct >= (['operators', 'skills', 'talents', 'modules'].includes(kind) ? 95 : 98), `${kind}: ${c.pct} %`);
|
||||
}
|
||||
assert.deepEqual(Object.keys(OVERLAY.files).filter((f) => !DATA_FILES.includes(f)), []);
|
||||
assert.equal(OVERLAY.names['琳琅诗怀雅'], 'Swire the Elegant Wit');
|
||||
assert.equal(OVERLAY.names['维式重锤'], 'Victorian Hammer');
|
||||
assert.equal(OVERLAY.names['奇迹'], 'Marvel');
|
||||
for (const [zh, en] of Object.entries(OVERLAY.names)) assert.ok(HAN.test(zh) && !HAN.test(en), `${zh} → ${en}`);
|
||||
});
|
||||
|
||||
test('data/i18n/en.json matches the current data/*.json: every leaf replaces a Chinese string that still hashes the same', () => {
|
||||
// A failure here means data/*.json was rebuilt after the overlay: run `node tools/build-i18n.mjs` (docs/I18N.md).
|
||||
for (const [file, fov] of Object.entries(OVERLAY.files)) {
|
||||
const data = readJson(`data/${file}.json`);
|
||||
let leaves = 0;
|
||||
for (const [id, rov] of Object.entries(fov)) {
|
||||
if (typeof rov._s === 'string') { assert.equal(typeof data[id], 'string', `${file}.${id}`); leaves++; continue; }
|
||||
walkOverlay(rov, (p, enText) => {
|
||||
const zhText = getPath(data[id], p);
|
||||
assert.equal(typeof zhText, 'string', `${file}.${id}.${p.join('.')} is not a string in data/${file}.json`);
|
||||
assert.ok(HAN.test(zhText), `${file}.${id}.${p.join('.')}: not a Chinese text`);
|
||||
assert.ok(enText.trim(), `${file}.${id}.${p.join('.')}: empty translation`);
|
||||
leaves++;
|
||||
});
|
||||
}
|
||||
const r = applyFileOverlay(data, fov);
|
||||
assert.equal(r.stale, 0, `${file}: ${r.stale} translations are stale (run node tools/build-i18n.mjs)`);
|
||||
assert.equal(r.applied, leaves, file);
|
||||
}
|
||||
});
|
||||
|
||||
test('official EN skill texts keep the rich-text markup and the remake\'s numbers (ui/richText.js renders them)', () => {
|
||||
const chess = readJson('data/chess.json');
|
||||
const cases = [['chess_char_3_04_a', 1, '155%'], ['chess_char_3_04_b', 1, '170%'], ['chess_char_1_01_a', 1, '+80%']];
|
||||
for (const [id, idx, number] of cases) {
|
||||
const zhRec = chess[id];
|
||||
const en = applyRecordOverlay(zhRec, OVERLAY.files.chess[id]).value;
|
||||
const zhSkill = zhRec.skills[idx];
|
||||
const enSkill = en.skills[idx];
|
||||
assert.ok(!HAN.test(enSkill.descRaw), `${id}: ${enSkill.descRaw}`);
|
||||
const vup = (s) => parseRichText(s).filter((x) => x.cls && x.cls.includes('ba.vup')).map((x) => x.text);
|
||||
assert.ok(vup(zhSkill.descRaw).includes(number) && vup(enSkill.descRaw).includes(number), `${id}: ${number} highlighted in both languages`);
|
||||
assert.equal(richTextPlain(enSkill.descRaw), enSkill.desc, 'desc is the plain form of descRaw');
|
||||
assert.equal((enSkill.descRaw.match(/<[@$][^<>]*>/g) || []).length, (enSkill.descRaw.match(/<\/>/g) || []).length, 'tags closed');
|
||||
}
|
||||
// every translated skill: it parses, no placeholder is left, plain = stripped raw (a few official EN texts leave a
|
||||
// tag unclosed, e.g. skchr_excu2_1's <@ba.rem>; the parser takes that as running to the end, as the game does)
|
||||
for (const [id, rov] of Object.entries(OVERLAY.files.chess)) {
|
||||
const rec = applyRecordOverlay(chess[id], rov).value;
|
||||
for (const s of rec.skills || []) {
|
||||
if (HAN.test(s.descRaw)) continue;
|
||||
assert.ok(parseRichText(s.descRaw).length > 0, `${id} ${s.skillId}`);
|
||||
assert.ok(!/\{-?[A-Za-z_@][^{}:]*(?::[^{}]+)?\}/.test(s.descRaw), `${id} ${s.skillId}: placeholder left: ${s.descRaw}`);
|
||||
assert.equal(stripRich(s.descRaw), s.desc, `${id} ${s.skillId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch over the repo's data/ (data/i18n/en.json included). */
|
||||
function diskFetch(calls) {
|
||||
return async (url) => {
|
||||
calls.push(url);
|
||||
const rel = url.replace(/^\/data\//, 'data/');
|
||||
const abs = path.join(ROOT, rel);
|
||||
if (!existsSync(abs)) return { ok: false, status: 404, json: async () => null };
|
||||
const text = readFileSync(abs, 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(text) };
|
||||
};
|
||||
}
|
||||
|
||||
test('data.js setLocale: getters return English records after the overlay loads, Chinese again after zh', async () => {
|
||||
const calls = [];
|
||||
const d = createDataStore({ fetch: diskFetch(calls), retryDelays: [] });
|
||||
await d.loadAll('chess', 'config', 'bonds');
|
||||
const seen = [];
|
||||
d.subscribe((n) => seen.push(n));
|
||||
assert.equal(d.lookup('chess', 'chess_char_3_04_a').name, '琳琅诗怀雅');
|
||||
assert.equal(await d.setLocale('en'), 'en');
|
||||
assert.equal(calls.filter((u) => u === '/data/i18n/en.json').length, 1);
|
||||
assert.deepEqual(seen.sort(), ['bonds', 'chess', 'config'], 'every loaded file notified');
|
||||
const swire = d.lookup('chess', 'chess_char_3_04_a');
|
||||
assert.equal(swire.name, 'Swire the Elegant Wit');
|
||||
assert.equal(swire.subProfessionName, 'Merchant');
|
||||
assert.equal(swire.stats.atk, 672, 'numbers untouched');
|
||||
assert.equal(d.list('bonds').find((b) => b.bondId === 'yanShip').name, 'Yan');
|
||||
assert.equal(d.get('config').modes.mode_multi_hard.name, 'Dire Simulation');
|
||||
assert.equal(d.getRaw('config').modes.mode_multi_hard.name, '绝境模拟', 'getRaw keeps the Chinese file');
|
||||
assert.equal(d.localeName('琳琅诗怀雅'), 'Swire the Elegant Wit');
|
||||
assert.equal(d.localeName('不存在的名字'), '不存在的名字');
|
||||
await d.load('items'); // loaded after the switch: localized too
|
||||
assert.equal(d.lookup('items', 'chess_item_1_01_e_a').name, 'Victorian Hammer');
|
||||
assert.equal(await d.setLocale('zh'), 'zh');
|
||||
assert.equal(d.lookup('chess', 'chess_char_3_04_a').name, '琳琅诗怀雅');
|
||||
assert.equal(d.localeName('琳琅诗怀雅'), '琳琅诗怀雅');
|
||||
await d.setLocale('en');
|
||||
assert.equal(calls.filter((u) => u === '/data/i18n/en.json').length, 1, 'the overlay downloads once');
|
||||
});
|
||||
|
||||
test('data.js setLocale: without an overlay the texts stay Chinese (no throw, no switch)', async () => {
|
||||
const d = createDataStore({ fetch: async () => ({ ok: false, status: 404, json: async () => null }), retryDelays: [] });
|
||||
const w = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
assert.equal(await d.setLocale('en'), 'zh');
|
||||
assert.equal(d.locale(), 'zh');
|
||||
} finally { console.warn = w; }
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Server → client texts in the player's language (docs/I18N.md): m.toast / m.ticker frames carry the Chinese `text`
|
||||
// (older clients) plus `msgid` + `params`, a config.broadcasts line its `args`; the client renders them with
|
||||
// translateWire / ui/lang.js tickerText; error codes through ui/toasts.js describeError.
|
||||
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MatchMessaging } from '../server/match/match/messaging.js';
|
||||
import { addMessages, setLang, setMessages, translateWire, msg, dn } from '../shared/i18n.js';
|
||||
import { makeMatch, give, DATA } from './match/harness.js';
|
||||
import { createRegistry } from '../server/match/effectsMeta.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const EN_UI = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
let lang;
|
||||
let data;
|
||||
let toasts;
|
||||
before(async () => {
|
||||
// the client data store (singleton) reads data/ through fetch: serve it from the repo
|
||||
globalThis.fetch = async (url) => {
|
||||
const abs = path.join(ROOT, String(url).replace(/^\//, ''));
|
||||
if (!existsSync(abs)) return { ok: false, status: 404, json: async () => null };
|
||||
const text = readFileSync(abs, 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(text) };
|
||||
};
|
||||
lang = await import('../public/js/ui/lang.js');
|
||||
({ data } = await import('../public/js/data.js'));
|
||||
toasts = await import('../public/js/ui/toasts.js');
|
||||
await data.loadAll('config', 'chess', 'tokens', 'bonds');
|
||||
addMessages('en', EN_UI);
|
||||
});
|
||||
after(() => { globalThis.fetch = realFetch; setLang('zh'); setMessages('en', {}); });
|
||||
|
||||
/** A Match-shaped stub for the messaging methods. */
|
||||
function stubMatch() {
|
||||
const sent = [];
|
||||
const broadcast = [];
|
||||
return {
|
||||
sent, bc: broadcast,
|
||||
disposed: false,
|
||||
players: new Map([['p1', { playerId: 'p1', connected: true }]]),
|
||||
spectators: new Map(),
|
||||
gd: { config: DATA.config },
|
||||
sendTo(id, m) { sent.push([id, m]); return true; },
|
||||
broadcast(m) { broadcast.push(m); },
|
||||
};
|
||||
}
|
||||
|
||||
test('m.toast frames: a string stays { text }; a msg() adds msgid + params beside its Chinese text', () => {
|
||||
const m = stubMatch();
|
||||
const ps = m.players.get('p1');
|
||||
MatchMessaging.prototype.toast.call(m, ps, 'warn', '整备区已满,获得的装备已销毁');
|
||||
MatchMessaging.prototype.toast.call(m, ps, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: ['医疗探机', '狼影'].map(dn) }));
|
||||
assert.deepEqual(m.sent[0][1], { t: 'm.toast', kind: 'warn', text: '整备区已满,获得的装备已销毁' });
|
||||
assert.deepEqual(m.sent[1][1], {
|
||||
t: 'm.toast', kind: 'warn', text: '医疗探机、狼影只能部署在召唤者攻击范围内,已退回整备区',
|
||||
msgid: '{names}只能部署在召唤者攻击范围内,已退回整备区', params: { names: [{ dn: '医疗探机' }, { dn: '狼影' }] },
|
||||
});
|
||||
});
|
||||
|
||||
test('m.ticker frames: broadcast lines carry their args; CUSTOM lines a msgid', () => {
|
||||
const m = stubMatch();
|
||||
MatchMessaging.prototype.tickerFor.call(m, 'GOLDEN_CHAR', ['阿米娅', '琳琅诗怀雅'], { playerId: 'p1' });
|
||||
MatchMessaging.prototype.tickerText.call(m, msg('{name}博士中途退出了模拟', { name: '阿米娅' }), 5);
|
||||
const [gold, custom] = m.bc;
|
||||
assert.equal(gold.type, 'GOLDEN_CHAR');
|
||||
assert.deepEqual(gold.args, ['阿米娅', '琳琅诗怀雅']);
|
||||
assert.ok(gold.text.includes('阿米娅') && gold.text.includes('琳琅诗怀雅'), gold.text);
|
||||
assert.deepEqual({ ...custom }, { t: 'm.ticker', text: '阿米娅博士中途退出了模拟', msgid: '{name}博士中途退出了模拟', params: { name: '阿米娅' }, id: null, type: 'CUSTOM', priority: 5, playerId: null });
|
||||
});
|
||||
|
||||
test('client: the same frames in English — msgid + params, broadcast template + args (player names never translated)', async () => {
|
||||
setLang('en');
|
||||
await data.setLocale('en');
|
||||
try {
|
||||
const toastFrame = { text: '医疗探机只能部署在召唤者攻击范围内,已退回整备区', msgid: '{names}只能部署在召唤者攻击范围内,已退回整备区', params: { names: [{ dn: '医疗探机' }] } };
|
||||
assert.equal(translateWire(toastFrame), "Medic Drone can only be deployed within the summoner's attack range and returned to the Bench");
|
||||
assert.equal(translateWire({ text: '整备区已满,获得的装备已销毁' }), EN_UI['整备区已满,获得的装备已销毁'], 'a static server text is its own msgid');
|
||||
const m = stubMatch();
|
||||
MatchMessaging.prototype.tickerFor.call(m, 'GOLDEN_CHAR', ['阿米娅', '琳琅诗怀雅'], { playerId: 'p1' });
|
||||
const line = lang.tickerText(m.bc[0]);
|
||||
assert.ok(line.includes('阿米娅'), `player name kept: ${line}`);
|
||||
assert.ok(line.includes('Swire the Elegant Wit'), `operator name translated: ${line}`);
|
||||
assert.ok(!/[一-鿿]/.test(line.replace('阿米娅', '')), line);
|
||||
// an older server (no args): the Chinese line as it came
|
||||
assert.equal(lang.tickerText({ text: m.bc[0].text, id: m.bc[0].id }), m.bc[0].text);
|
||||
// error codes: ERR_TEXT / net.js CLIENT_ERR_TEXT are msgids
|
||||
assert.equal(toasts.describeError({ code: 'HAND_FULL', msg: '整备区已满' }), 'Bench is full');
|
||||
assert.equal(toasts.describeError('NO_FUNDS'), EN_UI['资金不足']);
|
||||
} finally {
|
||||
setLang('zh');
|
||||
await data.setLocale('zh');
|
||||
}
|
||||
});
|
||||
|
||||
test('a real match: 歌蕾蒂娅 grants a chess → 「歌蕾蒂娅:获得X」 in Chinese, "Gladiia: obtained …" in English', async () => {
|
||||
const h = makeMatch({ mode: 'solo', seed: 11, registry: createRegistry({ log: { warn() {}, error() {}, info() {} } }), fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0; ps.bandId = null; ps.recompute();
|
||||
const prepFree = (c) => (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE');
|
||||
const row = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && prepFree(c)).map((c) => c.chessId).sort().slice(0, 2);
|
||||
give(m, ps, 'chess_char_4_12_a', 'board', [10, 4]);
|
||||
give(m, ps, row[0], 'board', [10, 6]);
|
||||
give(m, ps, row[1], 'board', [10, 8]);
|
||||
const n0 = h.allTo('p_0', 'm.toast').length;
|
||||
m.dispatch(ps, 'onRoundStart', { round: m.round });
|
||||
const frame = h.allTo('p_0', 'm.toast').slice(n0).find((t) => t.msgid === '{who}:获得{name}');
|
||||
assert.ok(frame, JSON.stringify(h.allTo('p_0', 'm.toast').slice(n0)));
|
||||
assert.ok(frame.text.startsWith('歌蕾蒂娅:获得'), frame.text);
|
||||
assert.deepEqual(frame.params.who, { dn: '歌蕾蒂娅' });
|
||||
setLang('en');
|
||||
await data.setLocale('en');
|
||||
try {
|
||||
const en = translateWire(JSON.parse(JSON.stringify(frame)));
|
||||
assert.match(en, /^Gladiia: obtained [A-Z]/, en);
|
||||
assert.ok(!/[一-鿿]/.test(en), en);
|
||||
} finally {
|
||||
setLang('zh');
|
||||
await data.setLocale('zh');
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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/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);
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from '.
|
||||
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
|
||||
import { placementContext, canPlace as clientCanPlace, boardTargets, deployMap as clientDeployMap } from '../../public/js/ui/gameLogic.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { renderMessage } from '../../shared/i18n.js';
|
||||
|
||||
const STAGE = 'act2autochess_m04';
|
||||
const WATER = ['10,6', '11,6', '12,6'];
|
||||
@@ -187,7 +188,7 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
|
||||
assert.equal(stackOf(ps, WOLF), null, 'its only copy is placed: no stack left to return onto');
|
||||
const toasts = [];
|
||||
const toast = m.toast.bind(m);
|
||||
m.toast = (p, kind, text) => { toasts.push(text); return toast(p, kind, text); };
|
||||
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
|
||||
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
|
||||
while (ps.hand.includes(null)) giveItem(m, ps, golden);
|
||||
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { planLayout } from '../../server/match/bot.js';
|
||||
import { collectViolations } from '../../server/match/invariants.js';
|
||||
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
|
||||
import { renderMessage } from '../../shared/i18n.js';
|
||||
|
||||
const STAGE = 'act2autochess_m01';
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -115,7 +116,8 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
|
||||
const toasts = [];
|
||||
const toast = m.toast.bind(m);
|
||||
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${text}`); return toast(ps, kind, text); };
|
||||
// a toast is a string or a shared/i18n.js msg(): compare its Chinese rendering
|
||||
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${renderMessage(text)}`); return toast(ps, kind, text); };
|
||||
const fields = [];
|
||||
m.deployFieldOf = ((orig) => function (ps) { const f = orig.call(this, ps); if (this.round === this.gd.hiddenRound) fields.push(f); return f; })(m.deployFieldOf);
|
||||
const placed = {};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Browser check of the language switch (puppeteer-core + system Chrome; opt-in: SP_E2E=1, ~20 s):
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/i18n.e2e.test.js
|
||||
//
|
||||
// The title screen opens in Chinese; 中文 | English → English in place (no reload): the title, the start button, the
|
||||
// connection line, <html lang>, the tab title; the choice is kept across a reload (localStorage sp.pref.lang) and the
|
||||
// game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. No console /
|
||||
// page / request errors. docs/I18N.md.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CHROME, hasChrome, sleep } from '../e2e/client.mjs';
|
||||
import { NAME_MAX_LEN } from '../../shared/constants.js';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome();
|
||||
|
||||
describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=1 (Chrome)' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
const text = (page, sel) => page.$eval(sel, (el) => el.textContent.replace(/\s+/g, ' ').trim());
|
||||
|
||||
test('中文 by default; English in place, kept across a reload; ?lang=zh back to Chinese', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
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}/`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.title-screen [data-testid="lang-toggle"]');
|
||||
assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约');
|
||||
assert.equal(await text(page, '.title-login .btn--primary'), '开始');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.lang), 'zh-CN');
|
||||
|
||||
await page.click('[data-testid="lang-toggle"] button[data-lang="en"]');
|
||||
await page.waitForFunction(() => document.querySelector('.title-cn')?.textContent.includes('Stronghold Protocol'), { timeout: 8000 });
|
||||
assert.equal(await text(page, '.title-cn'), 'Stronghold Protocol: Alliance');
|
||||
assert.equal(await text(page, '.title-login .btn--primary'), 'Start');
|
||||
assert.match(await text(page, '.title-tag'), /^Allocate Funds and Operators/);
|
||||
assert.match(await text(page, '.title-conn'), /Connect|Ready to connect/);
|
||||
assert.equal(await page.$eval('.title-login input', (el) => el.placeholder), `Callsign (max ${NAME_MAX_LEN} chars)`);
|
||||
assert.deepEqual(await page.evaluate(() => ({ lang: document.documentElement.lang, title: document.title, pref: localStorage.getItem('sp.pref.lang') })),
|
||||
{ lang: 'en', title: 'Stronghold Protocol: Alliance · Web Simulation', pref: '"en"' });
|
||||
// the game texts follow (data/i18n/en.json applied by data.js)
|
||||
await page.waitForFunction(() => globalThis.__SP__?.data?.locale() === 'en', { timeout: 8000 });
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP__.data.get('config').modes.mode_multi_abyss.name), 'Ultimate Simulation');
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.title-screen .title-cn');
|
||||
assert.equal(await text(page, '.title-cn'), 'Stronghold Protocol: Alliance', 'the choice survives a reload');
|
||||
assert.equal(await text(page, '.title-login .btn--primary'), 'Start');
|
||||
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.title-screen .title-cn');
|
||||
await sleep(200);
|
||||
assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约');
|
||||
assert.equal(await page.evaluate(() => location.search), '', '?lang is removed from the address bar');
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('sp.pref.lang')), '"zh"');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user