@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.
Files changed (78) hide show
  1. package/README.md +57 -597
  2. package/dist/chunk-F4PDBJLO.mjs +973 -0
  3. package/dist/chunk-F4PDBJLO.mjs.map +1 -0
  4. package/dist/index.d.mts +249 -0
  5. package/dist/index.d.ts +117 -30
  6. package/dist/index.js +1818 -177
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +845 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/server-4TeBq6hp.d.mts +367 -0
  11. package/dist/server-4TeBq6hp.d.ts +367 -0
  12. package/dist/server.d.mts +1 -0
  13. package/dist/server.d.ts +1 -0
  14. package/dist/server.js +977 -0
  15. package/dist/server.js.map +1 -0
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +1 -0
  18. package/package.json +42 -19
  19. package/src/__tests__/debug-tools.test.ts +359 -0
  20. package/src/__tests__/default-translations.test.ts +179 -0
  21. package/src/__tests__/i18n-resource.test.ts +137 -0
  22. package/src/__tests__/lazy-loader.test.ts +109 -0
  23. package/src/__tests__/missing-key-overlay.test.tsx +339 -0
  24. package/src/__tests__/translator-factory.test.ts +120 -0
  25. package/src/__tests__/translator.test.ts +442 -0
  26. package/src/__tests__/types.test.ts +211 -0
  27. package/src/__tests__/useI18n.test.tsx +181 -0
  28. package/src/__tests__/useTranslation.test.tsx +110 -0
  29. package/src/components/MissingKeyOverlay.tsx +1 -1
  30. package/src/core/lazy-loader.ts +2 -2
  31. package/src/core/translator.tsx +151 -62
  32. package/src/hooks/useI18n.tsx +96 -115
  33. package/src/hooks/useTranslation.tsx +12 -10
  34. package/src/index.ts +102 -5
  35. package/src/server.ts +9 -0
  36. package/src/types/index.ts +67 -12
  37. package/LICENSE +0 -21
  38. package/dist/components/MissingKeyOverlay.d.ts +0 -33
  39. package/dist/components/MissingKeyOverlay.d.ts.map +0 -1
  40. package/dist/components/MissingKeyOverlay.js +0 -138
  41. package/dist/components/MissingKeyOverlay.js.map +0 -1
  42. package/dist/core/debug-tools.d.ts +0 -37
  43. package/dist/core/debug-tools.d.ts.map +0 -1
  44. package/dist/core/debug-tools.js +0 -241
  45. package/dist/core/debug-tools.js.map +0 -1
  46. package/dist/core/i18n-resource.d.ts +0 -59
  47. package/dist/core/i18n-resource.d.ts.map +0 -1
  48. package/dist/core/i18n-resource.js +0 -153
  49. package/dist/core/i18n-resource.js.map +0 -1
  50. package/dist/core/lazy-loader.d.ts +0 -82
  51. package/dist/core/lazy-loader.d.ts.map +0 -1
  52. package/dist/core/lazy-loader.js +0 -193
  53. package/dist/core/lazy-loader.js.map +0 -1
  54. package/dist/core/translator-factory.d.ts +0 -50
  55. package/dist/core/translator-factory.d.ts.map +0 -1
  56. package/dist/core/translator-factory.js +0 -117
  57. package/dist/core/translator-factory.js.map +0 -1
  58. package/dist/core/translator.d.ts +0 -202
  59. package/dist/core/translator.d.ts.map +0 -1
  60. package/dist/core/translator.js +0 -912
  61. package/dist/core/translator.js.map +0 -1
  62. package/dist/hooks/useI18n.d.ts +0 -39
  63. package/dist/hooks/useI18n.d.ts.map +0 -1
  64. package/dist/hooks/useI18n.js +0 -531
  65. package/dist/hooks/useI18n.js.map +0 -1
  66. package/dist/hooks/useTranslation.d.ts +0 -55
  67. package/dist/hooks/useTranslation.d.ts.map +0 -1
  68. package/dist/hooks/useTranslation.js +0 -58
  69. package/dist/hooks/useTranslation.js.map +0 -1
  70. package/dist/index.d.ts.map +0 -1
  71. package/dist/types/index.d.ts +0 -162
  72. package/dist/types/index.d.ts.map +0 -1
  73. package/dist/types/index.js +0 -191
  74. package/dist/types/index.js.map +0 -1
  75. package/dist/utils/default-translations.d.ts +0 -20
  76. package/dist/utils/default-translations.d.ts.map +0 -1
  77. package/dist/utils/default-translations.js +0 -123
  78. 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
+ });