diff --git a/public/js/ui/lang.js b/public/js/ui/lang.js index 5f95bfbf..644318b5 100644 --- a/public/js/ui/lang.js +++ b/public/js/ui/lang.js @@ -39,6 +39,8 @@ const uiLoads = new Map(); let indexLoad = null; /** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */ let indexOk = false; +/** Only the latest boot or explicit selection may apply its loaded language. */ +let langRequest = 0; const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`; const defaultFetch = (...a) => globalThis.fetch(...a); @@ -194,20 +196,23 @@ function applyLang(lang) { * @returns {Promise} the language in effect */ export async function initLang() { + const request = ++langRequest; wire(); applyDocument(getLang()); const TIMEOUT = Symbol('timeout'); const boot = (async () => { const listed = await loadLangIndex(); const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed }); - if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); } + if (fromUrl) stripLangParam(); + if (request !== langRequest) return null; + if (fromUrl) savePref(PREF_KEY, lang); if (lang === DEFAULT_LANG) return null; return (await loadLangChain(lang)) ? lang : null; })(); const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]); if (got === TIMEOUT) { - boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); }); - } else if (got) applyLang(got); + boot.then((lang) => { if (request === langRequest && lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); }); + } else if (request === langRequest && got) applyLang(got); return getLang(); } @@ -218,10 +223,12 @@ export async function initLang() { * @returns {Promise} the language in effect */ export async function switchLang(lang) { + const request = ++langRequest; wire(); const want = normalizeLang(lang) || DEFAULT_LANG; savePref(PREF_KEY, want); if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang(); + if (request !== langRequest) return getLang(); applyLang(want); return getLang(); } @@ -290,10 +297,17 @@ export function machineTranslationNote() { export function LangToggle({ class: cls }) { const lang = useLang(); const menu = langMenuModel(useLangs(), lang); - const pick = (code) => { if (code && code !== getLang()) switchLang(code); }; + const pick = async (code, select = null) => { + if (!code) return; + const switched = switchLang(code); + const request = langRequest; + await switched; + // A failed current request keeps the displayed language; stale requests must not reset a newer pending pick. + if (select && request === langRequest) select.value = getLang(); + }; if (menu.kind === 'select') { return html``; diff --git a/test/ui/i18n.e2e.test.js b/test/ui/i18n.e2e.test.js index dfd76425..eb955237 100644 --- a/test/ui/i18n.e2e.test.js +++ b/test/ui/i18n.e2e.test.js @@ -179,4 +179,125 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E= assert.deepEqual(problems, []); await page.close(); }); + + // Hold only UI pack fetches; the real loader, cache, Preact menu and document updates still run. + const holdPacks = (page, codes) => page.evaluate((held) => { + const fetch = globalThis.fetch.bind(globalThis); + globalThis.languageRequests = {}; + const gates = Object.fromEntries(held.map((code) => { + let release; + const ready = new Promise((resolve) => { release = resolve; }); + return [code, { ready, release }]; + })); + globalThis.releaseLanguage = async (code, ok) => { + const { loadLangChain } = await import('/js/ui/lang.js'); + const settled = loadLangChain(code); + gates[code].release(ok); + await settled; + await new Promise(requestAnimationFrame); + }; + globalThis.fetch = (url, ...args) => { + const code = held.find((c) => url === `/i18n/${c}.json`); + if (!code) return fetch(url, ...args); + globalThis.languageRequests[code] = (globalThis.languageRequests[code] || 0) + 1; + return gates[code].ready.then((ok) => ok ? fetch(url, ...args) : new Response('', { status: 503 })); + }; + }, codes); + const waitForPack = (page, code) => page.waitForFunction((c) => globalThis.languageRequests[c] > 0, {}, code); + const releasePack = (page, code, ok = true) => page.evaluate((c, success) => globalThis.releaseLanguage(c, success), code, ok); + const languageState = (page) => page.evaluate(async () => ({ + current: (await import('/shared/i18n.js')).getLang(), + document: document.documentElement.lang, + dataLang: document.documentElement.dataset.lang, + script: document.documentElement.dataset.script, + title: document.title, + start: document.querySelector('.title-login .btn--primary').textContent.trim(), + selected: document.querySelector('[data-testid="lang-toggle"] select').value, + saved: JSON.parse(localStorage.getItem('sp.pref.lang')), + })); + const expectedLanguage = { + zh: { current: 'zh', document: 'zh-CN', dataLang: 'zh', script: 'cjk', title: '卫戍协议:盟约 · STRONGHOLD PROTOCOL', start: '开始', selected: 'zh', saved: 'zh' }, + en: { current: 'en', document: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance · Web Simulation', start: 'Start', selected: 'en', saved: 'en' }, + 'zh-TW': { current: 'zh-TW', document: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約 · STRONGHOLD PROTOCOL', start: '開始', selected: 'zh-TW', saved: 'zh-TW' }, + }; + + test('a late English pack cannot overwrite a newer Traditional Chinese selection; its cache remains reusable', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await page.waitForFunction(() => document.documentElement.lang === 'zh-TW'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']); + await pick(page, 'en'); + await page.waitForFunction(() => document.documentElement.lang === 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1, 'the stale pack was cached'); + }); + + test('selecting the displayed default language cancels a pending pack', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.value), 'en', 'the native menu can still change back to Chinese'); + assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.disabled), false); + await pick(page, 'zh'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.zh); + }); + + test('repeated selections reuse a pending pack and still supersede an intervening selection', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await pick(page, 'en'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + await releasePack(page, 'zh-TW'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1); + }); + + test('a failed latest selection keeps the last successful UI and menu; an older pack stays stale', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await releasePack(page, 'zh-TW', false); + const unchanged = { ...expectedLanguage.zh, saved: 'zh-TW' }; + assert.deepEqual(await languageState(page), unchanged, 'failed preference stays saved, as before'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), unchanged); + }); + + test('a stale failure leaves a newer pending dropdown selection intact', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await releasePack(page, 'en', false); + assert.deepEqual(await languageState(page), { ...expectedLanguage.zh, saved: 'zh-TW', selected: 'zh-TW' }); + await releasePack(page, 'zh-TW'); + assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']); + }); }); diff --git a/test/ui/lang.test.js b/test/ui/lang.test.js new file mode 100644 index 00000000..ebbfd7b8 --- /dev/null +++ b/test/ui/lang.test.js @@ -0,0 +1,222 @@ +// Controlled UI-pack completions exercise real language switching without network timing or a browser. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getLang, onLangChange, registerLangs, setLang, setMessages, setI18nWarn, t } from '../../shared/i18n.js'; + +const TITLE = '卫戍协议:盟约 · STRONGHOLD PROTOCOL'; +const PACKS = { + en: { 开始: 'Start', 卫戍协议: 'Stronghold Protocol', [TITLE]: 'Stronghold Protocol: Alliance' }, + 'zh-TW': { 开始: '開始', 卫戍协议: '衛戍協議', [TITLE]: '衛戍協議:盟約' }, +}; +const STATES = { + zh: { lang: 'zh', text: '开始', htmlLang: 'zh-CN', dataLang: 'zh', script: 'cjk', title: TITLE }, + en: { lang: 'en', text: 'Start', htmlLang: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance' }, + 'zh-TW': { lang: 'zh-TW', text: '開始', htmlLang: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約' }, +}; +const INDEX = { version: 1, packs: Object.keys(PACKS).map((code) => ({ + id: code, type: 'lang', lang: code, name: code, files: { ui: `/i18n/${code}.json` }, +})) }; +const turn = () => new Promise((resolve) => setImmediate(resolve)); +let imports = 0; + +async function setup(ctx, preference = null, address = 'https://example.test/') { + const previous = new Map(['document', 'localStorage', 'fetch', 'location', 'history'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const stored = new Map(preference ? [['sp.pref.lang', JSON.stringify(preference)]] : []); + const doc = { documentElement: { lang: 'zh-CN', dataset: { lang: 'zh', script: 'cjk' } }, title: TITLE }; + const pending = new Map(); + const calls = []; + globalThis.document = doc; + globalThis.localStorage = { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => stored.set(key, value) }; + globalThis.location = new URL(address); + globalThis.history = { + state: null, + replaceState(state, _title, relative) { + this.state = state; + globalThis.location.href = new URL(relative, globalThis.location.href).href; + }, + }; + globalThis.fetch = (url) => { + calls.push(url); + const request = Promise.withResolvers(); + if (!pending.has(url)) pending.set(url, []); + pending.get(url).push(request); + return request.promise; + }; + const warnings = []; + ctx.mock.method(console, 'warn', (message) => warnings.push(message)); + registerLangs(Object.keys(PACKS).map((code) => ({ code, fallback: [], base: null, data: false, ui: `/i18n/${code}.json` }))); + setLang('zh'); + for (const code of Object.keys(PACKS)) setMessages(code, {}); + const url = new URL('../../public/js/ui/lang.js', import.meta.url); + url.searchParams.set('test', String(++imports)); + const lang = await import(url.href); + ctx.after(() => { + setLang('zh'); + setI18nWarn(null); + for (const code of Object.keys(PACKS)) setMessages(code, {}); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + const finish = (path, body, status = 200) => { + const request = pending.get(path)?.shift(); + assert.ok(request, `a request is pending for ${path}`); + request.resolve({ ok: status === 200, status, json: async () => body }); + }; + const state = (code, saved = code) => assert.deepEqual({ + lang: getLang(), text: t('开始'), htmlLang: doc.documentElement.lang, dataLang: doc.documentElement.dataset.lang, + script: doc.documentElement.dataset.script, title: doc.title, saved: stored.get('sp.pref.lang') ?? null, + }, { ...STATES[code], saved: saved == null ? null : JSON.stringify(saved) }); + return { + ...lang, calls, warnings, state, + finish: (code, status = 200) => finish(`/i18n/${code}.json`, PACKS[code], status), + finishIndex: () => finish('/packs/index.json', INDEX), + }; +} + +test('a delayed language cannot overwrite the later successful selection', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + assert.equal(await latest, 'zh-TW'); + h.state('zh-TW'); + h.finish('en'); + assert.equal(await first, 'zh-TW'); + h.state('zh-TW'); +}); + +test('selecting the default language invalidates a pending pack', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + assert.equal(await h.switchLang('zh'), 'zh'); + h.state('zh'); + h.finish('en'); + assert.equal(await first, 'zh'); + h.state('zh'); +}); + +test('reselecting the current language cancels a different pending selection and uses its cached pack', async (ctx) => { + const h = await setup(ctx); + const initial = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await initial; + const pending = h.switchLang('en'); + await h.switchLang('zh-TW'); + h.finish('en'); + await pending; + h.state('zh-TW'); + assert.deepEqual(h.calls, ['/i18n/zh-TW.json', '/i18n/en.json']); +}); + +test('A, B, A shares the in-flight A pack and applies only the latest selection', async (ctx) => { + const h = await setup(ctx); + const changes = []; + ctx.after(onLangChange((code) => changes.push(code))); + const first = h.switchLang('en'); + const middle = h.switchLang('zh-TW'); + const latest = h.switchLang('en'); + h.finish('zh-TW'); + await middle; + h.state('zh', 'en'); + h.finish('en'); + await Promise.all([first, latest]); + h.state('en'); + assert.deepEqual(changes, ['en']); + await h.switchLang('en'); + assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json']); + assert.deepEqual(changes, ['en'], 'repeated successful selections do not notify a language change'); +}); + +test('a failed latest request retains the successful interface, rejects stale success, and can retry', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW', 503); + assert.equal(await latest, 'zh'); + h.state('zh', 'zh-TW'); + assert.equal(h.warnings.length, 1); + assert.match(h.warnings[0], /HTTP 503.*interface stays as it is/); + h.finish('en'); + await first; + h.state('zh', 'zh-TW'); + const retry = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await retry; + h.state('zh-TW'); + await h.switchLang('en'); + h.state('en'); + assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json', '/i18n/zh-TW.json'], 'failed loads retry; stale successful packs remain cached'); +}); + +test('startup applies the stored language after its pack is ready', async (ctx) => { + const h = await setup(ctx, 'en'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + h.finish('en'); + assert.equal(await boot, 'en'); + h.state('en'); +}); + +test('startup cannot overwrite a newer switch before the boot timeout', async (ctx) => { + const h = await setup(ctx, 'en'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await latest; + h.finish('en'); + await boot; + h.state('zh-TW'); +}); + +test('a delayed startup index cannot restore the URL choice after a newer selection', async (ctx) => { + const h = await setup(ctx, null, 'https://example.test/play?room=alliance&lang=en#settings'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await latest; + h.finishIndex(); + await turn(); + h.state('zh-TW'); + assert.equal(await boot, 'zh-TW'); + assert.deepEqual(h.calls, ['/packs/index.json', '/i18n/zh-TW.json']); + assert.equal(globalThis.location.href, 'https://example.test/play?room=alliance#settings'); + assert.deepEqual(h.initialLang(), { lang: 'zh-TW', fromUrl: false }, 'reload uses the newer saved choice'); +}); + +test('timed-out startup applies its pack later when the selection has not changed', async (ctx) => { + const h = await setup(ctx, 'en'); + let timeout; + ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; }); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + timeout(); + assert.equal(await boot, 'zh'); + h.state('zh', 'en'); + h.finish('en'); + await turn(); + h.state('en'); +}); + +test('timed-out startup cannot overwrite a newer default selection', async (ctx) => { + const h = await setup(ctx, 'en'); + let timeout; + ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; }); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + timeout(); + await boot; + await h.switchLang('zh'); + h.finish('en'); + await turn(); + h.state('zh'); +});