transduck 0.7.0 → 0.9.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/cli.js +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -0
- package/dist/handler.d.ts +9 -0
- package/dist/handler.js +70 -31
- package/dist/react/provider.d.ts +8 -3
- package/dist/react/provider.js +51 -22
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/config.ts +9 -0
- package/src/handler.ts +162 -117
- package/src/react/provider.tsx +64 -22
- package/tests/ait.test.ts +1 -0
- package/tests/backend.test.ts +1 -0
- package/tests/config.test.ts +26 -0
- package/tests/handler.test.ts +170 -7
- package/tests/hooks.test.ts +1 -0
- package/tests/providers.test.ts +1 -0
- package/tests/react-provider.test.tsx +204 -22
package/tests/handler.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
5
|
|
|
6
6
|
// We test the handler logic directly, not via HTTP
|
|
7
|
-
import { handleTranslationRequest, _resetHandlerStore } from '../src/handler.js';
|
|
7
|
+
import { handleTranslationRequest, _resetHandlerStore, createSemaphore } from '../src/handler.js';
|
|
8
8
|
|
|
9
9
|
function makeTmpDir(): string {
|
|
10
10
|
return mkdtempSync(join(tmpdir(), 'transduck-handler-test-'));
|
|
@@ -63,7 +63,7 @@ describe('handleTranslationRequest', () => {
|
|
|
63
63
|
plurals: [],
|
|
64
64
|
}, configPath);
|
|
65
65
|
|
|
66
|
-
expect(result.translations['Hello
|
|
66
|
+
expect(result.translations['Hello||||EN']).toBe('Hallo');
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
it('returns empty translations for uncached strings without API key', async () => {
|
|
@@ -75,7 +75,7 @@ describe('handleTranslationRequest', () => {
|
|
|
75
75
|
}, configPath);
|
|
76
76
|
|
|
77
77
|
// Without API key, backend will fail, should return source text
|
|
78
|
-
expect(result.translations['Unknown
|
|
78
|
+
expect(result.translations['Unknown||||EN']).toBe('Unknown');
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it('handles context in cache key', async () => {
|
|
@@ -98,7 +98,7 @@ describe('handleTranslationRequest', () => {
|
|
|
98
98
|
plurals: [],
|
|
99
99
|
}, configPath);
|
|
100
100
|
|
|
101
|
-
expect(result.translations['Book||Hotel booking']).toBe('Buchen');
|
|
101
|
+
expect(result.translations['Book||Hotel booking||EN']).toBe('Buchen');
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it('returns plural forms from cache', async () => {
|
|
@@ -129,7 +129,7 @@ describe('handleTranslationRequest', () => {
|
|
|
129
129
|
plurals: [{ one: '{count} item', other: '{count} items' }],
|
|
130
130
|
}, configPath);
|
|
131
131
|
|
|
132
|
-
const pluralKey = '{count} item\x00{count} items
|
|
132
|
+
const pluralKey = '{count} item\x00{count} items||||EN';
|
|
133
133
|
expect(result.plurals[pluralKey]).toBeDefined();
|
|
134
134
|
expect(result.plurals[pluralKey].one).toBe('{count} Artikel');
|
|
135
135
|
});
|
|
@@ -157,7 +157,7 @@ describe('handleTranslationRequest', () => {
|
|
|
157
157
|
plurals: [],
|
|
158
158
|
}, configPath);
|
|
159
159
|
|
|
160
|
-
expect(result.translations['Bonjour
|
|
160
|
+
expect(result.translations['Bonjour||||FR']).toBe('Hallo');
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
it('per-item sourceLang overrides body-level sourceLang', async () => {
|
|
@@ -181,6 +181,169 @@ describe('handleTranslationRequest', () => {
|
|
|
181
181
|
plurals: [],
|
|
182
182
|
}, configPath);
|
|
183
183
|
|
|
184
|
-
expect(result.translations['Hola
|
|
184
|
+
expect(result.translations['Hola||||ES']).toBe('Hallo');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('createSemaphore', () => {
|
|
189
|
+
it('bounds concurrent execution to the configured limit', async () => {
|
|
190
|
+
const sem = createSemaphore(3);
|
|
191
|
+
let active = 0;
|
|
192
|
+
let peak = 0;
|
|
193
|
+
|
|
194
|
+
const workers = Array.from({ length: 12 }, (_, i) =>
|
|
195
|
+
(async () => {
|
|
196
|
+
await sem.acquire();
|
|
197
|
+
active++;
|
|
198
|
+
peak = Math.max(peak, active);
|
|
199
|
+
await new Promise(r => setTimeout(r, 20));
|
|
200
|
+
active--;
|
|
201
|
+
sem.release();
|
|
202
|
+
return i;
|
|
203
|
+
})()
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const results = await Promise.all(workers);
|
|
207
|
+
expect(peak).toBe(3);
|
|
208
|
+
expect(results).toHaveLength(12);
|
|
209
|
+
expect(active).toBe(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('processes all work items even when queue fills', async () => {
|
|
213
|
+
const sem = createSemaphore(2);
|
|
214
|
+
const order: number[] = [];
|
|
215
|
+
const workers = Array.from({ length: 6 }, (_, i) =>
|
|
216
|
+
(async () => {
|
|
217
|
+
await sem.acquire();
|
|
218
|
+
await new Promise(r => setTimeout(r, 5));
|
|
219
|
+
order.push(i);
|
|
220
|
+
sem.release();
|
|
221
|
+
})()
|
|
222
|
+
);
|
|
223
|
+
await Promise.all(workers);
|
|
224
|
+
expect(order).toHaveLength(6);
|
|
225
|
+
expect(new Set(order).size).toBe(6);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('handleTranslationRequest parallelism', () => {
|
|
230
|
+
let tmpDir: string;
|
|
231
|
+
let configPath: string;
|
|
232
|
+
|
|
233
|
+
function writeConfigWithCap(dir: string, cap: number): string {
|
|
234
|
+
const p = join(dir, 'transduck.yaml');
|
|
235
|
+
writeFileSync(p, `
|
|
236
|
+
project:
|
|
237
|
+
name: test-project
|
|
238
|
+
context: "A test site"
|
|
239
|
+
languages:
|
|
240
|
+
source: EN
|
|
241
|
+
targets:
|
|
242
|
+
- DE
|
|
243
|
+
storage:
|
|
244
|
+
path: ./translations.db
|
|
245
|
+
backend:
|
|
246
|
+
api_key_env: OPENAI_API_KEY
|
|
247
|
+
model: gpt-4.1-mini
|
|
248
|
+
timeout_seconds: 10
|
|
249
|
+
max_retries: 2
|
|
250
|
+
max_concurrency: ${cap}
|
|
251
|
+
`);
|
|
252
|
+
return p;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
_resetHandlerStore();
|
|
257
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'transduck-handler-par-'));
|
|
258
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('parallelizes string translations bounded by max_concurrency', async () => {
|
|
262
|
+
configPath = writeConfigWithCap(tmpDir, 5);
|
|
263
|
+
|
|
264
|
+
let active = 0;
|
|
265
|
+
let peak = 0;
|
|
266
|
+
const backend = await import('../src/backend.js');
|
|
267
|
+
vi.spyOn(backend, 'translate').mockImplementation(async (text: string) => {
|
|
268
|
+
active++;
|
|
269
|
+
peak = Math.max(peak, active);
|
|
270
|
+
await new Promise(r => setTimeout(r, 20));
|
|
271
|
+
active--;
|
|
272
|
+
return `translated-${text}`;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const strings = Array.from({ length: 20 }, (_, i) => ({ text: `s${i}` }));
|
|
276
|
+
const result = await handleTranslationRequest(
|
|
277
|
+
{ language: 'DE', strings, plurals: [] },
|
|
278
|
+
configPath,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(peak).toBe(5);
|
|
282
|
+
expect(Object.keys(result.translations)).toHaveLength(20);
|
|
283
|
+
for (let i = 0; i < 20; i++) {
|
|
284
|
+
expect(result.translations[`s${i}||||EN`]).toBe(`translated-s${i}`);
|
|
285
|
+
}
|
|
286
|
+
vi.restoreAllMocks();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('shares the semaphore across strings and plurals', async () => {
|
|
290
|
+
configPath = writeConfigWithCap(tmpDir, 4);
|
|
291
|
+
|
|
292
|
+
let active = 0;
|
|
293
|
+
let peak = 0;
|
|
294
|
+
const backend = await import('../src/backend.js');
|
|
295
|
+
vi.spyOn(backend, 'translate').mockImplementation(async (text: string) => {
|
|
296
|
+
active++;
|
|
297
|
+
peak = Math.max(peak, active);
|
|
298
|
+
await new Promise(r => setTimeout(r, 20));
|
|
299
|
+
active--;
|
|
300
|
+
return `t-${text}`;
|
|
301
|
+
});
|
|
302
|
+
vi.spyOn(backend, 'translatePlural').mockImplementation(async (one: string, other: string) => {
|
|
303
|
+
active++;
|
|
304
|
+
peak = Math.max(peak, active);
|
|
305
|
+
await new Promise(r => setTimeout(r, 20));
|
|
306
|
+
active--;
|
|
307
|
+
return { one: `t-${one}`, other: `t-${other}` };
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const strings = Array.from({ length: 10 }, (_, i) => ({ text: `s${i}` }));
|
|
311
|
+
const plurals = Array.from({ length: 10 }, (_, i) => ({
|
|
312
|
+
one: `${i}-one`, other: `${i}-other`,
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
const result = await handleTranslationRequest(
|
|
316
|
+
{ language: 'DE', strings, plurals },
|
|
317
|
+
configPath,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
expect(peak).toBe(4);
|
|
321
|
+
expect(Object.keys(result.translations)).toHaveLength(10);
|
|
322
|
+
expect(Object.keys(result.plurals)).toHaveLength(10);
|
|
323
|
+
vi.restoreAllMocks();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('one backend failure does not affect sibling items', async () => {
|
|
327
|
+
configPath = writeConfigWithCap(tmpDir, 3);
|
|
328
|
+
|
|
329
|
+
const backend = await import('../src/backend.js');
|
|
330
|
+
vi.spyOn(backend, 'translate').mockImplementation(async (text: string) => {
|
|
331
|
+
if (text === 'boom') throw new Error('backend exploded');
|
|
332
|
+
return `t-${text}`;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = await handleTranslationRequest(
|
|
336
|
+
{
|
|
337
|
+
language: 'DE',
|
|
338
|
+
strings: [{ text: 'ok1' }, { text: 'boom' }, { text: 'ok2' }],
|
|
339
|
+
plurals: [],
|
|
340
|
+
},
|
|
341
|
+
configPath,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(result.translations['ok1||||EN']).toBe('t-ok1');
|
|
345
|
+
expect(result.translations['ok2||||EN']).toBe('t-ok2');
|
|
346
|
+
expect(result.translations['boom||||EN']).toBe('boom'); // fallback to source
|
|
347
|
+
vi.restoreAllMocks();
|
|
185
348
|
});
|
|
186
349
|
});
|
package/tests/hooks.test.ts
CHANGED
package/tests/providers.test.ts
CHANGED
|
@@ -33,7 +33,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
33
33
|
it('t() returns source text before translation loads', () => {
|
|
34
34
|
mockFetch.mockResolvedValue({
|
|
35
35
|
ok: true,
|
|
36
|
-
json: async () => ({ translations: { 'Hello
|
|
36
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
function TestComp() {
|
|
@@ -94,7 +94,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
94
94
|
mockFetch.mockResolvedValue({
|
|
95
95
|
ok: true,
|
|
96
96
|
json: async () => ({
|
|
97
|
-
translations: { 'Hello
|
|
97
|
+
translations: { 'Hello||||EN': 'Hallo', 'World||||EN': 'Welt' },
|
|
98
98
|
plurals: {},
|
|
99
99
|
}),
|
|
100
100
|
});
|
|
@@ -129,7 +129,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
129
129
|
mockFetch.mockResolvedValue({
|
|
130
130
|
ok: true,
|
|
131
131
|
json: async () => ({
|
|
132
|
-
translations: { 'Hello
|
|
132
|
+
translations: { 'Hello||||EN': 'Hallo' },
|
|
133
133
|
plurals: {},
|
|
134
134
|
}),
|
|
135
135
|
});
|
|
@@ -165,7 +165,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
165
165
|
it('t() tracks called keys in _state.knownKeys', async () => {
|
|
166
166
|
mockFetch.mockResolvedValue({
|
|
167
167
|
ok: true,
|
|
168
|
-
json: async () => ({ translations: { 'Hello
|
|
168
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
169
169
|
});
|
|
170
170
|
|
|
171
171
|
function TestComp() {
|
|
@@ -180,7 +180,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
180
180
|
);
|
|
181
181
|
|
|
182
182
|
const state = _getReactState();
|
|
183
|
-
expect(state.knownKeys.has('Hello
|
|
183
|
+
expect(state.knownKeys.has('Hello||||EN')).toBe(true);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
it('useTransDuck() returns t, tPlural, ait, aitPlural, language, setLanguage, isLoading', () => {
|
|
@@ -211,12 +211,12 @@ describe('TransDuckProvider + t()', () => {
|
|
|
211
211
|
// First render in DE
|
|
212
212
|
mockFetch.mockResolvedValueOnce({
|
|
213
213
|
ok: true,
|
|
214
|
-
json: async () => ({ translations: { 'Hello
|
|
214
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
215
215
|
});
|
|
216
216
|
// After switch to ES
|
|
217
217
|
mockFetch.mockResolvedValueOnce({
|
|
218
218
|
ok: true,
|
|
219
|
-
json: async () => ({ translations: { 'Hello
|
|
219
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
let hookRef: UseTransDuckReturn | null = null;
|
|
@@ -254,7 +254,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
254
254
|
it('setLanguage() with current language is a no-op', async () => {
|
|
255
255
|
mockFetch.mockResolvedValueOnce({
|
|
256
256
|
ok: true,
|
|
257
|
-
json: async () => ({ translations: { 'Hello
|
|
257
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
let hookRef: UseTransDuckReturn | null = null;
|
|
@@ -288,7 +288,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
288
288
|
it('setLanguage() to source language skips fetch', async () => {
|
|
289
289
|
mockFetch.mockResolvedValueOnce({
|
|
290
290
|
ok: true,
|
|
291
|
-
json: async () => ({ translations: { 'Hello
|
|
291
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
292
292
|
});
|
|
293
293
|
|
|
294
294
|
let hookRef: UseTransDuckReturn | null = null;
|
|
@@ -324,7 +324,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
324
324
|
it('isLoading is true during language switch fetch', async () => {
|
|
325
325
|
mockFetch.mockResolvedValueOnce({
|
|
326
326
|
ok: true,
|
|
327
|
-
json: async () => ({ translations: { 'Hello
|
|
327
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
328
328
|
});
|
|
329
329
|
|
|
330
330
|
let resolveSecondFetch: ((value: unknown) => void) | null = null;
|
|
@@ -359,7 +359,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
359
359
|
await act(async () => {
|
|
360
360
|
resolveSecondFetch!({
|
|
361
361
|
ok: true,
|
|
362
|
-
json: async () => ({ translations: { 'Hello
|
|
362
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
|
|
363
363
|
});
|
|
364
364
|
});
|
|
365
365
|
|
|
@@ -372,7 +372,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
372
372
|
it('isLoading clears on fetch error during language switch', async () => {
|
|
373
373
|
mockFetch.mockResolvedValueOnce({
|
|
374
374
|
ok: true,
|
|
375
|
-
json: async () => ({ translations: { 'Hello
|
|
375
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
376
376
|
});
|
|
377
377
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
378
378
|
|
|
@@ -406,7 +406,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
406
406
|
it('discards stale fetch response when language changes during fetch', async () => {
|
|
407
407
|
mockFetch.mockResolvedValueOnce({
|
|
408
408
|
ok: true,
|
|
409
|
-
json: async () => ({ translations: { 'Hello
|
|
409
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
410
410
|
});
|
|
411
411
|
|
|
412
412
|
let resolveFRFetch: ((value: unknown) => void) | null = null;
|
|
@@ -416,7 +416,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
416
416
|
|
|
417
417
|
mockFetch.mockResolvedValueOnce({
|
|
418
418
|
ok: true,
|
|
419
|
-
json: async () => ({ translations: { 'Hello
|
|
419
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
|
|
420
420
|
});
|
|
421
421
|
|
|
422
422
|
let hookRef: UseTransDuckReturn | null = null;
|
|
@@ -452,7 +452,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
452
452
|
await act(async () => {
|
|
453
453
|
resolveFRFetch!({
|
|
454
454
|
ok: true,
|
|
455
|
-
json: async () => ({ translations: { 'Hello
|
|
455
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Bonjour' }, plurals: {} }),
|
|
456
456
|
});
|
|
457
457
|
});
|
|
458
458
|
|
|
@@ -463,11 +463,11 @@ describe('TransDuckProvider + t()', () => {
|
|
|
463
463
|
it('changing language prop triggers switchLanguage with isLoading', async () => {
|
|
464
464
|
mockFetch.mockResolvedValueOnce({
|
|
465
465
|
ok: true,
|
|
466
|
-
json: async () => ({ translations: { 'Hello
|
|
466
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
467
467
|
});
|
|
468
468
|
mockFetch.mockResolvedValueOnce({
|
|
469
469
|
ok: true,
|
|
470
|
-
json: async () => ({ translations: { 'Hello
|
|
470
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
|
|
471
471
|
});
|
|
472
472
|
|
|
473
473
|
let hookRef: UseTransDuckReturn | null = null;
|
|
@@ -503,7 +503,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
503
503
|
it('t() returns a TranslationResult with pending=true on cache miss', () => {
|
|
504
504
|
mockFetch.mockResolvedValue({
|
|
505
505
|
ok: true,
|
|
506
|
-
json: async () => ({ translations: { 'Hello
|
|
506
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
507
507
|
});
|
|
508
508
|
|
|
509
509
|
let captured: ReturnType<typeof t> | null = null;
|
|
@@ -529,7 +529,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
529
529
|
it('t() returns a TranslationResult with pending=false on cache hit', async () => {
|
|
530
530
|
mockFetch.mockResolvedValue({
|
|
531
531
|
ok: true,
|
|
532
|
-
json: async () => ({ translations: { 'Hello
|
|
532
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
533
533
|
});
|
|
534
534
|
|
|
535
535
|
let captured: ReturnType<typeof t> | null = null;
|
|
@@ -599,7 +599,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
599
599
|
});
|
|
600
600
|
|
|
601
601
|
it('tPlural() returns a TranslationResult with pending=false on cache hit', async () => {
|
|
602
|
-
const pluralKey = '{count} message\x00{count} messages
|
|
602
|
+
const pluralKey = '{count} message\x00{count} messages||||EN';
|
|
603
603
|
mockFetch.mockResolvedValue({
|
|
604
604
|
ok: true,
|
|
605
605
|
json: async () => ({
|
|
@@ -633,7 +633,7 @@ describe('TransDuckProvider + t()', () => {
|
|
|
633
633
|
it('fetch body includes sourceLang from provider prop', async () => {
|
|
634
634
|
mockFetch.mockResolvedValue({
|
|
635
635
|
ok: true,
|
|
636
|
-
json: async () => ({ translations: { 'Hello
|
|
636
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
637
637
|
});
|
|
638
638
|
|
|
639
639
|
function TestComp() {
|
|
@@ -656,10 +656,192 @@ describe('TransDuckProvider + t()', () => {
|
|
|
656
656
|
expect(body.language).toBe('DE');
|
|
657
657
|
});
|
|
658
658
|
|
|
659
|
+
it('t() with per-call sourceLang option uses it instead of provider sourceLang', async () => {
|
|
660
|
+
// POS case: menu items in ES, allergens in EN, user target DE.
|
|
661
|
+
// Both t() calls run in the same <TransDuckProvider sourceLang="ES">.
|
|
662
|
+
mockFetch.mockResolvedValue({
|
|
663
|
+
ok: true,
|
|
664
|
+
json: async () => ({
|
|
665
|
+
translations: {
|
|
666
|
+
'Gambas||||ES': 'Garnelen',
|
|
667
|
+
'Contains nuts||||EN': 'Enthält Nüsse',
|
|
668
|
+
},
|
|
669
|
+
plurals: {},
|
|
670
|
+
}),
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
function TestComp() {
|
|
674
|
+
useTransDuck();
|
|
675
|
+
return (
|
|
676
|
+
<div>
|
|
677
|
+
<span data-testid="dish">{t('Gambas')}</span>
|
|
678
|
+
<span data-testid="allergen">{t('Contains nuts', { sourceLang: 'EN' })}</span>
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
render(
|
|
684
|
+
<TransDuckProvider language="DE" sourceLang="ES">
|
|
685
|
+
<TestComp />
|
|
686
|
+
</TransDuckProvider>
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
await waitFor(() => {
|
|
690
|
+
expect(screen.getByTestId('dish').textContent).toBe('Garnelen');
|
|
691
|
+
expect(screen.getByTestId('allergen').textContent).toBe('Enthält Nüsse');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// Fetch body contains per-item sourceLang for the allergen.
|
|
695
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
696
|
+
const dish = body.strings.find((s: any) => s.text === 'Gambas');
|
|
697
|
+
const allergen = body.strings.find((s: any) => s.text === 'Contains nuts');
|
|
698
|
+
expect(dish.sourceLang).toBe('ES');
|
|
699
|
+
expect(allergen.sourceLang).toBe('EN');
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('t() same text under different sourceLang does not collide in cache', async () => {
|
|
703
|
+
// Two menu entries with identical source text but different source langs
|
|
704
|
+
// must resolve to distinct cache entries and independent translations.
|
|
705
|
+
mockFetch.mockResolvedValue({
|
|
706
|
+
ok: true,
|
|
707
|
+
json: async () => ({
|
|
708
|
+
translations: {
|
|
709
|
+
'Salat||||DE': 'Salad', // German menu item → English
|
|
710
|
+
'Salat||||NL': 'Lettuce', // Dutch menu item → English
|
|
711
|
+
},
|
|
712
|
+
plurals: {},
|
|
713
|
+
}),
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
function TestComp() {
|
|
717
|
+
useTransDuck();
|
|
718
|
+
return (
|
|
719
|
+
<div>
|
|
720
|
+
<span data-testid="de">{t('Salat', { sourceLang: 'DE' })}</span>
|
|
721
|
+
<span data-testid="nl">{t('Salat', { sourceLang: 'NL' })}</span>
|
|
722
|
+
</div>
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
render(
|
|
727
|
+
<TransDuckProvider language="EN" sourceLang="EN">
|
|
728
|
+
<TestComp />
|
|
729
|
+
</TransDuckProvider>
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
await waitFor(() => {
|
|
733
|
+
expect(screen.getByTestId('de').textContent).toBe('Salad');
|
|
734
|
+
expect(screen.getByTestId('nl').textContent).toBe('Lettuce');
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('t() same-language short-circuit respects per-call sourceLang', () => {
|
|
739
|
+
// Provider is ES→DE, but this call marks its text as EN source.
|
|
740
|
+
// Target language is DE, so this translation IS needed even though
|
|
741
|
+
// provider.sourceLang === 'ES'. No same-language short-circuit.
|
|
742
|
+
let captured: ReturnType<typeof t> | null = null;
|
|
743
|
+
function TestComp() {
|
|
744
|
+
useTransDuck();
|
|
745
|
+
captured = t('Contains nuts', { sourceLang: 'EN' });
|
|
746
|
+
return <span>{captured}</span>;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
render(
|
|
750
|
+
<TransDuckProvider language="DE" sourceLang="ES">
|
|
751
|
+
<TestComp />
|
|
752
|
+
</TransDuckProvider>
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
expect(captured!.pending).toBe(true);
|
|
756
|
+
expect(captured!.sourceLang).toBe('EN');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('t() short-circuits when per-call sourceLang equals target language', () => {
|
|
760
|
+
// Provider ES→EN. Allergen is EN source, target is EN: no translation.
|
|
761
|
+
let captured: ReturnType<typeof t> | null = null;
|
|
762
|
+
function TestComp() {
|
|
763
|
+
useTransDuck();
|
|
764
|
+
captured = t('Contains nuts', { sourceLang: 'EN' });
|
|
765
|
+
return <span>{captured}</span>;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
render(
|
|
769
|
+
<TransDuckProvider language="EN" sourceLang="ES">
|
|
770
|
+
<TestComp />
|
|
771
|
+
</TransDuckProvider>
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
expect(captured!.pending).toBe(false);
|
|
775
|
+
expect(captured!.toString()).toBe('Contains nuts');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('t() options form supports context and vars', async () => {
|
|
779
|
+
mockFetch.mockResolvedValue({
|
|
780
|
+
ok: true,
|
|
781
|
+
json: async () => ({
|
|
782
|
+
translations: { 'Book||hotel booking||EN': 'Buchen {name}' },
|
|
783
|
+
plurals: {},
|
|
784
|
+
}),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
function TestComp() {
|
|
788
|
+
useTransDuck();
|
|
789
|
+
return (
|
|
790
|
+
<span data-testid="t">
|
|
791
|
+
{t('Book', { context: 'hotel booking', vars: { name: 'Tim' }, sourceLang: 'EN' })}
|
|
792
|
+
</span>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
render(
|
|
797
|
+
<TransDuckProvider language="DE" sourceLang="EN">
|
|
798
|
+
<TestComp />
|
|
799
|
+
</TransDuckProvider>
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
await waitFor(() => {
|
|
803
|
+
expect(screen.getByTestId('t').textContent).toBe('Buchen Tim');
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('tPlural() accepts per-call sourceLang', async () => {
|
|
808
|
+
const pluralKey = '{count} item\x00{count} items||||EN';
|
|
809
|
+
mockFetch.mockResolvedValue({
|
|
810
|
+
ok: true,
|
|
811
|
+
json: async () => ({
|
|
812
|
+
translations: {},
|
|
813
|
+
plurals: { [pluralKey]: { one: '{count} Artikel', other: '{count} Artikel' } },
|
|
814
|
+
}),
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
function TestComp() {
|
|
818
|
+
useTransDuck();
|
|
819
|
+
return (
|
|
820
|
+
<span data-testid="p">
|
|
821
|
+
{tPlural('{count} item', '{count} items', 5, { sourceLang: 'EN' })}
|
|
822
|
+
</span>
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Provider source is ES, but this plural is EN source.
|
|
827
|
+
render(
|
|
828
|
+
<TransDuckProvider language="DE" sourceLang="ES">
|
|
829
|
+
<TestComp />
|
|
830
|
+
</TransDuckProvider>
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
await waitFor(() => {
|
|
834
|
+
expect(screen.getByTestId('p').textContent).toBe('5 Artikel');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
838
|
+
expect(body.plurals[0].sourceLang).toBe('EN');
|
|
839
|
+
});
|
|
840
|
+
|
|
659
841
|
it('useTransDuck() still works without destructuring (backward compat)', async () => {
|
|
660
842
|
mockFetch.mockResolvedValue({
|
|
661
843
|
ok: true,
|
|
662
|
-
json: async () => ({ translations: { 'Hello
|
|
844
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
663
845
|
});
|
|
664
846
|
|
|
665
847
|
function TestComp() {
|