transduck 0.0.5 → 0.1.2

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.
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { mkdtempSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+
6
+ // We test the handler logic directly, not via HTTP
7
+ import { handleTranslationRequest, _resetHandlerStore } from '../src/handler.js';
8
+
9
+ function makeTmpDir(): string {
10
+ return mkdtempSync(join(tmpdir(), 'transduck-handler-test-'));
11
+ }
12
+
13
+ function writeConfig(dir: string): string {
14
+ const configPath = join(dir, 'transduck.yaml');
15
+ writeFileSync(configPath, `
16
+ project:
17
+ name: test-project
18
+ context: "A test site"
19
+ languages:
20
+ source: EN
21
+ targets:
22
+ - DE
23
+ storage:
24
+ path: ./translations.duckdb
25
+ backend:
26
+ api_key_env: OPENAI_API_KEY
27
+ model: gpt-4.1-mini
28
+ timeout_seconds: 10
29
+ max_retries: 2
30
+ `);
31
+ return configPath;
32
+ }
33
+
34
+ describe('handleTranslationRequest', () => {
35
+ let tmpDir: string;
36
+ let configPath: string;
37
+
38
+ beforeEach(() => {
39
+ _resetHandlerStore();
40
+ tmpDir = makeTmpDir();
41
+ configPath = writeConfig(tmpDir);
42
+ process.env.OPENAI_API_KEY = 'test-key';
43
+ });
44
+
45
+ it('returns translations for strings from cache', async () => {
46
+ // Pre-populate cache via storage directly
47
+ const { TranslationStore } = await import('../src/storage.js');
48
+ const { createHash } = await import('crypto');
49
+ const hash = (t: string) => createHash('sha256').update(t).digest('hex');
50
+
51
+ const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
52
+ await store.initialize();
53
+ await store.insert({
54
+ sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
55
+ projectContextHash: hash('A test site'), stringContextHash: hash(''),
56
+ translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated',
57
+ });
58
+ store.close();
59
+
60
+ const result = await handleTranslationRequest({
61
+ language: 'DE',
62
+ strings: [{ text: 'Hello' }],
63
+ plurals: [],
64
+ }, configPath);
65
+
66
+ expect(result.translations['Hello||']).toBe('Hallo');
67
+ });
68
+
69
+ it('returns empty translations for uncached strings without API key', async () => {
70
+ delete process.env.OPENAI_API_KEY;
71
+ const result = await handleTranslationRequest({
72
+ language: 'DE',
73
+ strings: [{ text: 'Unknown' }],
74
+ plurals: [],
75
+ }, configPath);
76
+
77
+ // Without API key, backend will fail, should return source text
78
+ expect(result.translations['Unknown||']).toBe('Unknown');
79
+ });
80
+
81
+ it('handles context in cache key', async () => {
82
+ const { TranslationStore } = await import('../src/storage.js');
83
+ const { createHash } = await import('crypto');
84
+ const hash = (t: string) => createHash('sha256').update(t).digest('hex');
85
+
86
+ const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
87
+ await store.initialize();
88
+ await store.insert({
89
+ sourceText: 'Book', sourceLang: 'EN', targetLang: 'DE',
90
+ projectContextHash: hash('A test site'), stringContextHash: hash('Hotel booking'),
91
+ translatedText: 'Buchen', model: 'gpt-4.1-mini', status: 'translated',
92
+ });
93
+ store.close();
94
+
95
+ const result = await handleTranslationRequest({
96
+ language: 'DE',
97
+ strings: [{ text: 'Book', context: 'Hotel booking' }],
98
+ plurals: [],
99
+ }, configPath);
100
+
101
+ expect(result.translations['Book||Hotel booking']).toBe('Buchen');
102
+ });
103
+
104
+ it('returns plural forms from cache', async () => {
105
+ const { TranslationStore } = await import('../src/storage.js');
106
+ const { createHash } = await import('crypto');
107
+ const hash = (t: string) => createHash('sha256').update(t).digest('hex');
108
+
109
+ const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
110
+ await store.initialize();
111
+ const sourceKey = '{count} item\x00{count} items';
112
+ await store.insertPlural({
113
+ sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
114
+ projectContextHash: hash('A test site'), stringContextHash: hash(''),
115
+ pluralCategory: 'one', translatedText: '{count} Artikel',
116
+ model: 'gpt-4.1-mini', status: 'translated',
117
+ });
118
+ await store.insertPlural({
119
+ sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
120
+ projectContextHash: hash('A test site'), stringContextHash: hash(''),
121
+ pluralCategory: 'other', translatedText: '{count} Artikel',
122
+ model: 'gpt-4.1-mini', status: 'translated',
123
+ });
124
+ store.close();
125
+
126
+ const result = await handleTranslationRequest({
127
+ language: 'DE',
128
+ strings: [],
129
+ plurals: [{ one: '{count} item', other: '{count} items' }],
130
+ }, configPath);
131
+
132
+ const pluralKey = '{count} item\x00{count} items||';
133
+ expect(result.plurals[pluralKey]).toBeDefined();
134
+ expect(result.plurals[pluralKey].one).toBe('{count} Artikel');
135
+ });
136
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
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';
5
+
6
+ // Mock fetch globally
7
+ const mockFetch = vi.fn();
8
+ global.fetch = mockFetch;
9
+
10
+ // Mock localStorage
11
+ const localStorageStore: Record<string, string> = {};
12
+ const mockLocalStorage = {
13
+ getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
14
+ setItem: vi.fn((key: string, value: string) => { localStorageStore[key] = value; }),
15
+ removeItem: vi.fn((key: string) => { delete localStorageStore[key]; }),
16
+ clear: vi.fn(() => { for (const k in localStorageStore) delete localStorageStore[k]; }),
17
+ };
18
+ Object.defineProperty(globalThis, 'localStorage', { value: mockLocalStorage, writable: true });
19
+
20
+ describe('TransDuckProvider + t()', () => {
21
+ beforeEach(() => {
22
+ _resetReactState();
23
+ mockFetch.mockReset();
24
+ mockLocalStorage.clear();
25
+ });
26
+
27
+ afterEach(() => {
28
+ cleanup();
29
+ });
30
+
31
+ it('t() returns source text before translation loads', () => {
32
+ mockFetch.mockResolvedValue({
33
+ ok: true,
34
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
35
+ });
36
+
37
+ function TestComp() {
38
+ useTransDuck();
39
+ return <span data-testid="text">{t('Hello')}</span>;
40
+ }
41
+
42
+ render(
43
+ <TransDuckProvider language="DE">
44
+ <TestComp />
45
+ </TransDuckProvider>
46
+ );
47
+
48
+ // Before fetch resolves, shows source text
49
+ expect(screen.getByTestId('text').textContent).toBe('Hello');
50
+ });
51
+
52
+ it('t() and ait() are the same function', () => {
53
+ expect(t).toBe(ait);
54
+ });
55
+
56
+ it('tPlural() and aitPlural() are the same function', () => {
57
+ expect(tPlural).toBe(aitPlural);
58
+ });
59
+
60
+ it('t() returns source text when language matches source', () => {
61
+ function TestComp() {
62
+ useTransDuck();
63
+ return <span data-testid="text">{t('Hello')}</span>;
64
+ }
65
+
66
+ render(
67
+ <TransDuckProvider language="EN" sourceLang="EN">
68
+ <TestComp />
69
+ </TransDuckProvider>
70
+ );
71
+
72
+ expect(screen.getByTestId('text').textContent).toBe('Hello');
73
+ expect(mockFetch).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('t() interpolates vars', () => {
77
+ function TestComp() {
78
+ useTransDuck();
79
+ return <span data-testid="text">{t('Hello {name}', undefined, { name: 'Tim' })}</span>;
80
+ }
81
+
82
+ render(
83
+ <TransDuckProvider language="EN" sourceLang="EN">
84
+ <TestComp />
85
+ </TransDuckProvider>
86
+ );
87
+
88
+ expect(screen.getByTestId('text').textContent).toBe('Hello Tim');
89
+ });
90
+
91
+ it('t() batches and fetches translations after render', async () => {
92
+ mockFetch.mockResolvedValue({
93
+ ok: true,
94
+ json: async () => ({
95
+ translations: { 'Hello||': 'Hallo', 'World||': 'Welt' },
96
+ plurals: {},
97
+ }),
98
+ });
99
+
100
+ function TestComp() {
101
+ useTransDuck();
102
+ return (
103
+ <div>
104
+ <span data-testid="a">{t('Hello')}</span>
105
+ <span data-testid="b">{t('World')}</span>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ render(
111
+ <TransDuckProvider language="DE">
112
+ <TestComp />
113
+ </TransDuckProvider>
114
+ );
115
+
116
+ // After useEffect fires and fetch resolves
117
+ await waitFor(() => {
118
+ expect(screen.getByTestId('a').textContent).toBe('Hallo');
119
+ expect(screen.getByTestId('b').textContent).toBe('Welt');
120
+ });
121
+
122
+ // Should have made exactly one fetch call (batched)
123
+ expect(mockFetch).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it('t() does not refetch cached strings', async () => {
127
+ mockFetch.mockResolvedValue({
128
+ ok: true,
129
+ json: async () => ({
130
+ translations: { 'Hello||': 'Hallo' },
131
+ plurals: {},
132
+ }),
133
+ });
134
+
135
+ function TestComp() {
136
+ useTransDuck();
137
+ return <span data-testid="text">{t('Hello')}</span>;
138
+ }
139
+
140
+ const { rerender } = render(
141
+ <TransDuckProvider language="DE">
142
+ <TestComp />
143
+ </TransDuckProvider>
144
+ );
145
+
146
+ await waitFor(() => {
147
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
148
+ });
149
+
150
+ mockFetch.mockClear();
151
+
152
+ // Re-render — should not fetch again
153
+ rerender(
154
+ <TransDuckProvider language="DE">
155
+ <TestComp />
156
+ </TransDuckProvider>
157
+ );
158
+
159
+ expect(mockFetch).not.toHaveBeenCalled();
160
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
161
+ });
162
+ });
@@ -100,6 +100,30 @@ ait_plural("{count} item", "{count} items", count=n)
100
100
  const result = extractStrings('line1\nait("Hello")\nline3', 'test.py');
101
101
  expect(result[0].line).toBe(2);
102
102
  });
103
+
104
+ it('extracts t() from files with transduck/react import', () => {
105
+ const code = `import { t } from 'transduck/react';\nt("Hello")\nt("Book", "Hotel booking")`;
106
+ const result = extractStrings(code, 'test.tsx');
107
+ expect(result.length).toBe(2);
108
+ expect(result[0].text).toBe('Hello');
109
+ expect(result[1].text).toBe('Book');
110
+ expect(result[1].context).toBe('Hotel booking');
111
+ });
112
+
113
+ it('does not extract t() from files without transduck/react import', () => {
114
+ const code = `import { t } from 'i18next';\nt("Hello")`;
115
+ const result = extractStrings(code, 'test.tsx');
116
+ expect(result.length).toBe(0);
117
+ });
118
+
119
+ it('extracts tPlural() from files with transduck/react import', () => {
120
+ const code = `import { tPlural } from 'transduck/react';\ntPlural("{count} item", "{count} items", 5)`;
121
+ const result = extractStrings(code, 'test.tsx');
122
+ expect(result.length).toBe(1);
123
+ expect(result[0].plural).toBe(true);
124
+ expect(result[0].one).toBe('{count} item');
125
+ expect(result[0].other).toBe('{count} items');
126
+ });
103
127
  });
104
128
 
105
129
  describe('scanDirectory', () => {
package/tsconfig.json CHANGED
@@ -10,7 +10,8 @@
10
10
  "esModuleInterop": true,
11
11
  "skipLibCheck": true,
12
12
  "forceConsistentCasingInFileNames": true,
13
- "resolveJsonModule": true
13
+ "resolveJsonModule": true,
14
+ "jsx": "react-jsx"
14
15
  },
15
16
  "include": ["src/**/*"]
16
17
  }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environmentMatchGlobs: [
6
+ ['tests/react-*.test.tsx', 'jsdom'],
7
+ ],
8
+ },
9
+ });