diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 6ea672ea..f048c03e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -346,6 +346,27 @@ '会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。', 'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)', 'Pop-out Button on Tabs': '标签页弹出窗口按钮', + // Tab Layout and Header Stats Style (Discussion #426). The header style's + // "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles + // button), and "Tiles (default)" must not read as the grid. + 'Tab Layout': '标签页布局', + 'By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.': + '按状态:需要你、等待中、工作中和空闲各占一行。按案例:每个案例一个框。台账:对齐的列网格。经典:与以前相同的单一列表。按状态和按案例也会为侧边标签栏和侧边栏分组。Alt+1..9 仍按标签页顺序切换。', + 'By state (default)': '按状态(默认)', + 'By case (clusters)': '按案例(分组框)', + 'Ledger (aligned columns)': '台账(对齐的列)', + 'Classic (as before)': '经典(与以前相同)', + 'State Order': '状态顺序', + 'For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.': + '用于按状态的标签页布局。选择在底部会倒转各行,让“需要你”紧挨在终端上方。', + 'Needs you on top (default)': '“需要你”在顶部(默认)', + 'Needs you at the bottom': '“需要你”在底部', + 'Header Stats Style': '顶部栏状态样式', + 'How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.': + 'WS、CPU、MEM 和套餐用量窗口的显示方式。紧凑:在两个胶囊中每个数值旁显示一个圆环;磁贴:每个标签位于数值上方,下方带一条进度条。', + 'As before (bars)': '与以前相同(进度条)', + 'Compact (pill + rings)': '紧凑(胶囊 + 圆环)', + 'Tiles (default)': '磁贴(默认)', Panels: '面板', Monitor: '监视器', 'Project Insights': '项目洞察', diff --git a/test/tab-layout-settings-i18n.test.ts b/test/tab-layout-settings-i18n.test.ts new file mode 100644 index 00000000..d430bd4a --- /dev/null +++ b/test/tab-layout-settings-i18n.test.ts @@ -0,0 +1,116 @@ +/** + * @fileoverview The Tab Layout settings in Chinese (zh-CN): the App Settings + * rows #538 added (Tab Layout, State Order, Header Stats Style), their + * descriptions and every option, run through the real translator over the + * real index.html in JSDOM. + * + * - Each one reads in Chinese with no English left beyond key names and the + * WS / CPU / MEM readout names, and reads unchanged in English. + * - The header style "Tiles" is not the tile grid: its option must not take + * 平铺, the grid's own word, or "Tiles (default)" reads as the grid. + * - Every new key is in the dictionary once (a repeated key in the object + * literal silently replaces the first). + * - The state-row headings the strip draws (CodemanTabTriage's labels) + * translate too. + * + * The rows' `desktop` tag is a generic tag shared with rows older than #538 + * (WebGL Renderer) and is left out of the check. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8'); +const I18N = read('i18n.js'); +const INDEX = read('index.html'); + +type Api = { t(s: string): string; configure(o: Record): void; start(): void }; + +const ROWS = ['appSettingsTabArrangement', 'appSettingsTabStateOrder', 'appSettingsHeaderStatsStyle']; + +/** What may stay Latin: key names and the header's readout names. */ +const leftover = (text: string) => text.replace(/\bAlt\+\d\.\.\d\b|\b(WS|CPU|MEM)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +/** The rows' visible strings: label (without its tag), description, options. */ +function rowStrings(doc: Document): Map { + const out = new Map(); + for (const id of ROWS) { + const select = doc.getElementById(id)!; + const row = select.closest('.set-row')!; + const label = row.querySelector('.set-row-label')!.cloneNode(true) as Element; + label.querySelector('.set-tag')?.remove(); + out.set(`${id} label`, label.textContent!.trim()); + out.set(`${id} description`, row.querySelector('.set-row-desc')!.textContent!.trim()); + for (const option of select.querySelectorAll('option')) { + out.set(`${id} option ${option.getAttribute('value')}`, option.textContent!.trim()); + } + } + return out; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); + +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the Tab Layout settings rows in zh-CN', () => { + it('finds the three rows and all their options (the check is not vacuous)', () => { + expect(english.size).toBe(15); + expect(english.get('appSettingsHeaderStatsStyle option tiles')).toBe('Tiles (default)'); + expect(english.get('appSettingsTabStateOrder option urgent-last')).toBe('Needs you at the bottom'); + }); + + it('translates every label, description and option, with no English left', () => { + const bad: string[] = []; + for (const [where, text] of chinese) { + if (text === english.get(where) || leftover(text).length) bad.push(`${where}: "${text}"`); + } + expect(bad).toEqual([]); + }); + + it('keeps the header style Tiles apart from the tile grid (平铺)', () => { + expect(chinese.get('appSettingsHeaderStatsStyle option tiles')).not.toContain('平铺'); + expect(chinese.get('appSettingsHeaderStatsStyle description')).not.toContain('平铺'); + }); + + it('reads exactly as before in English', () => { + const en = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + vm.runInContext(I18N, en.getInternalVMContext(), { filename: 'i18n.js' }); + const t = (en.window as unknown as { CodemanI18n: Api }).CodemanI18n; + t.configure({ language: 'en' }); + expect([...english.values()].filter((s) => t.t(s) !== s)).toEqual([]); + en.window.close(); + }); + + it('adds each key to the dictionary once', () => { + const repeated = [...english.values()].filter((s) => { + const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1; + }); + expect(repeated).toEqual([]); + }); + + it('translates the state-row headings the strip draws', () => { + const labels = [...read('constants.js').matchAll(/\{ key: '\w+', label: '([^']+)'/g)].map((m) => m[1]); + expect(labels).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']); + expect(labels.filter((s) => api.t(s) === s || leftover(api.t(s)).length)).toEqual([]); + }); +});