From 86ffb2235a6593e7ca76a13332d16d76d68fe8f5 Mon Sep 17 00:00:00 2001 From: Mia Date: Sat, 19 Sep 2026 04:25:55 -0400 Subject: [PATCH] test: keep the client suite independent of the host locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two client test files assert English output from code that formats in the host's default locale, which is right for the app but fails the suite on a developer machine set to another language. The two files on 3.1.0: LC_ALL=fr_FR.UTF-8 3 failed LC_ALL=de_DE.UTF-8 4 failed LC_ALL= (empty) 2 failed Intl resolves `und` LC_ALL=en_US.UTF-8 0 failed - SessionPicker: `relativeTime` uses `Intl.RelativeTimeFormat(undefined, ...)` and its tests match /day/, /second/. Under fr_FR two days ago is 'avant-hier', under de_DE 'vorgestern', under `und` '-2 d'. The function already takes `now` so its tests are deterministic; it now takes an optional `locale` for the same reason, and the tests pass 'en'. The app passes none. - ContextMeter: the context and session titles use `toLocaleString()`, and the tests expected '50,000 of 200,000' and '12,500 tokens · 12,000 input'. Under de_DE those are '50.000' and '12.500'; under fr_FR the groups are separated by U+202F. The expected text is now built with `toLocaleString()`, as AnnotationMarker's and StatusDot's tests already do. With both, `npm run test:client` passes 1150/1150 under fr_FR, de_DE and an empty LC_ALL. --- src/components/SessionPicker.client.test.tsx | 10 ++++++---- src/components/SessionPicker.tsx | 7 ++++--- src/components/chat/ContextMeter.client.test.tsx | 9 ++++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/SessionPicker.client.test.tsx b/src/components/SessionPicker.client.test.tsx index c6cba8f6..e58fc56b 100644 --- a/src/components/SessionPicker.client.test.tsx +++ b/src/components/SessionPicker.client.test.tsx @@ -151,14 +151,16 @@ describe('SessionPicker', () => { describe('relativeTime', () => { const now = Date.parse('2026-09-17T12:00:00Z'); + // The assertions match English wording. + const ago = (iso: string) => relativeTime(Date.parse(iso), now, 'en'); it('picks the largest fitting unit', () => { - expect(relativeTime(Date.parse('2026-09-15T12:00:00Z'), now)).toMatch(/day/); - expect(relativeTime(Date.parse('2026-09-17T09:00:00Z'), now)).toMatch(/hour/); - expect(relativeTime(Date.parse('2026-09-17T11:30:00Z'), now)).toMatch(/minute/); + expect(ago('2026-09-15T12:00:00Z')).toMatch(/day/); + expect(ago('2026-09-17T09:00:00Z')).toMatch(/hour/); + expect(ago('2026-09-17T11:30:00Z')).toMatch(/minute/); }); it('falls through to seconds for a very recent session', () => { - expect(relativeTime(Date.parse('2026-09-17T11:59:50Z'), now)).toMatch(/second/); + expect(ago('2026-09-17T11:59:50Z')).toMatch(/second/); }); }); diff --git a/src/components/SessionPicker.tsx b/src/components/SessionPicker.tsx index ffb350a4..3968a235 100644 --- a/src/components/SessionPicker.tsx +++ b/src/components/SessionPicker.tsx @@ -6,9 +6,10 @@ import { canResumeSessionId } from '../../electron/shared/session-resume'; import type { SessionRecord } from '../../electron/shared/session-record'; /** Relative age of a session, via the platform formatter rather than a table - * of thresholds. Exported for its test. */ -export function relativeTime(epochMs: number, now = Date.now()): string { - const format = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + * of thresholds. Exported for its test, which pins `locale` so the wording it + * checks does not depend on the host's default. */ +export function relativeTime(epochMs: number, now = Date.now(), locale?: string): string { + const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); const seconds = Math.round((epochMs - now) / 1000); const units: [Intl.RelativeTimeFormatUnit, number][] = [ ['day', 86_400], diff --git a/src/components/chat/ContextMeter.client.test.tsx b/src/components/chat/ContextMeter.client.test.tsx index 2d0a593d..1f1952e2 100644 --- a/src/components/chat/ContextMeter.client.test.tsx +++ b/src/components/chat/ContextMeter.client.test.tsx @@ -22,12 +22,15 @@ afterEach(() => { container.remove(); }); +// Exact counts are formatted in the host's locale, so the expected text is too. +const n = (value: number) => value.toLocaleString(); + it('shows how full the context is, with exact figures and over-limit usage on hover', () => { expect(meter()).toBeNull(); setState({ ...base, contextUsage: { usedTokens: 50000, maxTokens: 200000 } }); // A ring alone until the window runs short; the figures wait on hover. expect(meter()?.textContent).toBe(''); - expect(meter()?.title).toContain('50,000 of 200,000 tokens used, 150K left'); + expect(meter()?.title).toContain(`${n(50_000)} of ${n(200_000)} tokens used, 150K left`); expect(meter()?.getAttribute('aria-valuenow')).toBe('50000'); setState({ ...base, contextUsage: { usedTokens: 190000, maxTokens: 200000 } }); expect(meter()?.dataset.level).toBe('high'); @@ -36,7 +39,7 @@ it('shows how full the context is, with exact figures and over-limit usage on ho expect(meter()?.textContent).toBe('105%'); expect(meter()?.dataset.level).toBe('full'); expect(meter()?.getAttribute('aria-valuenow')).toBe('200000'); - expect(meter()?.getAttribute('aria-valuetext')).toContain('210,000 of 200,000'); + expect(meter()?.getAttribute('aria-valuetext')).toContain(`${n(210_000)} of ${n(200_000)}`); setState(base); expect(meter()).toBeNull(); }); @@ -55,7 +58,7 @@ it('adds the session token breakdown to the hover text', () => { scope: 'conversation', }, }); - expect(meter()?.title).toContain('Session: 12,500 tokens · 12,000 input'); + expect(meter()?.title).toContain(`Session: ${n(12_500)} tokens · ${n(12_000)} input`); expect(meter()?.title).toContain('for this conversation'); setState({ ...base,