@hua-labs/i18n-core 2.0.0 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -597
- package/dist/chunk-F4PDBJLO.mjs +973 -0
- package/dist/chunk-F4PDBJLO.mjs.map +1 -0
- package/dist/index.d.mts +249 -0
- package/dist/index.d.ts +117 -30
- package/dist/index.js +1818 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +845 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server-4TeBq6hp.d.mts +367 -0
- package/dist/server-4TeBq6hp.d.ts +367 -0
- package/dist/server.d.mts +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +977 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +3 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +42 -19
- package/src/__tests__/debug-tools.test.ts +359 -0
- package/src/__tests__/default-translations.test.ts +179 -0
- package/src/__tests__/i18n-resource.test.ts +137 -0
- package/src/__tests__/lazy-loader.test.ts +109 -0
- package/src/__tests__/missing-key-overlay.test.tsx +339 -0
- package/src/__tests__/translator-factory.test.ts +120 -0
- package/src/__tests__/translator.test.ts +442 -0
- package/src/__tests__/types.test.ts +211 -0
- package/src/__tests__/useI18n.test.tsx +181 -0
- package/src/__tests__/useTranslation.test.tsx +110 -0
- package/src/components/MissingKeyOverlay.tsx +1 -1
- package/src/core/lazy-loader.ts +2 -2
- package/src/core/translator.tsx +151 -62
- package/src/hooks/useI18n.tsx +96 -115
- package/src/hooks/useTranslation.tsx +12 -10
- package/src/index.ts +102 -5
- package/src/server.ts +9 -0
- package/src/types/index.ts +67 -12
- package/LICENSE +0 -21
- package/dist/components/MissingKeyOverlay.d.ts +0 -33
- package/dist/components/MissingKeyOverlay.d.ts.map +0 -1
- package/dist/components/MissingKeyOverlay.js +0 -138
- package/dist/components/MissingKeyOverlay.js.map +0 -1
- package/dist/core/debug-tools.d.ts +0 -37
- package/dist/core/debug-tools.d.ts.map +0 -1
- package/dist/core/debug-tools.js +0 -241
- package/dist/core/debug-tools.js.map +0 -1
- package/dist/core/i18n-resource.d.ts +0 -59
- package/dist/core/i18n-resource.d.ts.map +0 -1
- package/dist/core/i18n-resource.js +0 -153
- package/dist/core/i18n-resource.js.map +0 -1
- package/dist/core/lazy-loader.d.ts +0 -82
- package/dist/core/lazy-loader.d.ts.map +0 -1
- package/dist/core/lazy-loader.js +0 -193
- package/dist/core/lazy-loader.js.map +0 -1
- package/dist/core/translator-factory.d.ts +0 -50
- package/dist/core/translator-factory.d.ts.map +0 -1
- package/dist/core/translator-factory.js +0 -117
- package/dist/core/translator-factory.js.map +0 -1
- package/dist/core/translator.d.ts +0 -202
- package/dist/core/translator.d.ts.map +0 -1
- package/dist/core/translator.js +0 -912
- package/dist/core/translator.js.map +0 -1
- package/dist/hooks/useI18n.d.ts +0 -39
- package/dist/hooks/useI18n.d.ts.map +0 -1
- package/dist/hooks/useI18n.js +0 -531
- package/dist/hooks/useI18n.js.map +0 -1
- package/dist/hooks/useTranslation.d.ts +0 -55
- package/dist/hooks/useTranslation.d.ts.map +0 -1
- package/dist/hooks/useTranslation.js +0 -58
- package/dist/hooks/useTranslation.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/types/index.d.ts +0 -162
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -191
- package/dist/types/index.js.map +0 -1
- package/dist/utils/default-translations.d.ts +0 -20
- package/dist/utils/default-translations.d.ts.map +0 -1
- package/dist/utils/default-translations.js +0 -123
- package/dist/utils/default-translations.js.map +0 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Translator } from '../core/translator';
|
|
3
|
+
import { I18nConfig } from '../types';
|
|
4
|
+
|
|
5
|
+
// Helper to create a mock config
|
|
6
|
+
function createMockConfig(overrides?: Partial<I18nConfig>): I18nConfig {
|
|
7
|
+
return {
|
|
8
|
+
defaultLanguage: 'ko',
|
|
9
|
+
fallbackLanguage: 'en',
|
|
10
|
+
supportedLanguages: [
|
|
11
|
+
{ code: 'ko', name: 'Korean', nativeName: '한국어' },
|
|
12
|
+
{ code: 'en', name: 'English', nativeName: 'English' },
|
|
13
|
+
],
|
|
14
|
+
namespaces: ['common'],
|
|
15
|
+
loadTranslations: vi.fn().mockImplementation(async (lang: string, ns: string) => {
|
|
16
|
+
const translations: Record<string, Record<string, any>> = {
|
|
17
|
+
'ko:common': {
|
|
18
|
+
greeting: '안녕하세요',
|
|
19
|
+
welcome: '{name}님 환영합니다',
|
|
20
|
+
nested: { deep: { key: '중첩된 값' } },
|
|
21
|
+
items: ['사과', '바나나', '체리'],
|
|
22
|
+
total_count: { one: '총 {count}개', other: '총 {count}개' },
|
|
23
|
+
},
|
|
24
|
+
'en:common': {
|
|
25
|
+
greeting: 'Hello',
|
|
26
|
+
welcome: 'Welcome {name}',
|
|
27
|
+
nested: { deep: { key: 'nested value' } },
|
|
28
|
+
items: ['apple', 'banana', 'cherry'],
|
|
29
|
+
total_count: { one: '{count} item', other: '{count} items' },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
return translations[`${lang}:${ns}`] || {};
|
|
33
|
+
}),
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('Translator', () => {
|
|
39
|
+
let translator: Translator;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.useRealTimers();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('constructor', () => {
|
|
50
|
+
it('should throw for invalid config', () => {
|
|
51
|
+
expect(() => new Translator({} as any)).toThrow('Invalid I18nConfig');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should create with valid config', () => {
|
|
55
|
+
const config = createMockConfig();
|
|
56
|
+
translator = new Translator(config);
|
|
57
|
+
expect(translator.getCurrentLanguage()).toBe('ko');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should use initialTranslations if provided', () => {
|
|
61
|
+
const config = createMockConfig({
|
|
62
|
+
initialTranslations: {
|
|
63
|
+
ko: { common: { greeting: '안녕하세요' } },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
translator = new Translator(config);
|
|
67
|
+
expect(translator.isReady()).toBe(true);
|
|
68
|
+
expect(translator.translate('common:greeting')).toBe('안녕하세요');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('initialize', () => {
|
|
73
|
+
it('should load translations for default and fallback languages', async () => {
|
|
74
|
+
const config = createMockConfig();
|
|
75
|
+
translator = new Translator(config);
|
|
76
|
+
await translator.initialize();
|
|
77
|
+
expect(translator.isReady()).toBe(true);
|
|
78
|
+
expect(config.loadTranslations).toHaveBeenCalledWith('ko', 'common');
|
|
79
|
+
expect(config.loadTranslations).toHaveBeenCalledWith('en', 'common');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should skip if already initialized', async () => {
|
|
83
|
+
const config = createMockConfig();
|
|
84
|
+
translator = new Translator(config);
|
|
85
|
+
await translator.initialize();
|
|
86
|
+
const callCount = (config.loadTranslations as any).mock.calls.length;
|
|
87
|
+
await translator.initialize();
|
|
88
|
+
expect((config.loadTranslations as any).mock.calls.length).toBe(callCount);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle load failure gracefully', async () => {
|
|
92
|
+
const config = createMockConfig({
|
|
93
|
+
loadTranslations: vi.fn().mockRejectedValue(new Error('Network error')),
|
|
94
|
+
});
|
|
95
|
+
translator = new Translator(config);
|
|
96
|
+
|
|
97
|
+
// Run initialize with fake timers - advance past all retry backoff delays
|
|
98
|
+
const initPromise = translator.initialize();
|
|
99
|
+
// Retry backoff: 2^1*1000=2s, 2^2*1000=4s, 2^3*1000=8s per namespace
|
|
100
|
+
// Multiple namespaces × 2 languages, so advance generously
|
|
101
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
102
|
+
await initPromise;
|
|
103
|
+
|
|
104
|
+
// After retries fail, translator uses empty fallback data
|
|
105
|
+
const debugInfo = translator.debug() as any;
|
|
106
|
+
expect(debugInfo.isInitialized).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should skip initialTranslations namespaces during initialize', async () => {
|
|
110
|
+
const loadFn = vi.fn().mockImplementation(async (lang: string, ns: string) => {
|
|
111
|
+
// Return translations for fallback language
|
|
112
|
+
if (lang === 'en' && ns === 'common') {
|
|
113
|
+
return { greeting: 'Hello' };
|
|
114
|
+
}
|
|
115
|
+
return {};
|
|
116
|
+
});
|
|
117
|
+
const config = createMockConfig({
|
|
118
|
+
loadTranslations: loadFn,
|
|
119
|
+
// Don't provide initialTranslations in constructor
|
|
120
|
+
// so initialize() actually runs
|
|
121
|
+
});
|
|
122
|
+
translator = new Translator(config);
|
|
123
|
+
|
|
124
|
+
// Manually set some initial data BEFORE calling initialize
|
|
125
|
+
// This simulates SSR scenario where data is set but initialize hasn't been called
|
|
126
|
+
translator['allTranslations'] = {
|
|
127
|
+
ko: { common: { greeting: '안녕하세요' } }
|
|
128
|
+
};
|
|
129
|
+
translator['loadedNamespaces'].add('ko:common');
|
|
130
|
+
|
|
131
|
+
await translator.initialize();
|
|
132
|
+
|
|
133
|
+
// ko:common should be skipped since it's already in allTranslations
|
|
134
|
+
const koCommonCalls = loadFn.mock.calls.filter(
|
|
135
|
+
([lang, ns]: [string, string]) => lang === 'ko' && ns === 'common'
|
|
136
|
+
);
|
|
137
|
+
expect(koCommonCalls).toHaveLength(0);
|
|
138
|
+
|
|
139
|
+
// en:common should be loaded since it's the fallback language
|
|
140
|
+
const enCommonCalls = loadFn.mock.calls.filter(
|
|
141
|
+
([lang, ns]: [string, string]) => lang === 'en' && ns === 'common'
|
|
142
|
+
);
|
|
143
|
+
expect(enCommonCalls).toHaveLength(1);
|
|
144
|
+
|
|
145
|
+
// Verify that ko:common translation is available
|
|
146
|
+
expect(translator.translate('common:greeting', 'ko')).toBe('안녕하세요');
|
|
147
|
+
// Verify that en:common was loaded
|
|
148
|
+
expect(translator.translate('common:greeting', 'en')).toBe('Hello');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('translate', () => {
|
|
153
|
+
beforeEach(async () => {
|
|
154
|
+
const config = createMockConfig();
|
|
155
|
+
translator = new Translator(config);
|
|
156
|
+
await translator.initialize();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should translate a simple key', () => {
|
|
160
|
+
expect(translator.translate('common:greeting')).toBe('안녕하세요');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should translate with namespace:key format', () => {
|
|
164
|
+
expect(translator.translate('common:greeting')).toBe('안녕하세요');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should use common namespace as default', () => {
|
|
168
|
+
expect(translator.translate('greeting')).toBe('안녕하세요');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should interpolate params with {key} format', () => {
|
|
172
|
+
expect(translator.translate('common:welcome', { name: '철수' })).toBe('철수님 환영합니다');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should translate nested keys with dot notation', () => {
|
|
176
|
+
expect(translator.translate('common:nested.deep.key')).toBe('중첩된 값');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should fallback to fallback language when key not found in current language', async () => {
|
|
180
|
+
// Add a key that only exists in English
|
|
181
|
+
const config = createMockConfig({
|
|
182
|
+
loadTranslations: vi.fn().mockImplementation(async (lang: string, ns: string) => {
|
|
183
|
+
if (lang === 'en' && ns === 'common') return { english_only: 'Only in English' };
|
|
184
|
+
if (lang === 'ko' && ns === 'common') return {};
|
|
185
|
+
return {};
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
translator = new Translator(config);
|
|
189
|
+
await translator.initialize();
|
|
190
|
+
expect(translator.translate('common:english_only')).toBe('Only in English');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should return empty string in production for missing key', () => {
|
|
194
|
+
expect(translator.translate('common:nonexistent')).toBe('');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should accept language override as second parameter (string)', () => {
|
|
198
|
+
expect(translator.translate('common:greeting', 'en')).toBe('Hello');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should accept params and language override', () => {
|
|
202
|
+
expect(translator.translate('common:welcome', { name: 'John' }, 'en')).toBe('Welcome John');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('tPlural', () => {
|
|
207
|
+
beforeEach(async () => {
|
|
208
|
+
const config = createMockConfig();
|
|
209
|
+
translator = new Translator(config);
|
|
210
|
+
await translator.initialize();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should select "one" for count=1 in English', () => {
|
|
214
|
+
expect(translator.tPlural('common:total_count', 1, {}, 'en')).toBe('1 item');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should select "other" for count>1 in English', () => {
|
|
218
|
+
expect(translator.tPlural('common:total_count', 5, {}, 'en')).toBe('5 items');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should use "other" for Korean (no singular form)', () => {
|
|
222
|
+
expect(translator.tPlural('common:total_count', 1)).toBe('총 1개');
|
|
223
|
+
expect(translator.tPlural('common:total_count', 5)).toBe('총 5개');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should return empty string for missing key in non-debug mode', () => {
|
|
227
|
+
expect(translator.tPlural('common:nonexistent', 1)).toBe('');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('tArray', () => {
|
|
232
|
+
beforeEach(async () => {
|
|
233
|
+
const config = createMockConfig();
|
|
234
|
+
translator = new Translator(config);
|
|
235
|
+
await translator.initialize();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should return string array', () => {
|
|
239
|
+
expect(translator.tArray('common:items')).toEqual(['사과', '바나나', '체리']);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should return empty array for non-array key', () => {
|
|
243
|
+
expect(translator.tArray('common:greeting')).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should return empty array for missing key', () => {
|
|
247
|
+
expect(translator.tArray('common:nonexistent')).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should return array for specific language', () => {
|
|
251
|
+
expect(translator.tArray('common:items', 'en')).toEqual(['apple', 'banana', 'cherry']);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('getRawValue', () => {
|
|
256
|
+
beforeEach(async () => {
|
|
257
|
+
const config = createMockConfig();
|
|
258
|
+
translator = new Translator(config);
|
|
259
|
+
await translator.initialize();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should return raw string', () => {
|
|
263
|
+
expect(translator.getRawValue('common:greeting')).toBe('안녕하세요');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should return raw array', () => {
|
|
267
|
+
expect(translator.getRawValue('common:items')).toEqual(['사과', '바나나', '체리']);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should return raw plural object', () => {
|
|
271
|
+
const raw = translator.getRawValue('common:total_count') as any;
|
|
272
|
+
expect(raw).toEqual({ one: '총 {count}개', other: '총 {count}개' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should return undefined for missing key', () => {
|
|
276
|
+
expect(translator.getRawValue('common:nonexistent')).toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should return nested value', () => {
|
|
280
|
+
expect(translator.getRawValue('common:nested.deep.key')).toBe('중첩된 값');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('setLanguage', () => {
|
|
285
|
+
it('should change current language', async () => {
|
|
286
|
+
const config = createMockConfig();
|
|
287
|
+
translator = new Translator(config);
|
|
288
|
+
await translator.initialize();
|
|
289
|
+
translator.setLanguage('en');
|
|
290
|
+
expect(translator.getCurrentLanguage()).toBe('en');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should not change if same language', async () => {
|
|
294
|
+
const config = createMockConfig();
|
|
295
|
+
translator = new Translator(config);
|
|
296
|
+
await translator.initialize();
|
|
297
|
+
const callback = vi.fn();
|
|
298
|
+
translator.onLanguageChanged(callback);
|
|
299
|
+
translator.setLanguage('ko');
|
|
300
|
+
expect(callback).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should notify language change listeners', async () => {
|
|
304
|
+
const config = createMockConfig();
|
|
305
|
+
translator = new Translator(config);
|
|
306
|
+
await translator.initialize();
|
|
307
|
+
const callback = vi.fn();
|
|
308
|
+
translator.onLanguageChanged(callback);
|
|
309
|
+
translator.setLanguage('en');
|
|
310
|
+
expect(callback).toHaveBeenCalledWith('en');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('clearCache', () => {
|
|
315
|
+
it('should clear cache and reset stats', async () => {
|
|
316
|
+
const config = createMockConfig();
|
|
317
|
+
translator = new Translator(config);
|
|
318
|
+
await translator.initialize();
|
|
319
|
+
translator.translate('common:greeting');
|
|
320
|
+
translator.clearCache();
|
|
321
|
+
const debugInfo = translator.debug() as any;
|
|
322
|
+
expect(debugInfo.cacheStats.hits).toBe(0);
|
|
323
|
+
expect(debugInfo.cacheStats.misses).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('callback management', () => {
|
|
328
|
+
it('should register and unregister translation loaded callbacks', async () => {
|
|
329
|
+
const config = createMockConfig();
|
|
330
|
+
translator = new Translator(config);
|
|
331
|
+
await translator.initialize();
|
|
332
|
+
const callback = vi.fn();
|
|
333
|
+
const unsubscribe = translator.onTranslationLoaded(callback);
|
|
334
|
+
expect(typeof unsubscribe).toBe('function');
|
|
335
|
+
unsubscribe();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should register and unregister language changed callbacks', async () => {
|
|
339
|
+
const config = createMockConfig();
|
|
340
|
+
translator = new Translator(config);
|
|
341
|
+
const callback = vi.fn();
|
|
342
|
+
const unsubscribe = translator.onLanguageChanged(callback);
|
|
343
|
+
translator.setLanguage('en');
|
|
344
|
+
expect(callback).toHaveBeenCalled();
|
|
345
|
+
callback.mockClear();
|
|
346
|
+
unsubscribe();
|
|
347
|
+
translator.setLanguage('ko');
|
|
348
|
+
expect(callback).not.toHaveBeenCalled();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('debug', () => {
|
|
353
|
+
it('should return debug info', async () => {
|
|
354
|
+
const config = createMockConfig();
|
|
355
|
+
translator = new Translator(config);
|
|
356
|
+
await translator.initialize();
|
|
357
|
+
const info = translator.debug() as any;
|
|
358
|
+
expect(info.isInitialized).toBe(true);
|
|
359
|
+
expect(info.currentLanguage).toBe('ko');
|
|
360
|
+
expect(info.loadedNamespaces).toContain('ko:common');
|
|
361
|
+
expect(info.loadedNamespaces).toContain('en:common');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('hydrateFromSSR', () => {
|
|
366
|
+
it('should set translations and mark as initialized', () => {
|
|
367
|
+
const config = createMockConfig();
|
|
368
|
+
translator = new Translator(config);
|
|
369
|
+
translator.hydrateFromSSR({
|
|
370
|
+
ko: { common: { greeting: 'SSR 안녕' } },
|
|
371
|
+
});
|
|
372
|
+
expect(translator.isReady()).toBe(true);
|
|
373
|
+
expect(translator.translate('common:greeting')).toBe('SSR 안녕');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Test ssrTranslate standalone function
|
|
379
|
+
import { ssrTranslate, serverTranslate } from '../core/translator';
|
|
380
|
+
|
|
381
|
+
describe('ssrTranslate', () => {
|
|
382
|
+
const translations = {
|
|
383
|
+
ko: {
|
|
384
|
+
common: {
|
|
385
|
+
greeting: '안녕하세요',
|
|
386
|
+
nested: { deep: 'SSR 중첩' },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
en: {
|
|
390
|
+
common: {
|
|
391
|
+
greeting: 'Hello',
|
|
392
|
+
english_only: 'Only in English',
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
it('should translate with current language', () => {
|
|
398
|
+
expect(ssrTranslate({ translations, key: 'common:greeting', language: 'ko' })).toBe('안녕하세요');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should fallback to fallback language', () => {
|
|
402
|
+
expect(ssrTranslate({ translations, key: 'common:english_only', language: 'ko' })).toBe('Only in English');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should use missingKeyHandler for missing keys', () => {
|
|
406
|
+
expect(ssrTranslate({
|
|
407
|
+
translations,
|
|
408
|
+
key: 'common:nonexistent',
|
|
409
|
+
language: 'ko',
|
|
410
|
+
missingKeyHandler: (k) => `[MISSING: ${k}]`,
|
|
411
|
+
})).toBe('[MISSING: common:nonexistent]');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should handle nested keys', () => {
|
|
415
|
+
expect(ssrTranslate({ translations, key: 'common:nested.deep', language: 'ko' })).toBe('SSR 중첩');
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('serverTranslate', () => {
|
|
420
|
+
const translations = {
|
|
421
|
+
ko: { common: { title: '제목' } },
|
|
422
|
+
en: { common: { title: 'Title' } },
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
it('should translate basic key', () => {
|
|
426
|
+
expect(serverTranslate({ translations, key: 'common:title', language: 'ko' })).toBe('제목');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should use cache when provided', () => {
|
|
430
|
+
const cache = new Map<string, string>();
|
|
431
|
+
serverTranslate({ translations, key: 'common:title', language: 'ko', options: { cache } });
|
|
432
|
+
expect(cache.has('ko:common:title')).toBe(true);
|
|
433
|
+
// Second call should hit cache
|
|
434
|
+
const result = serverTranslate({ translations, key: 'common:title', language: 'ko', options: { cache } });
|
|
435
|
+
expect(result).toBe('제목');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should fallback to fallback language', () => {
|
|
439
|
+
const result = serverTranslate({ translations, key: 'common:title', language: 'ja', fallbackLanguage: 'en' });
|
|
440
|
+
expect(result).toBe('Title');
|
|
441
|
+
});
|
|
442
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isPluralValue,
|
|
4
|
+
isTranslationNamespace,
|
|
5
|
+
isLanguageConfig,
|
|
6
|
+
isTranslationError,
|
|
7
|
+
validateI18nConfig,
|
|
8
|
+
createTranslationError,
|
|
9
|
+
createUserFriendlyError,
|
|
10
|
+
isRecoverableError,
|
|
11
|
+
logTranslationError,
|
|
12
|
+
} from '../types';
|
|
13
|
+
|
|
14
|
+
describe('isPluralValue', () => {
|
|
15
|
+
it('should return true for valid plural value with other key', () => {
|
|
16
|
+
expect(isPluralValue({ other: 'items' })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return true for full plural value', () => {
|
|
20
|
+
expect(isPluralValue({ zero: 'no items', one: '1 item', other: '{count} items' })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return false for null', () => {
|
|
24
|
+
expect(isPluralValue(null)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return false for arrays', () => {
|
|
28
|
+
expect(isPluralValue(['a', 'b'])).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return false for empty object', () => {
|
|
32
|
+
expect(isPluralValue({})).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return false for object without "other" key', () => {
|
|
36
|
+
expect(isPluralValue({ one: 'item' })).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return false for object with non-plural keys', () => {
|
|
40
|
+
expect(isPluralValue({ other: 'items', invalid: 'key' })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return false for object with non-string values', () => {
|
|
44
|
+
expect(isPluralValue({ other: 123 })).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('isTranslationNamespace', () => {
|
|
49
|
+
it('should return true for plain object', () => {
|
|
50
|
+
expect(isTranslationNamespace({ key: 'value' })).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return true for empty object', () => {
|
|
54
|
+
expect(isTranslationNamespace({})).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return false for null', () => {
|
|
58
|
+
expect(isTranslationNamespace(null)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return false for array', () => {
|
|
62
|
+
expect(isTranslationNamespace(['a'])).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return false for primitive', () => {
|
|
66
|
+
expect(isTranslationNamespace('string')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('isLanguageConfig', () => {
|
|
71
|
+
it('should return true for valid config', () => {
|
|
72
|
+
expect(isLanguageConfig({ code: 'ko', name: 'Korean', nativeName: '한국어' })).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return false for missing code', () => {
|
|
76
|
+
expect(isLanguageConfig({ name: 'Korean', nativeName: '한국어' })).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return false for null', () => {
|
|
80
|
+
expect(isLanguageConfig(null)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return false for non-string fields', () => {
|
|
84
|
+
expect(isLanguageConfig({ code: 123, name: 'Korean', nativeName: '한국어' })).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('isTranslationError', () => {
|
|
89
|
+
it('should return true for valid translation error', () => {
|
|
90
|
+
const error = createTranslationError('MISSING_KEY', 'Key not found');
|
|
91
|
+
expect(isTranslationError(error)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return false for regular error', () => {
|
|
95
|
+
expect(isTranslationError(new Error('normal error'))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return false for non-error', () => {
|
|
99
|
+
expect(isTranslationError({ code: 'MISSING_KEY', message: 'test' })).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('validateI18nConfig', () => {
|
|
104
|
+
const validConfig = {
|
|
105
|
+
defaultLanguage: 'ko',
|
|
106
|
+
supportedLanguages: [{ code: 'ko', name: 'Korean', nativeName: '한국어' }],
|
|
107
|
+
loadTranslations: async () => ({}),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it('should return true for valid config', () => {
|
|
111
|
+
expect(validateI18nConfig(validConfig)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return false for null', () => {
|
|
115
|
+
expect(validateI18nConfig(null)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should return false for missing defaultLanguage', () => {
|
|
119
|
+
expect(validateI18nConfig({ ...validConfig, defaultLanguage: undefined })).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should return false for missing supportedLanguages', () => {
|
|
123
|
+
expect(validateI18nConfig({ ...validConfig, supportedLanguages: undefined })).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return false for missing loadTranslations', () => {
|
|
127
|
+
expect(validateI18nConfig({ ...validConfig, loadTranslations: undefined })).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('createTranslationError', () => {
|
|
132
|
+
it('should create error with correct code', () => {
|
|
133
|
+
const error = createTranslationError('MISSING_KEY', 'Key not found');
|
|
134
|
+
expect(error.code).toBe('MISSING_KEY');
|
|
135
|
+
expect(error.message).toBe('Key not found');
|
|
136
|
+
expect(error.name).toBe('TranslationError');
|
|
137
|
+
expect(error.timestamp).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should include context information', () => {
|
|
141
|
+
const error = createTranslationError('LOAD_FAILED', 'Failed', undefined, {
|
|
142
|
+
language: 'ko',
|
|
143
|
+
namespace: 'common',
|
|
144
|
+
key: 'test',
|
|
145
|
+
});
|
|
146
|
+
expect(error.language).toBe('ko');
|
|
147
|
+
expect(error.namespace).toBe('common');
|
|
148
|
+
expect(error.key).toBe('test');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should include original error', () => {
|
|
152
|
+
const original = new Error('original');
|
|
153
|
+
const error = createTranslationError('NETWORK_ERROR', 'Network failed', original);
|
|
154
|
+
expect(error.originalError).toBe(original);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('createUserFriendlyError', () => {
|
|
159
|
+
it('should return user-friendly message for MISSING_KEY', () => {
|
|
160
|
+
const error = createTranslationError('MISSING_KEY', 'Key not found');
|
|
161
|
+
const friendly = createUserFriendlyError(error);
|
|
162
|
+
expect(friendly.code).toBe('MISSING_KEY');
|
|
163
|
+
expect(friendly.severity).toBe('low');
|
|
164
|
+
expect(friendly.message).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should return critical severity for INITIALIZATION_ERROR', () => {
|
|
168
|
+
const error = createTranslationError('INITIALIZATION_ERROR', 'Init failed');
|
|
169
|
+
const friendly = createUserFriendlyError(error);
|
|
170
|
+
expect(friendly.severity).toBe('critical');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('isRecoverableError', () => {
|
|
175
|
+
it('should return true for LOAD_FAILED with retries remaining', () => {
|
|
176
|
+
const error = createTranslationError('LOAD_FAILED', 'Failed', undefined, { retryCount: 0, maxRetries: 3 });
|
|
177
|
+
expect(isRecoverableError(error)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should return true for NETWORK_ERROR', () => {
|
|
181
|
+
const error = createTranslationError('NETWORK_ERROR', 'Network error');
|
|
182
|
+
expect(isRecoverableError(error)).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should return false for MISSING_KEY', () => {
|
|
186
|
+
const error = createTranslationError('MISSING_KEY', 'Missing');
|
|
187
|
+
expect(isRecoverableError(error)).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should return false when retries exhausted', () => {
|
|
191
|
+
const error = createTranslationError('LOAD_FAILED', 'Failed', undefined, { retryCount: 3, maxRetries: 3 });
|
|
192
|
+
expect(isRecoverableError(error)).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('logTranslationError', () => {
|
|
197
|
+
it('should not log when disabled', () => {
|
|
198
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
199
|
+
const error = createTranslationError('LOAD_FAILED', 'Failed');
|
|
200
|
+
logTranslationError(error, { enabled: false, level: 'error', includeStack: false, includeContext: false });
|
|
201
|
+
expect(spy).not.toHaveBeenCalled();
|
|
202
|
+
spy.mockRestore();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should call custom logger when provided', () => {
|
|
206
|
+
const customLogger = vi.fn();
|
|
207
|
+
const error = createTranslationError('LOAD_FAILED', 'Failed');
|
|
208
|
+
logTranslationError(error, { enabled: true, level: 'error', includeStack: false, includeContext: false, customLogger });
|
|
209
|
+
expect(customLogger).toHaveBeenCalledWith(error);
|
|
210
|
+
});
|
|
211
|
+
});
|