transduck 0.2.5 → 0.4.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/README.md +3 -3
- package/dist/cli.js +3 -3
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +1 -1
- package/dist/react/provider.d.ts +27 -3
- package/dist/react/provider.js +104 -20
- package/dist/storage.d.ts +10 -5
- package/dist/storage.js +137 -153
- package/package.json +18 -10
- package/src/cli.ts +3 -3
- package/src/react/index.ts +2 -1
- package/src/react/provider.tsx +142 -18
- package/src/storage.ts +146 -167
- package/tests/ait.test.ts +1 -1
- package/tests/backend.test.ts +1 -1
- package/tests/cli.test.ts +6 -6
- package/tests/config.test.ts +2 -2
- package/tests/handler.test.ts +4 -4
- package/tests/providers.test.ts +5 -5
- package/tests/react-provider.test.tsx +363 -2
- package/tests/storage.test.ts +27 -59
package/tests/cli.test.ts
CHANGED
|
@@ -27,7 +27,7 @@ languages:
|
|
|
27
27
|
targets:
|
|
28
28
|
- DE
|
|
29
29
|
storage:
|
|
30
|
-
path: ./translations.
|
|
30
|
+
path: ./translations.lmdb
|
|
31
31
|
backend:
|
|
32
32
|
api_key_env: OPENAI_API_KEY
|
|
33
33
|
model: gpt-4.1-mini
|
|
@@ -55,7 +55,7 @@ describe('CLI functions', () => {
|
|
|
55
55
|
});
|
|
56
56
|
const { existsSync } = await import('fs');
|
|
57
57
|
expect(existsSync(join(tmpDir, 'transduck.yaml'))).toBe(true);
|
|
58
|
-
expect(existsSync(join(tmpDir, 'translations.
|
|
58
|
+
expect(existsSync(join(tmpDir, 'translations.lmdb'))).toBe(true);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('stats on empty db shows zero', async () => {
|
|
@@ -68,7 +68,7 @@ describe('CLI functions', () => {
|
|
|
68
68
|
|
|
69
69
|
it('translate returns cached with vars interpolated', async () => {
|
|
70
70
|
const configPath = writeConfig(tmpDir);
|
|
71
|
-
const dbPath = join(tmpDir, 'translations.
|
|
71
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
72
72
|
|
|
73
73
|
// Pre-populate the DB
|
|
74
74
|
const store = new TranslationStore(dbPath);
|
|
@@ -91,7 +91,7 @@ describe('CLI functions', () => {
|
|
|
91
91
|
|
|
92
92
|
it('translate-plural returns cached plural form', async () => {
|
|
93
93
|
const configPath = writeConfig(tmpDir);
|
|
94
|
-
const dbPath = join(tmpDir, 'translations.
|
|
94
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
95
95
|
|
|
96
96
|
// Pre-populate the DB with plural forms
|
|
97
97
|
const store = new TranslationStore(dbPath);
|
|
@@ -132,7 +132,7 @@ describe('CLI functions', () => {
|
|
|
132
132
|
|
|
133
133
|
it('warm handles plural entries', async () => {
|
|
134
134
|
const configPath = writeConfig(tmpDir);
|
|
135
|
-
const dbPath = join(tmpDir, 'translations.
|
|
135
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
136
136
|
|
|
137
137
|
// Pre-populate some plural forms so warm skips them
|
|
138
138
|
const store = new TranslationStore(dbPath);
|
|
@@ -202,7 +202,7 @@ describe('CLI functions', () => {
|
|
|
202
202
|
writeFileSync(join(srcDir, 'app.py'), 'ait("Hello")\n');
|
|
203
203
|
|
|
204
204
|
// Pre-populate the DB so warm skips
|
|
205
|
-
const dbPath = join(tmpDir, 'translations.
|
|
205
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
206
206
|
const store = new TranslationStore(dbPath);
|
|
207
207
|
await store.initialize();
|
|
208
208
|
await store.insert({
|
package/tests/config.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ languages:
|
|
|
19
19
|
- DE
|
|
20
20
|
- ES
|
|
21
21
|
storage:
|
|
22
|
-
path: ./translations.
|
|
22
|
+
path: ./translations.lmdb
|
|
23
23
|
backend:
|
|
24
24
|
api_key_env: OPENAI_API_KEY
|
|
25
25
|
model: gpt-4.1-mini
|
|
@@ -60,7 +60,7 @@ describe('loadConfig', () => {
|
|
|
60
60
|
const configPath = join(tmpDir, 'transduck.yaml');
|
|
61
61
|
writeFileSync(configPath, VALID_YAML);
|
|
62
62
|
const cfg = loadConfig(configPath);
|
|
63
|
-
expect(cfg.storagePath).toBe(join(tmpDir, 'translations.
|
|
63
|
+
expect(cfg.storagePath).toBe(join(tmpDir, 'translations.lmdb'));
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it('discovers config from TRANSDUCK_CONFIG env var', () => {
|
package/tests/handler.test.ts
CHANGED
|
@@ -21,7 +21,7 @@ languages:
|
|
|
21
21
|
targets:
|
|
22
22
|
- DE
|
|
23
23
|
storage:
|
|
24
|
-
path: ./translations.
|
|
24
|
+
path: ./translations.lmdb
|
|
25
25
|
backend:
|
|
26
26
|
api_key_env: OPENAI_API_KEY
|
|
27
27
|
model: gpt-4.1-mini
|
|
@@ -48,7 +48,7 @@ describe('handleTranslationRequest', () => {
|
|
|
48
48
|
const { createHash } = await import('crypto');
|
|
49
49
|
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
50
50
|
|
|
51
|
-
const store = new TranslationStore(join(tmpDir, 'translations.
|
|
51
|
+
const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
|
|
52
52
|
await store.initialize();
|
|
53
53
|
await store.insert({
|
|
54
54
|
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
@@ -83,7 +83,7 @@ describe('handleTranslationRequest', () => {
|
|
|
83
83
|
const { createHash } = await import('crypto');
|
|
84
84
|
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
85
85
|
|
|
86
|
-
const store = new TranslationStore(join(tmpDir, 'translations.
|
|
86
|
+
const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
|
|
87
87
|
await store.initialize();
|
|
88
88
|
await store.insert({
|
|
89
89
|
sourceText: 'Book', sourceLang: 'EN', targetLang: 'DE',
|
|
@@ -106,7 +106,7 @@ describe('handleTranslationRequest', () => {
|
|
|
106
106
|
const { createHash } = await import('crypto');
|
|
107
107
|
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
108
108
|
|
|
109
|
-
const store = new TranslationStore(join(tmpDir, 'translations.
|
|
109
|
+
const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
|
|
110
110
|
await store.initialize();
|
|
111
111
|
const sourceKey = '{count} item\x00{count} items';
|
|
112
112
|
await store.insertPlural({
|
package/tests/providers.test.ts
CHANGED
|
@@ -20,7 +20,7 @@ function makeConfig(overrides: Partial<TransduckConfig> = {}): TransduckConfig {
|
|
|
20
20
|
projectContext: 'A test site',
|
|
21
21
|
sourceLang: 'EN',
|
|
22
22
|
targetLangs: ['DE'],
|
|
23
|
-
storagePath: '/tmp/test.
|
|
23
|
+
storagePath: '/tmp/test.lmdb',
|
|
24
24
|
provider: 'openai',
|
|
25
25
|
apiKeyEnv: 'OPENAI_API_KEY',
|
|
26
26
|
tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
@@ -207,7 +207,7 @@ languages:
|
|
|
207
207
|
source: EN
|
|
208
208
|
targets: [DE]
|
|
209
209
|
storage:
|
|
210
|
-
path: ./translations.
|
|
210
|
+
path: ./translations.lmdb
|
|
211
211
|
backend:
|
|
212
212
|
api_key_env: OPENAI_API_KEY
|
|
213
213
|
model: gpt-4.1-mini
|
|
@@ -230,7 +230,7 @@ languages:
|
|
|
230
230
|
source: EN
|
|
231
231
|
targets: [DE]
|
|
232
232
|
storage:
|
|
233
|
-
path: ./translations.
|
|
233
|
+
path: ./translations.lmdb
|
|
234
234
|
backend:
|
|
235
235
|
provider: claude_api
|
|
236
236
|
api_key_env: ANTHROPIC_API_KEY
|
|
@@ -255,7 +255,7 @@ languages:
|
|
|
255
255
|
source: EN
|
|
256
256
|
targets: [DE]
|
|
257
257
|
storage:
|
|
258
|
-
path: ./translations.
|
|
258
|
+
path: ./translations.lmdb
|
|
259
259
|
backend:
|
|
260
260
|
provider: claude_code
|
|
261
261
|
token_env: CLAUDE_CODE_OAUTH_TOKEN
|
|
@@ -277,7 +277,7 @@ languages:
|
|
|
277
277
|
source: EN
|
|
278
278
|
targets: [DE]
|
|
279
279
|
storage:
|
|
280
|
-
path: ./translations.
|
|
280
|
+
path: ./translations.lmdb
|
|
281
281
|
backend:
|
|
282
282
|
provider: claude_code
|
|
283
283
|
`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
|
4
|
-
import { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from '../src/react/index.js';
|
|
3
|
+
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
|
|
4
|
+
import { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from '../src/react/index.js';
|
|
5
|
+
import type { UseTransDuckReturn } from '../src/react/index.js';
|
|
5
6
|
|
|
6
7
|
// Mock fetch globally
|
|
7
8
|
const mockFetch = vi.fn();
|
|
@@ -159,4 +160,364 @@ describe('TransDuckProvider + t()', () => {
|
|
|
159
160
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
160
161
|
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
161
162
|
});
|
|
163
|
+
|
|
164
|
+
it('t() tracks called keys in _state.knownKeys', async () => {
|
|
165
|
+
mockFetch.mockResolvedValue({
|
|
166
|
+
ok: true,
|
|
167
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function TestComp() {
|
|
171
|
+
useTransDuck();
|
|
172
|
+
return <span>{t('Hello')}</span>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
render(
|
|
176
|
+
<TransDuckProvider language="DE">
|
|
177
|
+
<TestComp />
|
|
178
|
+
</TransDuckProvider>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const state = _getReactState();
|
|
182
|
+
expect(state.knownKeys.has('Hello||')).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('useTransDuck() returns t, tPlural, ait, aitPlural, language, setLanguage, isLoading', () => {
|
|
186
|
+
let hookResult: ReturnType<typeof useTransDuck> | null = null;
|
|
187
|
+
|
|
188
|
+
function TestComp() {
|
|
189
|
+
hookResult = useTransDuck();
|
|
190
|
+
return <span>test</span>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<TransDuckProvider language="DE">
|
|
195
|
+
<TestComp />
|
|
196
|
+
</TransDuckProvider>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(hookResult).not.toBeNull();
|
|
200
|
+
expect(hookResult!.t).toBe(t);
|
|
201
|
+
expect(hookResult!.tPlural).toBe(tPlural);
|
|
202
|
+
expect(hookResult!.ait).toBe(ait);
|
|
203
|
+
expect(hookResult!.aitPlural).toBe(aitPlural);
|
|
204
|
+
expect(hookResult!.language).toBe('DE');
|
|
205
|
+
expect(typeof hookResult!.setLanguage).toBe('function');
|
|
206
|
+
expect(hookResult!.isLoading).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('setLanguage() switches language, clears cache, and re-fetches', async () => {
|
|
210
|
+
// First render in DE
|
|
211
|
+
mockFetch.mockResolvedValueOnce({
|
|
212
|
+
ok: true,
|
|
213
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
214
|
+
});
|
|
215
|
+
// After switch to ES
|
|
216
|
+
mockFetch.mockResolvedValueOnce({
|
|
217
|
+
ok: true,
|
|
218
|
+
json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
222
|
+
|
|
223
|
+
function TestComp() {
|
|
224
|
+
const hook = useTransDuck();
|
|
225
|
+
hookRef = hook;
|
|
226
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
render(
|
|
230
|
+
<TransDuckProvider language="DE">
|
|
231
|
+
<TestComp />
|
|
232
|
+
</TransDuckProvider>
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Wait for DE translations
|
|
236
|
+
await waitFor(() => {
|
|
237
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Switch to ES
|
|
241
|
+
await act(async () => {
|
|
242
|
+
hookRef!.setLanguage('ES');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Wait for ES translations
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(screen.getByTestId('text').textContent).toBe('Hola');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(hookRef!.language).toBe('ES');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('setLanguage() with current language is a no-op', async () => {
|
|
254
|
+
mockFetch.mockResolvedValueOnce({
|
|
255
|
+
ok: true,
|
|
256
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
260
|
+
|
|
261
|
+
function TestComp() {
|
|
262
|
+
const hook = useTransDuck();
|
|
263
|
+
hookRef = hook;
|
|
264
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
render(
|
|
268
|
+
<TransDuckProvider language="DE">
|
|
269
|
+
<TestComp />
|
|
270
|
+
</TransDuckProvider>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await waitFor(() => {
|
|
274
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
mockFetch.mockClear();
|
|
278
|
+
|
|
279
|
+
await act(async () => {
|
|
280
|
+
hookRef!.setLanguage('DE');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
284
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('setLanguage() to source language skips fetch', async () => {
|
|
288
|
+
mockFetch.mockResolvedValueOnce({
|
|
289
|
+
ok: true,
|
|
290
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
294
|
+
|
|
295
|
+
function TestComp() {
|
|
296
|
+
const hook = useTransDuck();
|
|
297
|
+
hookRef = hook;
|
|
298
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
render(
|
|
302
|
+
<TransDuckProvider language="DE" sourceLang="EN">
|
|
303
|
+
<TestComp />
|
|
304
|
+
</TransDuckProvider>
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
await waitFor(() => {
|
|
308
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
mockFetch.mockClear();
|
|
312
|
+
|
|
313
|
+
await act(async () => {
|
|
314
|
+
hookRef!.setLanguage('EN');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await waitFor(() => {
|
|
318
|
+
expect(screen.getByTestId('text').textContent).toBe('Hello');
|
|
319
|
+
});
|
|
320
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('isLoading is true during language switch fetch', async () => {
|
|
324
|
+
mockFetch.mockResolvedValueOnce({
|
|
325
|
+
ok: true,
|
|
326
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
let resolveSecondFetch: ((value: unknown) => void) | null = null;
|
|
330
|
+
mockFetch.mockImplementationOnce(() => new Promise(resolve => {
|
|
331
|
+
resolveSecondFetch = resolve;
|
|
332
|
+
}));
|
|
333
|
+
|
|
334
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
335
|
+
|
|
336
|
+
function TestComp() {
|
|
337
|
+
const hook = useTransDuck();
|
|
338
|
+
hookRef = hook;
|
|
339
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
render(
|
|
343
|
+
<TransDuckProvider language="DE">
|
|
344
|
+
<TestComp />
|
|
345
|
+
</TransDuckProvider>
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await waitFor(() => {
|
|
349
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await act(async () => {
|
|
353
|
+
hookRef!.setLanguage('ES');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
expect(hookRef!.isLoading).toBe(true);
|
|
357
|
+
|
|
358
|
+
await act(async () => {
|
|
359
|
+
resolveSecondFetch!({
|
|
360
|
+
ok: true,
|
|
361
|
+
json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await waitFor(() => {
|
|
366
|
+
expect(hookRef!.isLoading).toBe(false);
|
|
367
|
+
expect(screen.getByTestId('text').textContent).toBe('Hola');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('isLoading clears on fetch error during language switch', async () => {
|
|
372
|
+
mockFetch.mockResolvedValueOnce({
|
|
373
|
+
ok: true,
|
|
374
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
375
|
+
});
|
|
376
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
377
|
+
|
|
378
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
379
|
+
|
|
380
|
+
function TestComp() {
|
|
381
|
+
const hook = useTransDuck();
|
|
382
|
+
hookRef = hook;
|
|
383
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
render(
|
|
387
|
+
<TransDuckProvider language="DE">
|
|
388
|
+
<TestComp />
|
|
389
|
+
</TransDuckProvider>
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
await waitFor(() => {
|
|
393
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await act(async () => {
|
|
397
|
+
hookRef!.setLanguage('ES');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await waitFor(() => {
|
|
401
|
+
expect(hookRef!.isLoading).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('discards stale fetch response when language changes during fetch', async () => {
|
|
406
|
+
mockFetch.mockResolvedValueOnce({
|
|
407
|
+
ok: true,
|
|
408
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
let resolveFRFetch: ((value: unknown) => void) | null = null;
|
|
412
|
+
mockFetch.mockImplementationOnce(() => new Promise(resolve => {
|
|
413
|
+
resolveFRFetch = resolve;
|
|
414
|
+
}));
|
|
415
|
+
|
|
416
|
+
mockFetch.mockResolvedValueOnce({
|
|
417
|
+
ok: true,
|
|
418
|
+
json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
422
|
+
|
|
423
|
+
function TestComp() {
|
|
424
|
+
const hook = useTransDuck();
|
|
425
|
+
hookRef = hook;
|
|
426
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
render(
|
|
430
|
+
<TransDuckProvider language="DE">
|
|
431
|
+
<TestComp />
|
|
432
|
+
</TransDuckProvider>
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
await waitFor(() => {
|
|
436
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await act(async () => {
|
|
440
|
+
hookRef!.setLanguage('FR');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await act(async () => {
|
|
444
|
+
hookRef!.setLanguage('ES');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await waitFor(() => {
|
|
448
|
+
expect(screen.getByTestId('text').textContent).toBe('Hola');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await act(async () => {
|
|
452
|
+
resolveFRFetch!({
|
|
453
|
+
ok: true,
|
|
454
|
+
json: async () => ({ translations: { 'Hello||': 'Bonjour' }, plurals: {} }),
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(screen.getByTestId('text').textContent).toBe('Hola');
|
|
459
|
+
expect(hookRef!.language).toBe('ES');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('changing language prop triggers switchLanguage with isLoading', async () => {
|
|
463
|
+
mockFetch.mockResolvedValueOnce({
|
|
464
|
+
ok: true,
|
|
465
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
466
|
+
});
|
|
467
|
+
mockFetch.mockResolvedValueOnce({
|
|
468
|
+
ok: true,
|
|
469
|
+
json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
let hookRef: UseTransDuckReturn | null = null;
|
|
473
|
+
|
|
474
|
+
function TestComp() {
|
|
475
|
+
const hook = useTransDuck();
|
|
476
|
+
hookRef = hook;
|
|
477
|
+
return <span data-testid="text">{hook.t('Hello')}</span>;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const { rerender } = render(
|
|
481
|
+
<TransDuckProvider language="DE">
|
|
482
|
+
<TestComp />
|
|
483
|
+
</TransDuckProvider>
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
await waitFor(() => {
|
|
487
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
rerender(
|
|
491
|
+
<TransDuckProvider language="ES">
|
|
492
|
+
<TestComp />
|
|
493
|
+
</TransDuckProvider>
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
await waitFor(() => {
|
|
497
|
+
expect(screen.getByTestId('text').textContent).toBe('Hola');
|
|
498
|
+
expect(hookRef!.language).toBe('ES');
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('useTransDuck() still works without destructuring (backward compat)', async () => {
|
|
503
|
+
mockFetch.mockResolvedValue({
|
|
504
|
+
ok: true,
|
|
505
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
function TestComp() {
|
|
509
|
+
useTransDuck();
|
|
510
|
+
return <span data-testid="text">{t('Hello')}</span>;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
render(
|
|
514
|
+
<TransDuckProvider language="DE">
|
|
515
|
+
<TestComp />
|
|
516
|
+
</TransDuckProvider>
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
await waitFor(() => {
|
|
520
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
521
|
+
});
|
|
522
|
+
});
|
|
162
523
|
});
|
package/tests/storage.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { mkdtempSync } from 'fs';
|
|
@@ -14,13 +14,17 @@ describe('TranslationStore', () => {
|
|
|
14
14
|
|
|
15
15
|
beforeEach(async () => {
|
|
16
16
|
const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-test-'));
|
|
17
|
-
store = new TranslationStore(join(tmpDir, 'test.
|
|
17
|
+
store = new TranslationStore(join(tmpDir, 'test.lmdb'));
|
|
18
18
|
await store.initialize();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
store.close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('starts empty', async () => {
|
|
26
|
+
const stats = await store.stats();
|
|
27
|
+
expect(stats.totalTranslations).toBe(0);
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
it('inserts and looks up translation', async () => {
|
|
@@ -195,68 +199,32 @@ describe('TranslationStore', () => {
|
|
|
195
199
|
await store.insertPlural(entry); // should not throw
|
|
196
200
|
});
|
|
197
201
|
|
|
198
|
-
// ---
|
|
199
|
-
|
|
200
|
-
it('migrates v1 schema to v2', async () => {
|
|
201
|
-
// Create a fresh store with v1 schema
|
|
202
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-migration-'));
|
|
203
|
-
const dbPath = join(tmpDir, 'migrate.duckdb');
|
|
204
|
-
|
|
205
|
-
// Manually create v1 schema (no plural_category column)
|
|
206
|
-
const { DuckDBInstance } = await import('@duckdb/node-api');
|
|
207
|
-
const instance = await DuckDBInstance.create(dbPath);
|
|
208
|
-
const conn = await instance.connect();
|
|
209
|
-
await conn.run(`
|
|
210
|
-
CREATE TABLE translations (
|
|
211
|
-
source_text TEXT NOT NULL,
|
|
212
|
-
source_lang TEXT NOT NULL,
|
|
213
|
-
target_lang TEXT NOT NULL,
|
|
214
|
-
project_context_hash TEXT NOT NULL,
|
|
215
|
-
string_context_hash TEXT NOT NULL,
|
|
216
|
-
translated_text TEXT NOT NULL,
|
|
217
|
-
model TEXT NOT NULL,
|
|
218
|
-
status TEXT NOT NULL DEFAULT 'translated',
|
|
219
|
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
220
|
-
PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash)
|
|
221
|
-
)
|
|
222
|
-
`);
|
|
223
|
-
// Insert a v1 row
|
|
224
|
-
await conn.run(`
|
|
225
|
-
INSERT INTO translations (source_text, source_lang, target_lang, project_context_hash, string_context_hash, translated_text, model, status)
|
|
226
|
-
VALUES ('Hello', 'EN', 'DE', '${hash('ctx')}', '${hash('')}', 'Hallo', 'gpt-4.1-mini', 'translated')
|
|
227
|
-
`);
|
|
228
|
-
|
|
229
|
-
// Close the raw connection
|
|
230
|
-
// (DuckDB node-api handles cleanup via GC, just null out)
|
|
202
|
+
// --- Count and clear ---
|
|
231
203
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
await migratedStore.initialize();
|
|
235
|
-
|
|
236
|
-
// Old data should still be accessible
|
|
237
|
-
const result = await migratedStore.lookup({
|
|
204
|
+
it('counts and clears entries', async () => {
|
|
205
|
+
await store.insert({
|
|
238
206
|
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
239
207
|
projectContextHash: hash('ctx'), stringContextHash: hash(''),
|
|
208
|
+
translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated',
|
|
240
209
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// Plural methods should work
|
|
244
|
-
await migratedStore.insertPlural({
|
|
245
|
-
sourceText: 'test\x00tests',
|
|
246
|
-
sourceLang: 'EN', targetLang: 'DE',
|
|
210
|
+
await store.insert({
|
|
211
|
+
sourceText: 'Bad', sourceLang: 'EN', targetLang: 'DE',
|
|
247
212
|
projectContextHash: hash('ctx'), stringContextHash: hash(''),
|
|
248
|
-
|
|
249
|
-
translatedText: 'Tests',
|
|
250
|
-
model: 'gpt-4.1-mini', status: 'translated',
|
|
213
|
+
translatedText: 'schlecht', model: 'gpt-4.1-mini', status: 'failed',
|
|
251
214
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
sourceText: 'test\x00tests',
|
|
255
|
-
sourceLang: 'EN', targetLang: 'DE',
|
|
215
|
+
await store.insert({
|
|
216
|
+
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'ES',
|
|
256
217
|
projectContextHash: hash('ctx'), stringContextHash: hash(''),
|
|
218
|
+
translatedText: 'Hola', model: 'gpt-4.1-mini', status: 'translated',
|
|
257
219
|
});
|
|
258
|
-
expect(pluralResult).toEqual({ other: 'Tests' });
|
|
259
220
|
|
|
260
|
-
|
|
221
|
+
expect(store.count()).toBe(3);
|
|
222
|
+
expect(store.count('DE')).toBe(2);
|
|
223
|
+
expect(store.count(undefined, true)).toBe(1);
|
|
224
|
+
expect(store.count('DE', true)).toBe(1);
|
|
225
|
+
|
|
226
|
+
const deleted = await store.clear('DE', true);
|
|
227
|
+
expect(deleted).toBe(1);
|
|
228
|
+
expect(store.count()).toBe(2);
|
|
261
229
|
});
|
|
262
230
|
});
|