@i18n-micro/core 1.0.27 → 1.1.0
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/dist/base.d.ts +91 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.mjs +329 -268
- package/dist/route-service.d.ts +6 -5
- package/dist/translation.d.ts +16 -17
- package/package.json +4 -4
- package/src/base.ts +250 -0
- package/src/helpers.ts +7 -6
- package/src/index.ts +5 -2
- package/src/route-service.ts +29 -33
- package/src/translation.ts +71 -181
- package/tests/base.test.ts +485 -0
- package/tests/route-service.test.ts +31 -61
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { BaseI18n, type BaseI18nOptions } from '../src/base'
|
|
2
|
+
import type { Translations, PluralFunc } from '@i18n-micro/types'
|
|
3
|
+
|
|
4
|
+
// Test implementation of BaseI18n
|
|
5
|
+
class TestI18n extends BaseI18n {
|
|
6
|
+
private _locale: string
|
|
7
|
+
private _fallbackLocale: string
|
|
8
|
+
private _route: string
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
locale: string,
|
|
12
|
+
fallbackLocale: string,
|
|
13
|
+
route: string,
|
|
14
|
+
options?: BaseI18nOptions,
|
|
15
|
+
) {
|
|
16
|
+
super(options)
|
|
17
|
+
this._locale = locale
|
|
18
|
+
this._fallbackLocale = fallbackLocale
|
|
19
|
+
this._route = route
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public getLocale(): string {
|
|
23
|
+
return this._locale
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public getFallbackLocale(): string {
|
|
27
|
+
return this._fallbackLocale
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public getRoute(): string {
|
|
31
|
+
return this._route
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public setLocale(locale: string): void {
|
|
35
|
+
this._locale = locale
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public setRoute(route: string): void {
|
|
39
|
+
this._route = route
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('BaseI18n', () => {
|
|
44
|
+
describe('Constructor', () => {
|
|
45
|
+
test('should initialize with default options', () => {
|
|
46
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
47
|
+
expect(i18n.getLocale()).toBe('en')
|
|
48
|
+
expect(i18n.getFallbackLocale()).toBe('en')
|
|
49
|
+
expect(i18n.getRoute()).toBe('general')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('should initialize with custom storage', () => {
|
|
53
|
+
const storage = { translations: new Map<string, Translations>() }
|
|
54
|
+
const i18n = new TestI18n('en', 'en', 'general', { storage })
|
|
55
|
+
expect(i18n).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should initialize with custom plural function', () => {
|
|
59
|
+
const customPlural: PluralFunc = () => 'custom'
|
|
60
|
+
const i18n = new TestI18n('en', 'en', 'general', { plural: customPlural })
|
|
61
|
+
expect(i18n).toBeDefined()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('should initialize with missingWarn option', () => {
|
|
65
|
+
const i18n = new TestI18n('en', 'en', 'general', { missingWarn: false })
|
|
66
|
+
expect(i18n).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('should initialize with missingHandler', () => {
|
|
70
|
+
const handler = jest.fn()
|
|
71
|
+
const i18n = new TestI18n('en', 'en', 'general', { missingHandler: handler })
|
|
72
|
+
expect(i18n).toBeDefined()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('t() method', () => {
|
|
77
|
+
test('should return empty string for empty key', () => {
|
|
78
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
79
|
+
expect(i18n.t('')).toBe('')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('should return translation for existing key', async () => {
|
|
83
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
84
|
+
const translations: Translations = { greeting: 'Hello' }
|
|
85
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
86
|
+
|
|
87
|
+
expect(i18n.t('greeting')).toBe('Hello')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('should interpolate params in translation', async () => {
|
|
91
|
+
const storage = { translations: new Map<string, Translations>() }
|
|
92
|
+
const i18n = new TestI18n('en', 'en', 'general', { storage })
|
|
93
|
+
const translations: Translations = { greeting: 'Hello, {name}!' }
|
|
94
|
+
await i18n['helper'].loadTranslations('en', translations)
|
|
95
|
+
|
|
96
|
+
expect(i18n.t('greeting', { name: 'John' })).toBe('Hello, John!')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('should use defaultValue when translation is missing', () => {
|
|
100
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
101
|
+
expect(i18n.t('missing.key', undefined, 'Default value')).toBe('Default value')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('should return key when translation is missing and no defaultValue', () => {
|
|
105
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
106
|
+
expect(i18n.t('missing.key')).toBe('missing.key')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('should fallback to fallbackLocale when translation is missing', async () => {
|
|
110
|
+
const storage = { translations: new Map<string, Translations>() }
|
|
111
|
+
const i18n = new TestI18n('en', 'fr', 'general', { storage })
|
|
112
|
+
const translations: Translations = { greeting: 'Bonjour' }
|
|
113
|
+
await i18n['helper'].loadTranslations('fr', translations)
|
|
114
|
+
|
|
115
|
+
expect(i18n.t('greeting')).toBe('Bonjour')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('should use route-specific translation when routeName is provided', async () => {
|
|
119
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
120
|
+
const routeTranslations: Translations = { title: 'Route Title' }
|
|
121
|
+
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
122
|
+
|
|
123
|
+
expect(i18n.t('title', undefined, undefined, 'about')).toBe('Route Title')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('should use previousPageInfo fallback when enabled', async () => {
|
|
127
|
+
const storage = { translations: new Map<string, Translations>() }
|
|
128
|
+
const prevInfo = { locale: 'fr', routeName: 'previous' }
|
|
129
|
+
const i18n = new TestI18n('en', 'en', 'general', {
|
|
130
|
+
storage,
|
|
131
|
+
getPreviousPageInfo: () => prevInfo,
|
|
132
|
+
enablePreviousPageFallback: true,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const translations: Translations = { greeting: 'Bonjour' }
|
|
136
|
+
await i18n['helper'].loadPageTranslations('fr', 'previous', translations)
|
|
137
|
+
|
|
138
|
+
// Current locale is 'en', route is 'general', translation not found
|
|
139
|
+
// Should fallback to previous page info: locale 'fr', route 'previous'
|
|
140
|
+
expect(i18n.t('greeting')).toBe('Bonjour')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('should call missingHandler when translation is missing', () => {
|
|
144
|
+
const handler = jest.fn()
|
|
145
|
+
const i18n = new TestI18n('en', 'en', 'general', { missingHandler: handler })
|
|
146
|
+
|
|
147
|
+
i18n.t('missing.key')
|
|
148
|
+
|
|
149
|
+
expect(handler).toHaveBeenCalledWith('en', 'missing.key', 'general')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('should call customMissingHandler when set (Nuxt runtime)', () => {
|
|
153
|
+
const customHandler = jest.fn()
|
|
154
|
+
const i18n = new TestI18n('en', 'en', 'general', {
|
|
155
|
+
getCustomMissingHandler: () => customHandler,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
i18n.t('missing.key')
|
|
159
|
+
|
|
160
|
+
expect(customHandler).toHaveBeenCalledWith('en', 'missing.key', 'general')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('should not warn when missingWarn is false', () => {
|
|
164
|
+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
165
|
+
const i18n = new TestI18n('en', 'en', 'general', { missingWarn: false })
|
|
166
|
+
|
|
167
|
+
i18n.t('missing.key')
|
|
168
|
+
|
|
169
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
170
|
+
consoleSpy.mockRestore()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('ts() method', () => {
|
|
175
|
+
test('should return translation as string', async () => {
|
|
176
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
177
|
+
const translations: Translations = { greeting: 'Hello' }
|
|
178
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
179
|
+
|
|
180
|
+
expect(i18n.ts('greeting')).toBe('Hello')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('should return defaultValue when translation is missing', () => {
|
|
184
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
185
|
+
expect(i18n.ts('missing.key', undefined, 'Default')).toBe('Default')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('should return key when translation is missing and no defaultValue', () => {
|
|
189
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
190
|
+
expect(i18n.ts('missing.key')).toBe('missing.key')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('should convert non-string values to string', async () => {
|
|
194
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
195
|
+
const translations: Translations = { count: 42 }
|
|
196
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
197
|
+
|
|
198
|
+
expect(i18n.ts('count')).toBe('42')
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('tc() method', () => {
|
|
203
|
+
test('should return defaultValue when count is undefined', () => {
|
|
204
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
205
|
+
expect(i18n.tc('apples', { other: 'params' }, 'No count')).toBe('No count')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('should use plural function with count', async () => {
|
|
209
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
210
|
+
const translations: Translations = { apples: 'apple|apples' }
|
|
211
|
+
await i18n['helper'].loadTranslations('en', translations)
|
|
212
|
+
|
|
213
|
+
// defaultPlural selects form by index: forms[count] or last form if count >= forms.length
|
|
214
|
+
// For 'apple|apples': forms[0]='apple', forms[1]='apples'
|
|
215
|
+
expect(i18n.tc('apples', 0)).toBe('apple')
|
|
216
|
+
expect(i18n.tc('apples', 1)).toBe('apples') // forms[1]
|
|
217
|
+
expect(i18n.tc('apples', 5)).toBe('apples') // last form
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('should handle count as number', async () => {
|
|
221
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
222
|
+
const translations: Translations = { apples: 'apple|apples' }
|
|
223
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
224
|
+
|
|
225
|
+
expect(i18n.tc('apples', 2)).toBe('apples')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('should handle count as Params object', async () => {
|
|
229
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
230
|
+
const translations: Translations = { apples: 'apple|apples' }
|
|
231
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
232
|
+
|
|
233
|
+
expect(i18n.tc('apples', { count: 2, name: 'John' })).toBe('apples')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('should return defaultValue when plural function returns null', () => {
|
|
237
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
238
|
+
// When translation is missing, t() returns key, which is passed to pluralFunc
|
|
239
|
+
// pluralFunc tries to process 'missing.key' as translation, but since it doesn't contain '|',
|
|
240
|
+
// it returns the key itself. So tc returns the key, not defaultValue.
|
|
241
|
+
// To test defaultValue, we need a case where pluralFunc actually returns null.
|
|
242
|
+
// This happens when translation exists but is empty or invalid.
|
|
243
|
+
expect(i18n.tc('missing.key', 1, 'Default')).toBe('missing.key')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('tn() method', () => {
|
|
248
|
+
test('should format number with default locale', () => {
|
|
249
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
250
|
+
const result = i18n.tn(1234.56)
|
|
251
|
+
expect(result).toMatch(/1[,.]234[.,]56/)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('should format number with custom options', () => {
|
|
255
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
256
|
+
const result = i18n.tn(1234.56, { style: 'currency', currency: 'USD' })
|
|
257
|
+
expect(result).toContain('1,234.56')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('should use current locale for formatting', () => {
|
|
261
|
+
const i18n = new TestI18n('ru', 'en', 'general')
|
|
262
|
+
const result = i18n.tn(1234.56)
|
|
263
|
+
// Russian locale uses different number formatting
|
|
264
|
+
expect(result).toBeDefined()
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('td() method', () => {
|
|
269
|
+
test('should format date with default locale', () => {
|
|
270
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
271
|
+
const date = new Date('2024-01-15')
|
|
272
|
+
const result = i18n.td(date)
|
|
273
|
+
expect(result).toBeDefined()
|
|
274
|
+
expect(result).not.toBe('Invalid Date')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('should format date with custom options', () => {
|
|
278
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
279
|
+
const date = new Date('2024-01-15')
|
|
280
|
+
const result = i18n.td(date, { year: 'numeric', month: 'long', day: 'numeric' })
|
|
281
|
+
expect(result).toContain('2024')
|
|
282
|
+
expect(result).toContain('January')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('should handle date as number (timestamp)', () => {
|
|
286
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
287
|
+
const timestamp = new Date('2024-01-15').getTime()
|
|
288
|
+
const result = i18n.td(timestamp)
|
|
289
|
+
expect(result).toBeDefined()
|
|
290
|
+
expect(result).not.toBe('Invalid Date')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('should handle date as string', () => {
|
|
294
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
295
|
+
const result = i18n.td('2024-01-15')
|
|
296
|
+
expect(result).toBeDefined()
|
|
297
|
+
expect(result).not.toBe('Invalid Date')
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('tdr() method', () => {
|
|
302
|
+
test('should format relative time', () => {
|
|
303
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
304
|
+
const yesterday = new Date(Date.now() - 86400000)
|
|
305
|
+
const result = i18n.tdr(yesterday)
|
|
306
|
+
expect(result).toBeDefined()
|
|
307
|
+
expect(result).toMatch(/day|ago/i)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('should format relative time with custom options', () => {
|
|
311
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
312
|
+
const yesterday = new Date(Date.now() - 86400000)
|
|
313
|
+
const result = i18n.tdr(yesterday, { numeric: 'always' })
|
|
314
|
+
expect(result).toBeDefined()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('should handle invalid date gracefully', () => {
|
|
318
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
319
|
+
const invalidDate = new Date('invalid')
|
|
320
|
+
const result = i18n.tdr(invalidDate)
|
|
321
|
+
expect(result).toBeDefined()
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('has() method', () => {
|
|
326
|
+
test('should return true when translation exists', async () => {
|
|
327
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
328
|
+
const translations: Translations = { greeting: 'Hello' }
|
|
329
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
330
|
+
|
|
331
|
+
expect(i18n.has('greeting')).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('should return false when translation does not exist', () => {
|
|
335
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
336
|
+
expect(i18n.has('missing.key')).toBe(false)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('should check route-specific translation when routeName is provided', async () => {
|
|
340
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
341
|
+
const routeTranslations: Translations = { title: 'Route Title' }
|
|
342
|
+
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
343
|
+
|
|
344
|
+
expect(i18n.has('title', 'about')).toBe(true)
|
|
345
|
+
expect(i18n.has('title', 'general')).toBe(false)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('should use current route when routeName is not provided', async () => {
|
|
349
|
+
const i18n = new TestI18n('en', 'en', 'about')
|
|
350
|
+
const routeTranslations: Translations = { title: 'Route Title' }
|
|
351
|
+
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
352
|
+
|
|
353
|
+
expect(i18n.has('title')).toBe(true)
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe('clearCache() method', () => {
|
|
358
|
+
test('should clear all translations from cache', async () => {
|
|
359
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
360
|
+
const translations: Translations = { greeting: 'Hello' }
|
|
361
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
362
|
+
|
|
363
|
+
expect(i18n.has('greeting')).toBe(true)
|
|
364
|
+
|
|
365
|
+
i18n.clearCache()
|
|
366
|
+
|
|
367
|
+
expect(i18n.has('greeting')).toBe(false)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('loadTranslationsCore() method', () => {
|
|
372
|
+
test('should load translations when merge is false', async () => {
|
|
373
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
374
|
+
const translations: Translations = { greeting: 'Hello' }
|
|
375
|
+
|
|
376
|
+
i18n['loadTranslationsCore']('en', translations, false)
|
|
377
|
+
// Wait for async operation to complete
|
|
378
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
379
|
+
|
|
380
|
+
expect(i18n.has('greeting')).toBe(true)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('should merge translations when merge is true', async () => {
|
|
384
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
385
|
+
const initial: Translations = { greeting: 'Hello' }
|
|
386
|
+
const additional: Translations = { farewell: 'Goodbye' }
|
|
387
|
+
|
|
388
|
+
await i18n['helper'].loadTranslations('en', initial)
|
|
389
|
+
i18n['loadTranslationsCore']('en', additional, true)
|
|
390
|
+
// Wait for async operation to complete
|
|
391
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
392
|
+
|
|
393
|
+
expect(i18n.has('greeting')).toBe(true)
|
|
394
|
+
expect(i18n.has('farewell')).toBe(true)
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
describe('loadRouteTranslationsCore() method', () => {
|
|
399
|
+
test('should load route translations when merge is false', async () => {
|
|
400
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
401
|
+
const translations: Translations = { title: 'Page Title' }
|
|
402
|
+
|
|
403
|
+
i18n['loadRouteTranslationsCore']('en', 'about', translations, false)
|
|
404
|
+
// Wait for async operation to complete
|
|
405
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
406
|
+
|
|
407
|
+
expect(i18n.has('title', 'about')).toBe(true)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('should merge route translations when merge is true', async () => {
|
|
411
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
412
|
+
const initial: Translations = { title: 'Page Title' }
|
|
413
|
+
const additional: Translations = { description: 'Page Description' }
|
|
414
|
+
|
|
415
|
+
await i18n['helper'].loadPageTranslations('en', 'about', initial)
|
|
416
|
+
i18n['loadRouteTranslationsCore']('en', 'about', additional, true)
|
|
417
|
+
// Wait for async operation to complete
|
|
418
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
419
|
+
|
|
420
|
+
expect(i18n.has('title', 'about')).toBe(true)
|
|
421
|
+
expect(i18n.has('description', 'about')).toBe(true)
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
describe('Edge cases', () => {
|
|
426
|
+
test('should handle nested translation keys', async () => {
|
|
427
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
428
|
+
const translations: Translations = {
|
|
429
|
+
nested: {
|
|
430
|
+
deep: {
|
|
431
|
+
key: 'Nested value',
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
436
|
+
|
|
437
|
+
expect(i18n.t('nested.deep.key')).toBe('Nested value')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('should handle multiple params in interpolation', async () => {
|
|
441
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
442
|
+
const translations: Translations = {
|
|
443
|
+
message: 'Hello, {name}! You are {age} years old.',
|
|
444
|
+
}
|
|
445
|
+
i18n['helper'].loadTranslations('en', translations)
|
|
446
|
+
|
|
447
|
+
expect(i18n.t('message', { name: 'John', age: 30 })).toBe('Hello, John! You are 30 years old.')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('should handle null defaultValue', () => {
|
|
451
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
452
|
+
expect(i18n.t('missing.key', undefined, null)).toBe('missing.key')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test('should handle empty string defaultValue', () => {
|
|
456
|
+
const i18n = new TestI18n('en', 'en', 'general')
|
|
457
|
+
// Empty string is falsy, so it will fall back to key (as per defaultValue || key logic)
|
|
458
|
+
expect(i18n.t('missing.key', undefined, '')).toBe('missing.key')
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('should handle route change', async () => {
|
|
462
|
+
const storage = { translations: new Map<string, Translations>() }
|
|
463
|
+
const i18n = new TestI18n('en', 'en', 'general', { storage })
|
|
464
|
+
const generalTranslations: Translations = { greeting: 'Hello' }
|
|
465
|
+
const routeTranslations: Translations = { title: 'About Page' }
|
|
466
|
+
|
|
467
|
+
await i18n['helper'].loadTranslations('en', generalTranslations)
|
|
468
|
+
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
469
|
+
|
|
470
|
+
// Verify translations are loaded
|
|
471
|
+
expect(i18n.has('greeting')).toBe(true)
|
|
472
|
+
expect(i18n.has('title', 'about')).toBe(true)
|
|
473
|
+
|
|
474
|
+
// Check route-specific translation with explicit routeName
|
|
475
|
+
// Note: getTranslation uses routeName parameter, so this should work
|
|
476
|
+
const titleValue = i18n.t('title', undefined, undefined, 'about')
|
|
477
|
+
expect(titleValue).toBe('About Page')
|
|
478
|
+
|
|
479
|
+
// Change route and check
|
|
480
|
+
i18n.setRoute('about')
|
|
481
|
+
expect(i18n.t('title')).toBe('About Page')
|
|
482
|
+
expect(i18n.t('greeting')).toBe('Hello') // Should still work from general
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
})
|
|
@@ -13,7 +13,6 @@ describe('RouteService', () => {
|
|
|
13
13
|
let routeService: RouteService
|
|
14
14
|
let mockRouter: jest.Mocked<Router>
|
|
15
15
|
let mockI18nConfig: ModuleOptionsExtend
|
|
16
|
-
let setCookieMock: jest.Mock
|
|
17
16
|
let navigateToMock: jest.Mock
|
|
18
17
|
|
|
19
18
|
beforeEach(() => {
|
|
@@ -47,20 +46,15 @@ describe('RouteService', () => {
|
|
|
47
46
|
hashMode: false,
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
// Mock setCookie and navigateTo
|
|
51
|
-
setCookieMock = jest.fn()
|
|
52
49
|
navigateToMock = jest.fn()
|
|
53
50
|
|
|
54
|
-
// Initialize RouteService
|
|
51
|
+
// Initialize RouteService (no setCookie - plugin handles cookies)
|
|
55
52
|
routeService = new RouteService(
|
|
56
53
|
mockI18nConfig,
|
|
57
54
|
mockRouter,
|
|
58
55
|
null,
|
|
59
56
|
null,
|
|
60
57
|
navigateToMock,
|
|
61
|
-
setCookieMock,
|
|
62
|
-
null,
|
|
63
|
-
null,
|
|
64
58
|
)
|
|
65
59
|
})
|
|
66
60
|
|
|
@@ -71,14 +65,14 @@ describe('RouteService', () => {
|
|
|
71
65
|
|
|
72
66
|
test('getCurrentLocale should return the hash locale if hashMode is enabled', () => {
|
|
73
67
|
const mockI18nConfigWithHashMode = { ...mockI18nConfig, hashMode: true }
|
|
74
|
-
routeService = new RouteService(mockI18nConfigWithHashMode, mockRouter, 'de', null, navigateToMock
|
|
68
|
+
routeService = new RouteService(mockI18nConfigWithHashMode, mockRouter, 'de', null, navigateToMock)
|
|
75
69
|
const locale = routeService.getCurrentLocale()
|
|
76
70
|
expect(locale).toBe('de')
|
|
77
71
|
})
|
|
78
72
|
|
|
79
73
|
test('getCurrentLocale should return the noPrefix locale if noPrefix strategy is enabled', () => {
|
|
80
74
|
const mockI18nConfigWithNoPrefix: ModuleOptionsExtend = { ...mockI18nConfig, strategy: 'no_prefix' }
|
|
81
|
-
routeService = new RouteService(mockI18nConfigWithNoPrefix, mockRouter, null, 'ru', navigateToMock
|
|
75
|
+
routeService = new RouteService(mockI18nConfigWithNoPrefix, mockRouter, null, 'ru', navigateToMock)
|
|
82
76
|
const locale = routeService.getCurrentLocale()
|
|
83
77
|
expect(locale).toBe('ru')
|
|
84
78
|
})
|
|
@@ -92,7 +86,7 @@ describe('RouteService', () => {
|
|
|
92
86
|
{ code: 'ru', iso: 'ru-RU', displayName: 'Russian' },
|
|
93
87
|
],
|
|
94
88
|
}
|
|
95
|
-
routeService = new RouteService(mockI18nConfigWithDisplayName, mockRouter, null, null, navigateToMock
|
|
89
|
+
routeService = new RouteService(mockI18nConfigWithDisplayName, mockRouter, null, null, navigateToMock)
|
|
96
90
|
const route = { name: 'localized-about-en' } as RouteLocationNormalizedLoaded
|
|
97
91
|
const displayName = routeService.getCurrentName(route)
|
|
98
92
|
expect(displayName).toBe('English')
|
|
@@ -157,25 +151,6 @@ describe('RouteService', () => {
|
|
|
157
151
|
expect(fullPath).toBe('https://example.com/de/about')
|
|
158
152
|
})
|
|
159
153
|
|
|
160
|
-
test('updateCookies should update cookies for hashMode and noPrefixStrategy', () => {
|
|
161
|
-
const mockI18nConfigWithHashMode: ModuleOptionsExtend = {
|
|
162
|
-
...mockI18nConfig,
|
|
163
|
-
hashMode: true,
|
|
164
|
-
strategy: 'prefix_except_default',
|
|
165
|
-
}
|
|
166
|
-
routeService = new RouteService(mockI18nConfigWithHashMode, mockRouter, 'de', null, navigateToMock, setCookieMock, null, null)
|
|
167
|
-
routeService.updateCookies('de')
|
|
168
|
-
expect(setCookieMock).toHaveBeenCalledWith('hash-locale', 'de')
|
|
169
|
-
|
|
170
|
-
const mockI18nConfigWithNoPrefix: ModuleOptionsExtend = {
|
|
171
|
-
...mockI18nConfig,
|
|
172
|
-
strategy: 'no_prefix',
|
|
173
|
-
}
|
|
174
|
-
routeService = new RouteService(mockI18nConfigWithNoPrefix, mockRouter, null, 'ru', navigateToMock, setCookieMock, null, null)
|
|
175
|
-
routeService.updateCookies('ru')
|
|
176
|
-
expect(setCookieMock).toHaveBeenCalledWith('no-prefix-locale', 'ru')
|
|
177
|
-
})
|
|
178
|
-
|
|
179
154
|
test('resolveLocalizedRoute should return the correct localized route', () => {
|
|
180
155
|
const to = { path: '/about' } as RouteLocationAsPathGeneric
|
|
181
156
|
mockRouter.resolve.mockReturnValueOnce({
|
|
@@ -209,21 +184,38 @@ describe('RouteService', () => {
|
|
|
209
184
|
expect(locale).toBe('ru') // Should extract from URL
|
|
210
185
|
})
|
|
211
186
|
|
|
212
|
-
test('getCurrentLocale should
|
|
187
|
+
test('getCurrentLocale should return defaultLocale for prefix_except_default when URL has no locale prefix', () => {
|
|
213
188
|
routeService = new RouteService(
|
|
214
|
-
mockI18nConfig,
|
|
189
|
+
mockI18nConfig, // strategy: prefix_except_default
|
|
215
190
|
mockRouter,
|
|
216
191
|
null,
|
|
217
192
|
null,
|
|
218
193
|
navigateToMock,
|
|
219
|
-
|
|
220
|
-
'de', // cookie locale
|
|
221
|
-
'user-locale', // cookie name
|
|
194
|
+
() => 'de', // getter from plugin (cookie/state)
|
|
222
195
|
)
|
|
223
196
|
mockRouter.currentRoute.value.params = {} // Remove locale from params
|
|
224
197
|
mockRouter.currentRoute.value.path = '/' // No locale in path
|
|
225
198
|
const locale = routeService.getCurrentLocale()
|
|
226
|
-
|
|
199
|
+
// For prefix_except_default: URL without locale = defaultLocale (SEO requirement)
|
|
200
|
+
// Getter is NOT used because URL explicitly indicates defaultLocale
|
|
201
|
+
expect(locale).toBe('en')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('getCurrentLocale should use getDefaultLocale for no_prefix strategy', () => {
|
|
205
|
+
const noPrefixConfig = { ...mockI18nConfig, strategy: 'no_prefix' as const }
|
|
206
|
+
routeService = new RouteService(
|
|
207
|
+
noPrefixConfig,
|
|
208
|
+
mockRouter,
|
|
209
|
+
null,
|
|
210
|
+
'de', // noPrefixDefault
|
|
211
|
+
navigateToMock,
|
|
212
|
+
() => 'de', // getter from plugin (cookie/state)
|
|
213
|
+
)
|
|
214
|
+
mockRouter.currentRoute.value.params = {} // Remove locale from params
|
|
215
|
+
mockRouter.currentRoute.value.path = '/' // No locale in path
|
|
216
|
+
const locale = routeService.getCurrentLocale()
|
|
217
|
+
// For no_prefix: getter/noPrefixDefault determines locale
|
|
218
|
+
expect(locale).toBe('de')
|
|
227
219
|
})
|
|
228
220
|
|
|
229
221
|
test('getCurrentLocale should prioritize route params over URL path', () => {
|
|
@@ -233,36 +225,19 @@ describe('RouteService', () => {
|
|
|
233
225
|
expect(locale).toBe('de') // Should use route params
|
|
234
226
|
})
|
|
235
227
|
|
|
236
|
-
test('getCurrentLocale should prioritize URL path over
|
|
228
|
+
test('getCurrentLocale should prioritize URL path over getDefaultLocale', () => {
|
|
237
229
|
routeService = new RouteService(
|
|
238
230
|
mockI18nConfig,
|
|
239
231
|
mockRouter,
|
|
240
232
|
null,
|
|
241
233
|
null,
|
|
242
234
|
navigateToMock,
|
|
243
|
-
|
|
244
|
-
'de', // cookie locale
|
|
245
|
-
'user-locale', // cookie name
|
|
235
|
+
() => 'de', // getter
|
|
246
236
|
)
|
|
247
237
|
mockRouter.currentRoute.value.params = {} // No locale in params
|
|
248
238
|
mockRouter.currentRoute.value.path = '/ru/sdfsdf' // URL has locale
|
|
249
239
|
const locale = routeService.getCurrentLocale()
|
|
250
|
-
expect(locale).toBe('ru') // Should use URL path, not
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
test('updateCookies should update cookie for regular strategy', () => {
|
|
254
|
-
routeService = new RouteService(
|
|
255
|
-
mockI18nConfig,
|
|
256
|
-
mockRouter,
|
|
257
|
-
null,
|
|
258
|
-
null,
|
|
259
|
-
navigateToMock,
|
|
260
|
-
setCookieMock,
|
|
261
|
-
null,
|
|
262
|
-
'user-locale', // cookie name
|
|
263
|
-
)
|
|
264
|
-
routeService.updateCookies('ru')
|
|
265
|
-
expect(setCookieMock).toHaveBeenCalledWith('user-locale', 'ru')
|
|
240
|
+
expect(locale).toBe('ru') // Should use URL path, not getter
|
|
266
241
|
})
|
|
267
242
|
|
|
268
243
|
test('getCurrentName should return null if displayName is missing', () => {
|
|
@@ -274,7 +249,7 @@ describe('RouteService', () => {
|
|
|
274
249
|
{ code: 'ru', iso: 'ru-RU' },
|
|
275
250
|
],
|
|
276
251
|
}
|
|
277
|
-
routeService = new RouteService(mockI18nConfigWithoutDisplayName, mockRouter, null, null, navigateToMock
|
|
252
|
+
routeService = new RouteService(mockI18nConfigWithoutDisplayName, mockRouter, null, null, navigateToMock)
|
|
278
253
|
const route = { name: 'localized-about-en' } as RouteLocationNormalizedLoaded
|
|
279
254
|
const displayName = routeService.getCurrentName(route)
|
|
280
255
|
expect(displayName).toBeNull()
|
|
@@ -350,11 +325,6 @@ describe('RouteService', () => {
|
|
|
350
325
|
})
|
|
351
326
|
})
|
|
352
327
|
|
|
353
|
-
test('updateCookies should not update cookies if hashMode and noPrefixStrategy are disabled', () => {
|
|
354
|
-
routeService.updateCookies('de')
|
|
355
|
-
expect(setCookieMock).not.toHaveBeenCalled()
|
|
356
|
-
})
|
|
357
|
-
|
|
358
328
|
test('getCurrentLocale should extract locale from fullPath with query params when route.path is missing', () => {
|
|
359
329
|
mockRouter.currentRoute.value.params = {}
|
|
360
330
|
mockRouter.currentRoute.value.path = '' // Simulate missing path (e.g. error state)
|