Files
Codeman/test/header-stats-style.test.ts
T
Codeman maintainer 82c87f56d1 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>
2026-10-09 09:23:05 +02:00

348 lines
16 KiB
TypeScript

/**
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
* option G): 'classic' (as before), 'compact' (one system pill with rings
* plus a plan-ring pill; the default, the owner's pick on the 1.36.0 beta) and
* 'tiles' (label over value, bar underneath).
*
* Pinned here:
* - The default is 'compact', and only the three known values are honoured.
* - The two clustered styles move the connection indicator INTO the system
* stats pill and the plan chip right after it, on the REAL header markup
* from index.html, and 'classic' puts both back exactly where the template
* had them.
* - The WS readout never disappears with a hidden System Stats pill.
* - 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').
*
* Port: none.
*/
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';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const HEADER = INDEX.slice(INDEX.indexOf('<header class="header">'), INDEX.indexOf('</header>') + '</header>'.length);
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.eval(
'var MobileDetection = { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => "desktop" }, ' +
'KeyboardHandler = {}, SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('app.js') +
'\n' +
read('settings-ui.js') +
'\n' +
read('panels-ui.js') +
'\n;window.__HeaderStatsCodemanApp = CodemanApp;'
);
CodemanApp = window.__HeaderStatsCodemanApp;
});
function makeApp() {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
return app;
}
/** Element children of the header's right side, by id (or first class). */
const rightSide = () => [...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const statsChildren = () =>
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
beforeEach(() => {
document.body.innerHTML = HEADER;
delete document.documentElement.dataset.headerStats;
});
describe('resolveHeaderStatsStyle', () => {
it("defaults to 'compact' and honours only the three known styles", () => {
const app = makeApp();
expect(app.resolveHeaderStatsStyle({})).toBe('compact');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('compact');
});
});
describe('resolveTabArrangement', () => {
it("defaults to 'classic' and honours only the four known layouts", () => {
// Lives here because this file already loads settings-ui.js's resolvers.
const app = makeApp();
expect(app.resolveTabArrangement({})).toBe('classic');
expect(app.resolveTabArrangement(undefined)).toBe('classic');
for (const value of ['state', 'case', 'ledger', 'classic']) {
expect(app.resolveTabArrangement({ tabArrangement: value })).toBe(value);
}
expect(app.resolveTabArrangement({ tabArrangement: 'neon' })).toBe('classic');
});
});
describe('applyHeaderStatsStyle', () => {
it('clusters WS, the system stats and the plan chip for tiles and compact', () => {
for (const style of ['tiles', 'compact']) {
document.body.innerHTML = HEADER;
const app = makeApp();
app.applyHeaderStatsStyle({ style, showSystemStats: true });
expect(document.documentElement.dataset.headerStats).toBe(style);
expect(statsChildren()[0]).toBe('connectionIndicator');
const right = rightSide();
expect(right[right.indexOf('headerSystemStats') + 1]).toBe('planUsageChip');
expect(right).not.toContain('connectionIndicator');
}
});
it("'classic' puts both back exactly where the template had them", () => {
const template = rightSide();
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'compact', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'classic', showSystemStats: true });
expect(rightSide()).toEqual(template);
expect(statsChildren()).not.toContain('connectionIndicator');
expect(document.documentElement.dataset.headerStats).toBe('classic');
});
it('keeps WS out of a hidden System Stats pill', () => {
const template = rightSide();
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: false });
expect(statsChildren()).not.toContain('connectionIndicator');
expect(rightSide().indexOf('connectionIndicator')).toBe(template.indexOf('connectionIndicator'));
});
it('is idempotent: re-applying the same style moves nothing', () => {
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
const once = document.getElementById('headerRight')!.innerHTML;
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
expect(document.getElementById('headerRight')!.innerHTML).toBe(once);
});
});
describe('the parts the new styles draw', () => {
it('gives every plan window a ring and a meter with a clamped fill, the label the real number', () => {
const app = makeApp();
app.updatePlanUsageChip({ fiveHour: { usedPercentage: 28 }, sevenDay: { usedPercentage: 104 } });
const wins = [...document.querySelectorAll<HTMLElement>('#planUsageChip .pu-win')];
expect(wins).toHaveLength(2);
expect(wins[0].querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('28');
expect(wins[0].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('28%');
expect(wins[0].querySelector('.pu-ring')!.classList.contains('pu-green')).toBe(true);
expect(wins[1].querySelector('.pu-val')!.textContent).toBe('104%');
expect(wins[1].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('100%');
expect(wins[1].querySelector('.pu-meter')!.classList.contains('pu-red')).toBe(true);
});
it('keeps an idle Claude window as a dimmed slot with an empty ring and meter', () => {
const app = makeApp();
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 35 } });
const idle = document.querySelector<HTMLElement>('#planUsageChip .pu-win-idle')!;
expect(idle.querySelector('.pu-val')!.textContent).toBe('—');
expect(idle.querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('0');
expect(idle.querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('0%');
});
it('names the connection state in two short words per dot state', () => {
const app = makeApp();
const words = (dotClass: string, text = '') => app._connectionTileWords({ dotClass, text });
expect(words('connection-dot connected', 'WS')).toMatchObject({ label: 'WS', value: 'live', state: 'connected' });
expect(words('connection-dot fallback', 'HTTP')).toMatchObject({ label: 'HTTP', value: 'fallback' });
expect(words('connection-dot offline', 'Offline')).toMatchObject({ label: 'NET', value: 'offline' });
expect(words('connection-dot draining', 'Sending...')).toMatchObject({ label: 'SEND', value: 'queued' });
expect(words('connection-dot reconnecting', 'WS…')).toMatchObject({ label: 'WS', value: 'retry' });
expect(words('connection-dot reconnecting', 'Reconnecting...')).toMatchObject({ label: 'SSE', value: 'retry' });
});
it('writes the tile words beside the classic text, which stays as it was', () => {
const app = makeApp();
app._computeConnectionDescriptor = () => ({
display: 'flex',
dotClass: 'connection-dot connected',
text: 'WS · 2.0KB queued',
title: 'Terminal connected over WebSocket',
});
app._updateConnectionIndicator();
expect(document.getElementById('connectionText')!.textContent).toBe('WS · 2.0KB queued');
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
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) => {
const el = document.getElementById(id)!;
return [el.style.getPropertyValue('--pu'), el.classList.contains('high')];
};
app.updateSystemStatsDisplay({ cpu: 22, memory: { usedMB: 14.4 * 1024, percent: 45.6 } });
expect(ring('statCpuRing')).toEqual(['22', false]);
expect(ring('statMemRing')).toEqual(['46', false]);
app.updateSystemStatsDisplay({ cpu: 140, memory: { usedMB: 30 * 1024, percent: 81 } });
expect(ring('statCpuRing')).toEqual(['100', true]);
expect(ring('statMemRing')).toEqual(['81', true]);
// A ring comes first in its stat, before the label, like the plan rings.
expect(document.getElementById('statCpuRing')!.nextElementSibling!.className).toBe('stat-label');
});
});
describe('header stats wiring (static)', () => {
const css = read('styles.css');
it('hides the new-style parts by default, so classic looks exactly as before', () => {
expect(css).toMatch(
/\.stat-ring,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
);
});
it('lays a tile out as three rows, so its height never depends on the font', () => {
// A bar laid over the bottom of a fixed-height tile is what let a taller
// system mono (SF Mono) push the value into it.
expect(css).toMatch(/grid-template-rows: 9px 14px 2px;\s*row-gap: 2px;/);
const tiles = css.slice(css.indexOf('/* --- Tiles: label over value'));
const bar = tiles.slice(tiles.indexOf("html[data-header-stats='tiles'] .header-system-stats .stat-bar,"));
expect(bar.slice(0, bar.indexOf('}'))).not.toContain('position: absolute');
expect(bar.slice(0, bar.indexOf('}'))).toContain('grid-row: 3;');
});
it('gives the header buttons the tile box beside the tiles', () => {
expect(css).toMatch(
/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header \{\s*width: 36px;\s*height: 36px;/
);
expect(css).toMatch(/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header > svg \{\s*width: 18px;/);
});
it('keeps the open Tiles and Split buttons pressed (accent) in the boxed styles, hover included', () => {
// The box rule (html[data-header-stats] .header-right > .btn-icon-header)
// outranks the buttons' own open-state accent, so nothing would show that
// the next click closes the grid or the split.
const found = new Map<string, Record<string, string>>();
postcss.parse(css).walkRules((rule) => {
const decls: Record<string, string> = {};
rule.walkDecls((d) => {
decls[d.prop] = d.value;
});
for (const sel of rule.selectors) found.set(sel, { ...found.get(sel), ...decls });
});
const missing: string[] = [];
for (const style of ['tiles', 'compact']) {
for (const open of ['btn-tile-grid.tiles-open', 'btn-split.split-open']) {
for (const hover of ['', ':hover']) {
// Starting from the box rule's own selector keeps it the more specific one.
const sel = `html[data-header-stats='${style}'] .header-right > .btn-icon-header.${open}${hover}`;
const d = found.get(sel);
if (
d?.background !== 'var(--accent)' ||
d['border-color'] !== 'var(--accent)' ||
d.color !== 'var(--accent-ink)'
)
missing.push(sel);
}
}
}
expect(missing).toEqual([]);
// Classic keeps the buttons' own rules: nothing scoped to it touches them.
expect([...found.keys()].filter((s) => s.includes("'classic'") && /tiles-open|split-open/.test(s))).toEqual([]);
expect([...found.keys()].filter((s) => /(tiles|split)-open/.test(s) && s.includes(':is('))).toEqual([]);
});
it('stamps data-header-stats before first paint, compact by default and classic on narrow screens', () => {
expect(INDEX).toContain(
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact'"
);
});
it('offers the three styles with compact marked as the default', () => {
expect(INDEX).toMatch(
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">Compact \(default\)<\/option>\s*<option value="tiles">[^<]+<\/option>/
);
expect(INDEX).not.toContain('Tiles (default)');
});
});