Skip to content
42 changes: 37 additions & 5 deletions ui/desktop/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getLocale } from './index';

const englishMessages = {
shared: 'English shared message',
englishOnly: 'English fallback message',
};

const zhCnMessages = {
shared: 'Chinese shared message',
};

// Helper to mock window.appConfig for tests
function mockAppConfig(values: Record<string, unknown>) {
(window as unknown as Record<string, unknown>).appConfig = {
Expand Down Expand Up @@ -61,17 +70,40 @@ describe('getLocale', () => {
});

describe('loadMessages', () => {
it('returns empty object for English locale', async () => {
afterEach(() => {
vi.doUnmock('./compiled/en.json');
vi.doUnmock('./compiled/zh-CN.json');
vi.resetModules();
});

async function loadMessagesWithMockedCatalogs() {
vi.resetModules();
vi.doMock('./compiled/en.json', () => ({ default: englishMessages }));
vi.doMock('./compiled/zh-CN.json', () => ({ default: zhCnMessages }));

const { loadMessages } = await import('./index');
return loadMessages;
}

it('returns compiled English messages for English locale', async () => {
const loadMessages = await loadMessagesWithMockedCatalogs();
const messages = await loadMessages('en');
expect(messages).toEqual({});
expect(messages).toEqual(englishMessages);
});

it('returns empty object for unsupported locale (with warning)', async () => {
it('merges locale messages over English fallback messages', async () => {
const loadMessages = await loadMessagesWithMockedCatalogs();
const messages = await loadMessages('zh-CN');

expect(messages.shared).toBe(zhCnMessages.shared);
expect(messages.englishOnly).toBe(englishMessages.englishOnly);
});

it('returns English messages for unsupported locale (with warning)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { loadMessages } = await import('./index');
const loadMessages = await loadMessagesWithMockedCatalogs();
const messages = await loadMessages('xx');
expect(messages).toEqual({});
expect(messages).toEqual(englishMessages);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No message catalog found'));
warnSpy.mockRestore();
});
Expand Down
17 changes: 12 additions & 5 deletions ui/desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,22 +92,29 @@ export const currentMessageLocale = resolvedLocale.messageLocale;

/**
* Load compiled messages for a given locale.
* Returns an empty object for English (react-intl uses defaultMessage as fallback).
* English messages are always loaded as the fallback catalog so regional
* English locales can keep their locale for date/number formatting without
* triggering missing translation warnings for every message.
*/
export async function loadMessages(locale: string): Promise<Record<string, string>> {
const englishMod = await import('./compiled/en.json');
Comment thread
angiejones marked this conversation as resolved.
Outdated
const englishMessages = englishMod.default ?? englishMod;

if (locale === 'en') {
// English strings live in source code as defaultMessage — no catalog needed.
return {};
return englishMessages;
}

try {
// Dynamic import so compiled translation bundles are code-split.
const mod = await import(`./compiled/${locale}.json`);
return mod.default ?? mod;
return {
...englishMessages,
...(mod.default ?? mod),
Comment thread
angiejones marked this conversation as resolved.
Outdated
};
} catch {
console.warn(
`[i18n] No message catalog found for locale "${locale}", falling back to English.`
);
return {};
return englishMessages;
}
}
Loading