fix(i18n): translate the Tab Layout and Header Stats Style settings

#538 added three App Settings rows (Tab Layout, State Order, Header Stats
Style) with their descriptions and nine options, and none of them had a
zh-CN entry, while #561 in the same release translated every string it
put on screen. With the language set to Chinese those rows stayed English
in the middle of a translated settings page.

Adds the 15 entries next to the other tab-bar settings. The header
style's "Tiles" is 磁贴, not 平铺: 平铺 is #561's word for the tile grid
(the Tiles button and its setting), and "Tiles (default)" must not read as
the grid. The test runs the real index.html through the real translator
in JSDOM, checks every label, description and option of the three rows
(the shared `desktop` tag aside), that English is unchanged, that each key
is in the dictionary once, and that the strip's state-row headings
translate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 21:36:04 +02:00
parent deb11bade2
commit 1296223403
2 changed files with 137 additions and 0 deletions
+21
View File
@@ -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': '项目洞察',
+116
View File
@@ -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<string, string>): 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<string, string> {
const out = new Map<string, string>();
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('<!doctype html><html><body></body></html>', {
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([]);
});
});