mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
fix(i18n): zh-CN for the case picker rows, Bottom bar settings, new toasts, tile and spreadsheet text
- Case picker: the "New or link a case…" and "Case settings…" rows that replaced the translated + and gear buttons get zh-CN entries reusing the buttons' wording, plus the list's "No cases match". - App Settings, Bottom bar: the whole group translates as one (heading, the four Git status rows with #543's max repositories and git timeout, and their descriptions), so it never reads half English. The Git panel's two button names, which the new "Git status" key reaches, read naturally too. - Toasts: the three "Could not open a new window for this ..." errors and the dictation-closed warning. - Spreadsheet preview: "Spreadsheet preview failed (<status>)" gets a pattern. Worker refusals map their error code to one user sentence each in the renderer (the raw message goes to the console), feature ids read as words (pivot tables, external links), and the notice bar translates each item before the join; a number format's code passes through untouched. The worker and core are unchanged, so their pinned strings and the asset version hash stay as they were. - Header Stats Style Tiles: the connection tile's value word reads in Chinese through scoped "Connection tile: <word>" keys, never bare-word keys ("retry" is also the orchestrator's Retry button, "LIVE" a resume list badge). The indicator cache now includes the UI language, so a language switch repaints it on the next update. - Tile grid: the "Loading…" label was CSS content text the translator cannot reach; it is now content: attr(data-loading-label), written through the translator when the tile is built and each time it starts loading. Tests pin every new string (zh differs, no English left, English unchanged) and fail without these changes: the tile harvest sees the loading label and the rename field, a CSS guard keeps words out of tile generated content, the Bottom bar group, the case picker rows and toasts read from their source, the connection tile across a language switch, and the spreadsheet error codes, notice bar and status line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -985,6 +985,7 @@ class CodemanApp {
|
||||
// Last rendered connection-indicator tuple; the hot input path skips DOM
|
||||
// writes when the freshly computed descriptor is identical (COD-136).
|
||||
this._lastIndicatorDescriptor = null;
|
||||
this._lastIndicatorLanguage = null; // the UI language it was rendered in
|
||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||
// session they are bound to: the split pane's second terminal registers its
|
||||
@@ -4354,6 +4355,21 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile's value word as shown: i18n.js's scoped 'Connection tile: <word>'
|
||||
* entry in Chinese, the English word otherwise. Never a bare-word key: those
|
||||
* would also translate other text ("retry" is the orchestrator's Retry button),
|
||||
* which is why the value span carries data-i18n-skip.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_connectionTileValueText(value) {
|
||||
if (!value) return '';
|
||||
const key = `Connection tile: ${value}`;
|
||||
const translated = typeof window.codemanT === 'function' ? window.codemanT(key) : key;
|
||||
return translated && translated !== key ? translated : value;
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
@@ -4365,8 +4381,12 @@ class CodemanApp {
|
||||
// writes when nothing changed (COD-136) — the compute above is DOM-free.
|
||||
const next = this._computeConnectionDescriptor();
|
||||
const prev = this._lastIndicatorDescriptor;
|
||||
// The tile's value word is written in the UI language, so a language
|
||||
// switch counts as a change too.
|
||||
const language = window.CodemanI18n?.language || 'en';
|
||||
if (
|
||||
prev &&
|
||||
language === this._lastIndicatorLanguage &&
|
||||
prev.display === next.display &&
|
||||
prev.dotClass === next.dotClass &&
|
||||
prev.text === next.text &&
|
||||
@@ -4375,6 +4395,7 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
this._lastIndicatorDescriptor = next;
|
||||
this._lastIndicatorLanguage = language;
|
||||
|
||||
indicator.style.display = next.display;
|
||||
if (next.display !== 'none') {
|
||||
@@ -4386,7 +4407,7 @@ class CodemanApp {
|
||||
if (tileLabel && tileValue) {
|
||||
const words = this._connectionTileWords(next);
|
||||
tileLabel.textContent = words.label;
|
||||
tileValue.textContent = words.value;
|
||||
tileValue.textContent = this._connectionTileValueText(words.value);
|
||||
tileValue.className = `connection-tile-value ${words.state}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,11 @@
|
||||
'Create New Case': '新建案例',
|
||||
'Create new case': '新建案例',
|
||||
'Link Existing': '关联现有目录',
|
||||
// The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS),
|
||||
// which replaced the "+" and gear buttons, and its empty state.
|
||||
'New or link a case…': '新建或关联案例…',
|
||||
'Case settings…': '案例设置…',
|
||||
'No cases match': '没有匹配的案例',
|
||||
'Add Case': '添加案例',
|
||||
'Open sessions': '打开会话',
|
||||
'Recent Sessions': '最近会话',
|
||||
@@ -379,6 +384,32 @@
|
||||
'As before (bars)': '与以前相同(进度条)',
|
||||
'Compact (default)': '紧凑(默认)',
|
||||
'Tiles (label over value)': '磁贴(标签在数值上方)',
|
||||
// The connection tile's value word in that style (app.js
|
||||
// _connectionTileValueText). Scoped keys on purpose: the bare words also
|
||||
// name other things ("retry" is the orchestrator's Retry button, "LIVE" a
|
||||
// badge in the resume list), and a bare key would translate those too.
|
||||
'Connection tile: live': '已连接',
|
||||
'Connection tile: fallback': '回退',
|
||||
'Connection tile: offline': '离线',
|
||||
'Connection tile: queued': '已排队',
|
||||
'Connection tile: retry': '重连中',
|
||||
// App Settings → Bottom bar, translated as one group (the Git status rows,
|
||||
// #543's two included). Keys are the trimmed label text, without the scope tag.
|
||||
'Bottom bar': '底部栏',
|
||||
'Git status': 'Git 状态',
|
||||
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
|
||||
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
|
||||
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
|
||||
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
|
||||
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
|
||||
'Git status: max repositories': 'Git 状态:最多仓库数',
|
||||
"When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.":
|
||||
'当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。',
|
||||
'Git status: git timeout': 'Git 状态:git 超时',
|
||||
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
|
||||
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
|
||||
'Refresh git status': '刷新 Git 状态',
|
||||
'Close git status': '关闭 Git 状态',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
@@ -699,6 +730,13 @@
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||
'Session not found': '未找到会话',
|
||||
// A native host that would not open a window (app.js openInHostWindow); the
|
||||
// "dashboard" is a web tab.
|
||||
'Could not open a new window for this session': '无法在新窗口中打开此会话',
|
||||
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
|
||||
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
|
||||
// Dictation whose session closed before the text was sent (voice-input.js).
|
||||
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
@@ -872,6 +910,24 @@
|
||||
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
|
||||
'Spreadsheet parser message failed': '电子表格解析器消息出错',
|
||||
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
|
||||
'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL',
|
||||
// Worker refusals, one sentence per error code (spreadsheet-preview.js
|
||||
// WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The
|
||||
// counted ones are patterns in translateDynamic below.
|
||||
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.':
|
||||
'此工作簿受密码保护或为旧版 .xls 格式,无法预览。',
|
||||
'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。',
|
||||
'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。',
|
||||
'This workbook could not be read. The file may be damaged or not a valid .xlsx file.':
|
||||
'无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。',
|
||||
charts: '图表',
|
||||
drawings: '绘图',
|
||||
'pivot tables': '数据透视表',
|
||||
'external links': '外部链接',
|
||||
macros: '宏',
|
||||
'Formula has no cached result': '公式没有缓存的计算结果',
|
||||
'Unsupported cell value': '不支持的单元格值',
|
||||
'Unsupported number format': '不支持的数字格式',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
'Export as Markdown': '导出为 Markdown',
|
||||
'Mark all read': '全部标为已读',
|
||||
@@ -1099,6 +1155,10 @@
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
|
||||
[/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`],
|
||||
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
[
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue" data-i18n-skip></span></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
|
||||
@@ -31,6 +31,67 @@
|
||||
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
|
||||
});
|
||||
|
||||
// What a refusal from the worker says on screen. The core's own messages
|
||||
// (spreadsheet-xlsx-core.js) are developer detail, so each error code maps to
|
||||
// one sentence with a zh-CN entry in i18n.js, and the raw message goes to the
|
||||
// console. Any other code (parse-failed carries ExcelJS's own exception text)
|
||||
// shows the generic failure.
|
||||
const TOO_LARGE_OR_COMPLEX = 'This workbook is too large or complex to preview.';
|
||||
const UNREADABLE = 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.';
|
||||
const WORKER_ERROR_TEXT = Object.freeze({
|
||||
encrypted: 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.',
|
||||
zip64: 'This workbook uses ZIP64, which the preview does not support.',
|
||||
malformed: UNREADABLE,
|
||||
'number-format': UNREADABLE,
|
||||
'entry-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'entry-size': TOO_LARGE_OR_COMPLEX,
|
||||
'inflated-size': TOO_LARGE_OR_COMPLEX,
|
||||
'compression-ratio': TOO_LARGE_OR_COMPLEX,
|
||||
'worksheet-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'element-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'row-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'cell-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'merge-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'style-limit': TOO_LARGE_OR_COMPLEX,
|
||||
});
|
||||
|
||||
// The workbook features the preview leaves out, as the core names them
|
||||
// (featureForName), in words for the notice bar.
|
||||
const FEATURE_LABELS = Object.freeze({
|
||||
charts: 'charts',
|
||||
drawings: 'drawings',
|
||||
pivotTables: 'pivot tables',
|
||||
externalLinks: 'external links',
|
||||
macros: 'macros',
|
||||
});
|
||||
const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: ';
|
||||
|
||||
function translate(text) {
|
||||
return global.codemanT?.(text) || text;
|
||||
}
|
||||
|
||||
function own(map, key) {
|
||||
return Object.prototype.hasOwnProperty.call(map, key);
|
||||
}
|
||||
|
||||
function workerErrorText(payload) {
|
||||
const code = String(payload?.code || '');
|
||||
if (payload?.message) console.warn(`Spreadsheet preview: ${code || 'error'}: ${payload.message}`);
|
||||
return own(WORKER_ERROR_TEXT, code) ? WORKER_ERROR_TEXT[code] : 'Spreadsheet preview failed';
|
||||
}
|
||||
|
||||
// One notice bar item, translated on its own (the bar is one text node, which
|
||||
// the i18n layer could only match whole). A number format's code is workbook
|
||||
// text: it is appended as is, never passed through the translator, which
|
||||
// would read a `{…}` in it as a placeholder.
|
||||
function warningText(warning) {
|
||||
const text = String(warning);
|
||||
if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) {
|
||||
return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`;
|
||||
}
|
||||
return translate(own(FEATURE_LABELS, text) ? FEATURE_LABELS[text] : text);
|
||||
}
|
||||
|
||||
function message(container, text, kind) {
|
||||
container.textContent = '';
|
||||
const state = document.createElement('div');
|
||||
@@ -209,9 +270,8 @@
|
||||
if (!notice) return;
|
||||
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
|
||||
notice.hidden = warnings.length === 0;
|
||||
const warningLabel =
|
||||
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
|
||||
const warningLabel = translate('Some workbook features are not shown');
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.map(warningText).join(', ')}` : '';
|
||||
}
|
||||
|
||||
function pinHeadings() {
|
||||
@@ -459,7 +519,7 @@
|
||||
clearTimer();
|
||||
renderMetadata(payload);
|
||||
} else if (payload.type === 'tile') renderTile(payload);
|
||||
else if (payload.type === 'error') fail(payload.message);
|
||||
else if (payload.type === 'error') fail(workerErrorText(payload));
|
||||
};
|
||||
worker.onerror = () => fail('Spreadsheet parser failed.');
|
||||
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
|
||||
|
||||
@@ -19843,9 +19843,11 @@ body.tile-grid-resizing--row * {
|
||||
}
|
||||
|
||||
/* Quiet "loading" state while the tile's capture waits in the grid's one load
|
||||
queue (or runs). */
|
||||
queue (or runs). The text is the body's data-loading-label, which tile-grid.js
|
||||
writes in the UI language (_setTileLoadingLabel): generated content is out of
|
||||
the i18n layer's reach, so no literal words here. */
|
||||
.tile.tile--loading .tile-body::after {
|
||||
content: 'Loading\2026';
|
||||
content: attr(data-loading-label);
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
|
||||
@@ -233,6 +233,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
onChange: (tile, state) => {
|
||||
const entry = grid.tiles.get(tile.sessionId);
|
||||
if (entry?.tile !== tile) return;
|
||||
// Rewritten on the way in, so a language switched since is picked up.
|
||||
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
||||
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||||
// The first capture has landed (or failed): the terminal fades in,
|
||||
// whole, instead of showing its replay scroll by.
|
||||
@@ -243,6 +245,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return grid.queue;
|
||||
},
|
||||
|
||||
/**
|
||||
* The "Loading…" label of a tile body. It is CSS generated content (styles.css,
|
||||
* `content: attr(data-loading-label)`), which the i18n layer never reaches, so
|
||||
* the text is written here in the UI language.
|
||||
*/
|
||||
_setTileLoadingLabel(body) {
|
||||
if (!body) return;
|
||||
const t = window.codemanT;
|
||||
body.dataset.loadingLabel = typeof t === 'function' ? t('Loading…') : 'Loading…';
|
||||
},
|
||||
|
||||
_tileGridSection() {
|
||||
let section = document.getElementById('tileGrid');
|
||||
if (!section) {
|
||||
@@ -1336,6 +1349,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header = this._buildTileHeader(sessionId);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-body';
|
||||
this._setTileLoadingLabel(body);
|
||||
el.append(header.el, body);
|
||||
// Pressing a tile is a human selection: it focuses the tile and
|
||||
// acknowledges its idle alert (the already-focused tile hits
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* - The parts only the new styles draw (stat rings, plan rings, meters, tile words)
|
||||
* are rendered with sane values and hidden by default in CSS, which is what
|
||||
* keeps 'classic' looking exactly as before.
|
||||
* - The connection tile's value word reads in Chinese through i18n.js's real
|
||||
* t(), from scoped keys (a bare 'retry' or 'live' key would also translate
|
||||
* other surfaces), and follows a language switch.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
@@ -22,6 +25,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss from 'postcss';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -195,6 +199,62 @@ describe('the parts the new styles draw', () => {
|
||||
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
|
||||
});
|
||||
|
||||
it('writes the value word in the UI language, from scoped keys, and follows a language switch', () => {
|
||||
const translator = (language: string) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(read('i18n.js'), dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: object): void } })
|
||||
.CodemanI18n;
|
||||
api.configure({ language });
|
||||
return { t: api.t, close: () => dom.window.close() };
|
||||
};
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
try {
|
||||
window.codemanT = zh.t;
|
||||
window.CodemanI18n = { language: 'zh-CN' };
|
||||
const app = makeApp();
|
||||
app._computeConnectionDescriptor = () => ({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
app._updateConnectionIndicator();
|
||||
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('已连接');
|
||||
// The same descriptor after a switch back to English is not skipped as unchanged.
|
||||
window.codemanT = en.t;
|
||||
window.CodemanI18n = { language: 'en' };
|
||||
app._updateConnectionIndicator();
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
|
||||
|
||||
// Every value word has its own Chinese, and none is a bare key.
|
||||
window.codemanT = zh.t;
|
||||
const values = [
|
||||
'connection-dot connected',
|
||||
'connection-dot fallback',
|
||||
'connection-dot offline',
|
||||
'connection-dot draining',
|
||||
'connection-dot reconnecting',
|
||||
].map((dotClass) => app._connectionTileWords({ dotClass, text: '' }).value);
|
||||
expect(values).toEqual(['live', 'fallback', 'offline', 'queued', 'retry']);
|
||||
const shown = values.map((value: string) => app._connectionTileValueText(value));
|
||||
expect(shown.filter((text: string) => /[A-Za-z]/.test(text))).toEqual([]);
|
||||
expect(values.filter((value: string) => zh.t(value) !== value)).toEqual([]);
|
||||
// The span the observer must leave alone (its text is set here, already translated).
|
||||
expect(INDEX).toMatch(/id="connectionTileValue" data-i18n-skip>/);
|
||||
} finally {
|
||||
delete window.codemanT;
|
||||
delete window.CodemanI18n;
|
||||
zh.close();
|
||||
en.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => {
|
||||
const app = makeApp();
|
||||
const ring = (id: string) => {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* session name in the accessible name passed through untranslated (a name
|
||||
* that is itself a dictionary word included), and read unchanged in English.
|
||||
*
|
||||
* The toolbar's case picker rows (which replaced the translated "+" and gear
|
||||
* buttons) and the host-window and dictation toasts are read from their source,
|
||||
* so a renamed label without an entry fails here.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -183,3 +187,40 @@ describe('the Help modal and the shortcut overlay in zh-CN', () => {
|
||||
expect(overlay).not.toMatch(/<kbd>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the case picker rows and the host-window and dictation toasts in zh-CN', () => {
|
||||
const SESSION_UI = read('session-ui.js');
|
||||
const actions = SESSION_UI.slice(
|
||||
SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['),
|
||||
SESSION_UI.indexOf('];', SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['))
|
||||
);
|
||||
const pickerLabels = [...actions.matchAll(/label: '([^']+)'/g)].map((m) => m[1]);
|
||||
const toasts = ['app.js', 'panels-ui.js', 'webview-tabs.js', 'voice-input.js'].flatMap((file) =>
|
||||
[
|
||||
...read(file).matchAll(
|
||||
/showToast\??\.?\(\s*'(Could not open a new window for this \w+|That session has closed; dictation not sent)'/g
|
||||
),
|
||||
].map((m) => m[1])
|
||||
);
|
||||
|
||||
it('finds the strings it checks (the check is not vacuous)', () => {
|
||||
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']);
|
||||
expect(SESSION_UI).toContain('<div class="case-combobox-empty">No cases match</div>');
|
||||
expect(toasts.sort()).toEqual([
|
||||
'Could not open a new window for this dashboard',
|
||||
'Could not open a new window for this preview',
|
||||
'Could not open a new window for this session',
|
||||
'That session has closed; dictation not sent',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each one reads in Chinese with no English left, and unchanged in English', () => {
|
||||
const bad = [...pickerLabels, 'No cases match', ...toasts].filter((s) => {
|
||||
const text = zh.api.t(s);
|
||||
return text === s || leftover(text).length > 0 || en.api.t(s) !== s;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
// The rows reuse the wording of the buttons they replaced.
|
||||
expect(zh.api.t('Case settings\u2026')).toBe(`${zh.api.t('Case settings')}\u2026`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
* The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts)
|
||||
* rather than the `jsdom` vitest environment. Workbook strings are asserted to
|
||||
* land as text, never markup.
|
||||
*
|
||||
* What the user reads is the renderer's, not the worker's: each worker error
|
||||
* code is one sentence (the raw message goes to the console), feature ids are
|
||||
* words, and every one of those, the HTTP status line and the notice bar's
|
||||
* items read in Chinese through i18n.js's real t(); a number format's code is
|
||||
* workbook text and passes through as is.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -14,6 +20,12 @@ import { JSDOM } from 'jsdom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8');
|
||||
const coreSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
|
||||
const workerSource = readFileSync(
|
||||
resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview-worker.js'),
|
||||
'utf8'
|
||||
);
|
||||
const i18nSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
|
||||
const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||
|
||||
// A real origin: the renderer resolves its fetch URL against `location.href`.
|
||||
@@ -392,6 +404,125 @@ describe('spreadsheet preview renderer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('spreadsheet preview text the user reads', () => {
|
||||
type Translate = (s: string) => string;
|
||||
const translator = (language: string): Translate => {
|
||||
const i18nDom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(i18nSource, i18nDom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (i18nDom.window as any).CodemanI18n as { t: Translate; configure(o: object): void };
|
||||
api.configure({ language });
|
||||
return api.t;
|
||||
};
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
/** What may stay Latin in a translation: file formats, the URL acronym, digits. */
|
||||
const leftover = (text: string) => text.replace(/\b(xlsx|xls|ZIP64|URL)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
beforeEach(() => {
|
||||
WorkerMock.instances = [];
|
||||
document.body.innerHTML = '<div id="preview"></div>';
|
||||
delete (window as any).codemanT;
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => callback(0), 0) as unknown as number;
|
||||
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
||||
});
|
||||
|
||||
async function openLoaded() {
|
||||
document.body.innerHTML = '<div id="preview"></div>';
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||
const worker = WorkerMock.instances.at(-1)!;
|
||||
worker.emit({ type: 'ready' });
|
||||
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||
return worker;
|
||||
}
|
||||
|
||||
const shown = () => document.querySelector('.spreadsheet-preview-message')?.textContent ?? '';
|
||||
|
||||
it('shows one sentence per worker error code, never the raw message, and each reads in Chinese', async () => {
|
||||
const codes = [
|
||||
...new Set(
|
||||
[...`${coreSource}\n${workerSource}`.matchAll(/(?:\bfail|XlsxPreviewError)\('([a-z0-9-]+)'/g)].map((m) => m[1])
|
||||
),
|
||||
];
|
||||
// Not vacuous: the refusals the core and the worker raise.
|
||||
expect(codes).toEqual(expect.arrayContaining(['encrypted', 'zip64', 'malformed', 'cell-limit', 'style-limit']));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const texts = new Map<string, string>();
|
||||
for (const code of [...codes, 'parse-failed']) {
|
||||
const worker = await openLoaded();
|
||||
worker.emit({ type: 'error', code, message: `raw ${code} detail` });
|
||||
texts.set(code, shown());
|
||||
}
|
||||
expect([...texts.values()].filter((text) => /raw .* detail/.test(text))).toEqual([]);
|
||||
// Every code the core and worker raise has its own sentence; only an
|
||||
// unlisted one (ExcelJS's own failures) shows the generic failure.
|
||||
expect(codes.filter((code) => texts.get(code) === 'Spreadsheet preview failed')).toEqual([]);
|
||||
expect(texts.get('parse-failed')).toBe('Spreadsheet preview failed');
|
||||
expect(texts.get('encrypted')).toBe(
|
||||
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.'
|
||||
);
|
||||
// The developer detail is kept, in the console.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('raw encrypted detail'));
|
||||
const bad = [...new Set(texts.values())].filter((text) => {
|
||||
const t = zh(text);
|
||||
return t === text || leftover(t).length > 0 || en(text) !== text;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('words the notice bar: feature ids as words, each item translated, a format code as it is', async () => {
|
||||
const notice = async () => {
|
||||
const worker = await openLoaded();
|
||||
worker.emit({ ...metadata(), warnings: ['pivotTables', 'externalLinks', 'charts', 'drawings', 'macros'] });
|
||||
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||
worker.emit({
|
||||
type: 'tile',
|
||||
requestId: request.requestId,
|
||||
sheetId: '1',
|
||||
cells: [],
|
||||
warnings: [
|
||||
'View truncated to the first 2500 cells',
|
||||
'Formula has no cached result',
|
||||
'Unsupported cell value',
|
||||
'Unsupported number format: {name}"x"',
|
||||
'3 unsupported number formats',
|
||||
],
|
||||
});
|
||||
return document.querySelector('.spreadsheet-preview-notice')!.textContent!;
|
||||
};
|
||||
const english = await notice();
|
||||
expect(english).toBe(
|
||||
'Some workbook features are not shown: pivot tables, external links, charts, drawings, macros, ' +
|
||||
'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' +
|
||||
'Unsupported number format: {name}"x", 3 unsupported number formats'
|
||||
);
|
||||
(window as any).codemanT = zh;
|
||||
const chinese = await notice();
|
||||
// The format code is workbook text: kept as is ({name} is not a placeholder here).
|
||||
expect(chinese).toContain('不支持的数字格式: {name}"x"');
|
||||
expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]);
|
||||
expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true);
|
||||
});
|
||||
|
||||
it('the HTTP status line reads in Chinese with its status', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }));
|
||||
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||
WorkerMock.instances.at(-1)!.emit({ type: 'ready' });
|
||||
await vi.waitFor(() => expect(shown()).toBe('Spreadsheet preview failed (404)'));
|
||||
expect(zh(shown())).toBe('电子表格预览失败(404)');
|
||||
expect(en(shown())).toBe(shown());
|
||||
expect(zh('Spreadsheet preview must use a same-origin URL')).toBe('电子表格预览必须使用同源 URL');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The overlay wiring in panels-ui.js: which route the renderer is pointed at,
|
||||
* and that the worker/fetch lifecycle is torn down on close and on re-open.
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* literal silently replaces the first).
|
||||
* - The state-row headings the strip draws (CodemanTabTriage's labels)
|
||||
* translate too.
|
||||
* - The Bottom bar group (the Git status rows, #543's max repositories and
|
||||
* git timeout among them) translates as one group: heading, every label and
|
||||
* description, and the Git panel's names that the group's key now reaches.
|
||||
*
|
||||
* The rows' `desktop` tag is a generic tag shared with rows older than #538
|
||||
* (WebGL Renderer) and is left out of the check.
|
||||
@@ -115,3 +118,67 @@ describe('the Tab Layout settings rows in zh-CN', () => {
|
||||
expect(labels.filter((s) => api.t(s) === s || leftover(api.t(s)).length)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The Bottom bar group: its heading, then each row's label (without its tag) and description. */
|
||||
function bottomBarStrings(doc: Document): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const group = doc.getElementById('appSettingsShowGitStatus')!.closest('.set-group')!;
|
||||
out.set('heading', group.querySelector('h4')!.textContent!.trim());
|
||||
for (const row of group.querySelectorAll('.set-row')) {
|
||||
const id = row.querySelector('input, select')!.id;
|
||||
const label = row.querySelector('.set-row-label')!.cloneNode(true) as Element;
|
||||
label.querySelector('.set-scope')?.remove();
|
||||
out.set(`${id} label`, label.textContent!.trim());
|
||||
out.set(`${id} description`, row.querySelector('.set-row-desc')!.textContent!.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('the Bottom bar settings group in zh-CN', () => {
|
||||
const englishGroup = bottomBarStrings(source.window.document);
|
||||
const chineseGroup = bottomBarStrings(dom.window.document);
|
||||
/** What may stay Latin here: the git and Docker names, and the product name. */
|
||||
const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => {
|
||||
expect(englishGroup.size).toBe(9);
|
||||
expect(englishGroup.get('heading')).toBe('Bottom bar');
|
||||
expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories');
|
||||
expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout');
|
||||
});
|
||||
|
||||
it('translates the heading, every label and every description, with no English left', () => {
|
||||
const bad: string[] = [];
|
||||
for (const [where, text] of chineseGroup) {
|
||||
if (text === englishGroup.get(where) || left(text).length) bad.push(`${where}: "${text}"`);
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
expect(chineseGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git 状态:最多仓库数');
|
||||
});
|
||||
|
||||
it('reads exactly as before in English, and adds each key once', () => {
|
||||
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([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]);
|
||||
en.window.close();
|
||||
const repeated = [...englishGroup.values()].filter((s) => {
|
||||
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1;
|
||||
});
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
|
||||
it("the Git panel's names, which the group's 'Git status' key reaches too, read naturally", () => {
|
||||
const doc = dom.window.document;
|
||||
expect(doc.getElementById('gitStatusPanel')!.getAttribute('aria-label')).toBe('Git 状态');
|
||||
const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) =>
|
||||
b.getAttribute('aria-label')
|
||||
);
|
||||
expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
* writes text: the count menu (cap and window wording), the Attach
|
||||
* overlay (not attached, attaching, exited, ended), zoom, the header
|
||||
* tooltip, the dividers, the empty slot, every toast, the crash-restart
|
||||
* confirm, the Tiles and Split button titles. Each must translate to text
|
||||
* with no Latin word left beyond key names and durations, and read
|
||||
* unchanged in English.
|
||||
* confirm, the Tiles and Split button titles, the loading label (a body
|
||||
* attribute, shown as CSS generated content) and the rename field's name.
|
||||
* Each must translate to text with no Latin word left beyond key names and
|
||||
* durations, and read unchanged in English.
|
||||
* - Static strings: the shortcut registry's tile entries (overlay and App
|
||||
* Settings list), and index.html run through the real translator in JSDOM
|
||||
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
|
||||
* - No words in the tile CSS: generated content (`content: '...'`) is out of
|
||||
* the translator's reach, so a tile rule may carry glyphs, never text.
|
||||
* - User text stays as typed: session names (tile header) and group names
|
||||
* carry data-i18n-skip, and a session name inside the
|
||||
* confirm passes through the pattern untranslated.
|
||||
@@ -24,6 +27,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss from 'postcss';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FakeEl,
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
makeGridApp,
|
||||
resetGridHarness,
|
||||
section,
|
||||
tileEl,
|
||||
windowStub,
|
||||
type GridApp,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
@@ -102,6 +107,8 @@ function harvest(root: FakeEl | null | undefined, where: string) {
|
||||
add(el.title, 'title');
|
||||
add(el.attrs['aria-label'], 'aria-label');
|
||||
add(el.attrs.title, 'title attribute');
|
||||
// The loading label: CSS shows it (content: attr(data-loading-label)).
|
||||
add(el.dataset.loadingLabel, 'loading label');
|
||||
for (const child of el.children) walk(child, skip);
|
||||
};
|
||||
if (root) walk(root, false);
|
||||
@@ -215,6 +222,11 @@ async function exercise() {
|
||||
harvestAll(app, 'zoomed');
|
||||
app.zoomTile('s-1');
|
||||
|
||||
// Renaming a tile: the input in the name's place, then Escape.
|
||||
app.startTileRename('s-1');
|
||||
harvestAll(app, 'renaming');
|
||||
tileEl('s-1').querySelector('.tile-rename-input')!.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
|
||||
|
||||
// A file that is not an image dropped on a tile.
|
||||
section.dispatch('drop', {
|
||||
target: section.children.find((el) => el.dataset.sessionId === 's-1'),
|
||||
@@ -327,6 +339,8 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'Tiles: show several sessions side by side (right-click for how many)',
|
||||
'Tiles: back to a single session (right-click for how many tiles)',
|
||||
'6 tiles was stopped after crashing repeatedly. Restart it?',
|
||||
'Loading…',
|
||||
'Session name',
|
||||
];
|
||||
const missing = expected.filter((s) => !seen.has(s));
|
||||
expect(missing).toEqual([]);
|
||||
@@ -471,3 +485,55 @@ describe('user text in the tile code', () => {
|
||||
expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the loading label: CSS generated content, written in the UI language', () => {
|
||||
it('goes through the translator when the tile is built, and again each time the tile starts loading', () => {
|
||||
setUp();
|
||||
const Queue = windowStub.TileLoadQueue;
|
||||
const queue: { onChange?: (tile: unknown, state: string) => void } = {};
|
||||
windowStub.TileLoadQueue = class {
|
||||
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
|
||||
queue.onChange = opts.onChange;
|
||||
}
|
||||
schedule() {}
|
||||
drop() {}
|
||||
};
|
||||
try {
|
||||
windowStub.codemanT = zh.api.t;
|
||||
const app = makeGridApp(EIGHT);
|
||||
app.openTileGrid(EIGHT.slice(0, 2));
|
||||
const entry = app._tileGrid.tiles.get('s-1');
|
||||
expect(entry.body.dataset.loadingLabel).toBe('加载中…');
|
||||
// A language switched since is picked up the next time the tile loads.
|
||||
windowStub.codemanT = en.api.t;
|
||||
app._tileLoadQueue();
|
||||
queue.onChange!(entry.tile, 'queued');
|
||||
expect(entry.el.classList.contains('tile--loading')).toBe(true);
|
||||
expect(entry.body.dataset.loadingLabel).toBe('Loading…');
|
||||
queue.onChange!(entry.tile, 'idle');
|
||||
expect(entry.el.classList.contains('tile--loading')).toBe(false);
|
||||
} finally {
|
||||
windowStub.TileLoadQueue = Queue;
|
||||
delete windowStub.codemanT;
|
||||
}
|
||||
});
|
||||
|
||||
it('no tile rule carries words in generated content (the translator cannot reach it)', () => {
|
||||
const bad: string[] = [];
|
||||
const contents = new Map<string, string>();
|
||||
postcss.parse(read('styles.css')).walkRules((rule) => {
|
||||
if (!/\.tile\b/.test(rule.selector)) return;
|
||||
rule.walkDecls('content', (decl) => {
|
||||
contents.set(rule.selector, decl.value);
|
||||
// CSS escapes are glyphs (\2026, \00B7), not words.
|
||||
const literals = [...decl.value.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) =>
|
||||
(m[1] ?? m[2]).replace(/\\[0-9a-fA-F]{1,6}\s?/g, '')
|
||||
);
|
||||
if (literals.some((text) => /[A-Za-z]/.test(text))) bad.push(`${rule.selector} { content: ${decl.value} }`);
|
||||
});
|
||||
});
|
||||
// Not vacuous: the loading label is one of the rules read.
|
||||
expect(contents.get('.tile.tile--loading .tile-body::after')).toBe('attr(data-loading-label)');
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user