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.
@@ -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||']).toBe('Hallo');
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||']).toBe('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||']).toBe('Hallo');
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||']).toBe('Hallo');
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
  });
@@ -23,6 +23,7 @@ function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): Trans
23
23
  backendModel: 'gpt-4.1-mini',
24
24
  backendTimeout: 10,
25
25
  backendMaxRetries: 2,
26
+ backendMaxConcurrency: 10,
26
27
  sharedUrl: null,
27
28
  readOnly: false,
28
29
  ...overrides,
@@ -27,6 +27,7 @@ function makeConfig(overrides: Partial<TransduckConfig> = {}): TransduckConfig {
27
27
  backendModel: 'gpt-4.1-mini',
28
28
  backendTimeout: 10,
29
29
  backendMaxRetries: 2,
30
+ backendMaxConcurrency: 10,
30
31
  readOnly: false,
31
32
  ...overrides,
32
33
  };
@@ -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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo', 'World||': 'Welt' },
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||': 'Hallo' },
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||': 'Hallo' }, plurals: {} }),
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||')).toBe(true);
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||': 'Hallo' }, plurals: {} }),
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||': 'Hola' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hola' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hola' }, plurals: {} }),
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||': 'Bonjour' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
466
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
467
467
  });
468
468
  mockFetch.mockResolvedValueOnce({
469
469
  ok: true,
470
- json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
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||': 'Hallo' }, plurals: {} }),
844
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
663
845
  });
664
846
 
665
847
  function TestComp() {