transduck 0.0.1
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/backend.d.ts +40 -0
- package/dist/backend.js +85 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +383 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +43 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +212 -0
- package/dist/plural.d.ts +19 -0
- package/dist/plural.js +42 -0
- package/dist/storage.d.ts +40 -0
- package/dist/storage.js +178 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +30 -0
- package/package.json +28 -0
- package/src/backend.ts +138 -0
- package/src/cli.ts +450 -0
- package/src/config.ts +62 -0
- package/src/index.ts +251 -0
- package/src/plural.ts +47 -0
- package/src/storage.ts +229 -0
- package/src/validation.ts +30 -0
- package/tests/ait.test.ts +213 -0
- package/tests/backend.test.ts +184 -0
- package/tests/cli.test.ts +162 -0
- package/tests/config.test.ts +79 -0
- package/tests/plural.test.ts +114 -0
- package/tests/storage.test.ts +262 -0
- package/tests/validation.test.ts +47 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { mkdtempSync } from 'fs';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import type { TransduckConfig } from '../src/config.js';
|
|
7
|
+
|
|
8
|
+
function hash(text: string): string {
|
|
9
|
+
return createHash('sha256').update(text).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeConfig(tmpDir: string): TransduckConfig {
|
|
13
|
+
return {
|
|
14
|
+
projectName: 'test',
|
|
15
|
+
projectContext: 'A test site',
|
|
16
|
+
sourceLang: 'EN',
|
|
17
|
+
targetLangs: ['DE', 'ES'],
|
|
18
|
+
storagePath: join(tmpDir, 'test.duckdb'),
|
|
19
|
+
apiKeyEnv: 'OPENAI_API_KEY',
|
|
20
|
+
backendModel: 'gpt-4.1-mini',
|
|
21
|
+
backendTimeout: 10,
|
|
22
|
+
backendMaxRetries: 2,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ait', () => {
|
|
27
|
+
let tmpDir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
// Dynamic import to get fresh module state
|
|
31
|
+
const mod = await import('../src/index.js');
|
|
32
|
+
mod._resetState();
|
|
33
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'transduck-test-'));
|
|
34
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns source text when same language', async () => {
|
|
38
|
+
const { ait, setLanguage, initialize } = await import('../src/index.js');
|
|
39
|
+
await initialize(makeConfig(tmpDir));
|
|
40
|
+
setLanguage('EN');
|
|
41
|
+
const result = await ait('Hello');
|
|
42
|
+
expect(result).toBe('Hello');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns cached translation', async () => {
|
|
46
|
+
const { ait, setLanguage, initialize, _getStore } = await import('../src/index.js');
|
|
47
|
+
const cfg = makeConfig(tmpDir);
|
|
48
|
+
await initialize(cfg);
|
|
49
|
+
setLanguage('DE');
|
|
50
|
+
|
|
51
|
+
// Pre-insert into storage
|
|
52
|
+
const store = _getStore();
|
|
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
|
+
|
|
59
|
+
const result = await ait('Hello');
|
|
60
|
+
expect(result).toBe('Hallo');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws if not initialized', async () => {
|
|
64
|
+
const { ait } = await import('../src/index.js');
|
|
65
|
+
await expect(ait('Hello')).rejects.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('throws if no language set', async () => {
|
|
69
|
+
const { ait, initialize } = await import('../src/index.js');
|
|
70
|
+
await initialize(makeConfig(tmpDir));
|
|
71
|
+
await expect(ait('Hello')).rejects.toThrow('Target language not set');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// --- vars tests ---
|
|
75
|
+
|
|
76
|
+
it('interpolates vars in same-language return', async () => {
|
|
77
|
+
const { ait, setLanguage, initialize } = await import('../src/index.js');
|
|
78
|
+
await initialize(makeConfig(tmpDir));
|
|
79
|
+
setLanguage('EN');
|
|
80
|
+
const result = await ait('Welcome {name}', undefined, { name: 'Tim' });
|
|
81
|
+
expect(result).toBe('Welcome Tim');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('interpolates vars in cached translation', async () => {
|
|
85
|
+
const { ait, setLanguage, initialize, _getStore } = await import('../src/index.js');
|
|
86
|
+
const cfg = makeConfig(tmpDir);
|
|
87
|
+
await initialize(cfg);
|
|
88
|
+
setLanguage('DE');
|
|
89
|
+
|
|
90
|
+
const store = _getStore();
|
|
91
|
+
await store!.insert({
|
|
92
|
+
sourceText: 'Welcome {name}', sourceLang: 'EN', targetLang: 'DE',
|
|
93
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
94
|
+
translatedText: 'Willkommen {name}', model: 'gpt-4.1-mini', status: 'translated',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result = await ait('Welcome {name}', undefined, { name: 'Tim' });
|
|
98
|
+
expect(result).toBe('Willkommen Tim');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns text with placeholders when no vars provided', async () => {
|
|
102
|
+
const { ait, setLanguage, initialize } = await import('../src/index.js');
|
|
103
|
+
await initialize(makeConfig(tmpDir));
|
|
104
|
+
setLanguage('EN');
|
|
105
|
+
const result = await ait('Welcome {name}');
|
|
106
|
+
expect(result).toBe('Welcome {name}');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('aitPlural', () => {
|
|
111
|
+
let tmpDir: string;
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
const mod = await import('../src/index.js');
|
|
115
|
+
mod._resetState();
|
|
116
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'transduck-plural-test-'));
|
|
117
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('throws if not initialized', async () => {
|
|
121
|
+
const { aitPlural } = await import('../src/index.js');
|
|
122
|
+
await expect(aitPlural('{count} msg', '{count} msgs', 5)).rejects.toThrow();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('same-language 2-form: selects one for count=1', async () => {
|
|
126
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
127
|
+
await initialize(makeConfig(tmpDir));
|
|
128
|
+
setLanguage('EN');
|
|
129
|
+
const result = await aitPlural('{count} message', '{count} messages', 1);
|
|
130
|
+
expect(result).toBe('1 message');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('same-language 2-form: selects other for count=5', async () => {
|
|
134
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
135
|
+
await initialize(makeConfig(tmpDir));
|
|
136
|
+
setLanguage('EN');
|
|
137
|
+
const result = await aitPlural('{count} message', '{count} messages', 5);
|
|
138
|
+
expect(result).toBe('5 messages');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('same-language 2-form: selects other for count=0', async () => {
|
|
142
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
143
|
+
await initialize(makeConfig(tmpDir));
|
|
144
|
+
setLanguage('EN');
|
|
145
|
+
const result = await aitPlural('{count} message', '{count} messages', 0);
|
|
146
|
+
expect(result).toBe('0 messages');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('same-language: includes custom vars', async () => {
|
|
150
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
151
|
+
await initialize(makeConfig(tmpDir));
|
|
152
|
+
setLanguage('EN');
|
|
153
|
+
const result = await aitPlural(
|
|
154
|
+
'{count} item in {name}\'s cart',
|
|
155
|
+
'{count} items in {name}\'s cart',
|
|
156
|
+
3,
|
|
157
|
+
{ vars: { name: 'Tim' } },
|
|
158
|
+
);
|
|
159
|
+
expect(result).toBe("3 items in Tim's cart");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('returns cached plural form', async () => {
|
|
163
|
+
const { aitPlural, setLanguage, initialize, _getStore } = await import('../src/index.js');
|
|
164
|
+
const cfg = makeConfig(tmpDir);
|
|
165
|
+
await initialize(cfg);
|
|
166
|
+
setLanguage('DE');
|
|
167
|
+
|
|
168
|
+
const store = _getStore();
|
|
169
|
+
const sourceKey = '{count} message\x00{count} messages';
|
|
170
|
+
const lookupParams = {
|
|
171
|
+
sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
|
|
172
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
await store!.insertPlural({
|
|
176
|
+
...lookupParams,
|
|
177
|
+
pluralCategory: 'one',
|
|
178
|
+
translatedText: '{count} Nachricht',
|
|
179
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
180
|
+
});
|
|
181
|
+
await store!.insertPlural({
|
|
182
|
+
...lookupParams,
|
|
183
|
+
pluralCategory: 'other',
|
|
184
|
+
translatedText: '{count} Nachrichten',
|
|
185
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result1 = await aitPlural('{count} message', '{count} messages', 1);
|
|
189
|
+
expect(result1).toBe('1 Nachricht');
|
|
190
|
+
|
|
191
|
+
const result5 = await aitPlural('{count} message', '{count} messages', 5);
|
|
192
|
+
expect(result5).toBe('5 Nachrichten');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('auto-includes count in vars', async () => {
|
|
196
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
197
|
+
await initialize(makeConfig(tmpDir));
|
|
198
|
+
setLanguage('EN');
|
|
199
|
+
// count is auto-added even without explicit vars
|
|
200
|
+
const result = await aitPlural('{count} message', '{count} messages', 42);
|
|
201
|
+
expect(result).toBe('42 messages');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not override explicit count in vars', async () => {
|
|
205
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
206
|
+
await initialize(makeConfig(tmpDir));
|
|
207
|
+
setLanguage('EN');
|
|
208
|
+
// If user provides count in vars, that value should be used
|
|
209
|
+
const result = await aitPlural('{count} message', '{count} messages', 1, { vars: { count: 99 } });
|
|
210
|
+
// count=1 selects "one" form, but the {count} var is 99 from explicit vars
|
|
211
|
+
expect(result).toBe('99 message');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { buildMessages, translate, buildPluralMessages, translatePlural } from '../src/backend.js';
|
|
3
|
+
|
|
4
|
+
describe('buildMessages', () => {
|
|
5
|
+
it('builds system and user messages', () => {
|
|
6
|
+
const messages = buildMessages({
|
|
7
|
+
sourceText: 'Our Events',
|
|
8
|
+
sourceLang: 'EN',
|
|
9
|
+
targetLang: 'DE',
|
|
10
|
+
projectContext: 'A travel site about Mallorca',
|
|
11
|
+
stringContext: 'Events are concerts and shows',
|
|
12
|
+
});
|
|
13
|
+
expect(messages).toHaveLength(2);
|
|
14
|
+
expect(messages[0].role).toBe('system');
|
|
15
|
+
expect(messages[0].content).toContain('EN');
|
|
16
|
+
expect(messages[0].content).toContain('DE');
|
|
17
|
+
expect(messages[0].content).toContain('Mallorca');
|
|
18
|
+
expect(messages[1].role).toBe('user');
|
|
19
|
+
expect(messages[1].content).toContain('Our Events');
|
|
20
|
+
expect(messages[1].content).toContain('concerts and shows');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('uses "none" when no string context', () => {
|
|
24
|
+
const messages = buildMessages({
|
|
25
|
+
sourceText: 'Book Now',
|
|
26
|
+
sourceLang: 'EN',
|
|
27
|
+
targetLang: 'DE',
|
|
28
|
+
projectContext: 'A travel site',
|
|
29
|
+
stringContext: null,
|
|
30
|
+
});
|
|
31
|
+
expect(messages[1].content.toLowerCase()).toContain('none');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('translate', () => {
|
|
36
|
+
it('calls OpenAI and returns translated text', async () => {
|
|
37
|
+
const mockCreate = vi.fn().mockResolvedValue({
|
|
38
|
+
choices: [{ message: { content: 'Unsere Veranstaltungen' } }],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await translate({
|
|
42
|
+
sourceText: 'Our Events',
|
|
43
|
+
sourceLang: 'EN',
|
|
44
|
+
targetLang: 'DE',
|
|
45
|
+
projectContext: 'A travel site',
|
|
46
|
+
stringContext: null,
|
|
47
|
+
apiKey: 'test-key',
|
|
48
|
+
model: 'gpt-4.1-mini',
|
|
49
|
+
timeout: 10,
|
|
50
|
+
maxRetries: 2,
|
|
51
|
+
_clientOverride: { chat: { completions: { create: mockCreate } } } as any,
|
|
52
|
+
});
|
|
53
|
+
expect(result).toBe('Unsere Veranstaltungen');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('strips whitespace from response', async () => {
|
|
57
|
+
const mockCreate = vi.fn().mockResolvedValue({
|
|
58
|
+
choices: [{ message: { content: ' Hallo \n' } }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await translate({
|
|
62
|
+
sourceText: 'Hello',
|
|
63
|
+
sourceLang: 'EN',
|
|
64
|
+
targetLang: 'DE',
|
|
65
|
+
projectContext: 'test',
|
|
66
|
+
stringContext: null,
|
|
67
|
+
apiKey: 'test-key',
|
|
68
|
+
model: 'gpt-4.1-mini',
|
|
69
|
+
timeout: 10,
|
|
70
|
+
maxRetries: 2,
|
|
71
|
+
_clientOverride: { chat: { completions: { create: mockCreate } } } as any,
|
|
72
|
+
});
|
|
73
|
+
expect(result).toBe('Hallo');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('buildPluralMessages', () => {
|
|
78
|
+
it('builds plural system and user messages', () => {
|
|
79
|
+
const messages = buildPluralMessages({
|
|
80
|
+
one: '{count} message',
|
|
81
|
+
other: '{count} messages',
|
|
82
|
+
sourceLang: 'EN',
|
|
83
|
+
targetLang: 'DE',
|
|
84
|
+
projectContext: 'A messaging app',
|
|
85
|
+
stringContext: 'inbox count',
|
|
86
|
+
});
|
|
87
|
+
expect(messages).toHaveLength(2);
|
|
88
|
+
expect(messages[0].role).toBe('system');
|
|
89
|
+
expect(messages[0].content).toContain('EN');
|
|
90
|
+
expect(messages[0].content).toContain('DE');
|
|
91
|
+
expect(messages[0].content).toContain('CLDR plural');
|
|
92
|
+
expect(messages[0].content).toContain('A messaging app');
|
|
93
|
+
expect(messages[1].role).toBe('user');
|
|
94
|
+
expect(messages[1].content).toContain('{count} message');
|
|
95
|
+
expect(messages[1].content).toContain('{count} messages');
|
|
96
|
+
expect(messages[1].content).toContain('inbox count');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('preserves placeholders in template (not interpreted as template vars)', () => {
|
|
100
|
+
const messages = buildPluralMessages({
|
|
101
|
+
one: '{count} item in {name}\'s cart',
|
|
102
|
+
other: '{count} items in {name}\'s cart',
|
|
103
|
+
sourceLang: 'EN',
|
|
104
|
+
targetLang: 'DE',
|
|
105
|
+
projectContext: 'E-commerce',
|
|
106
|
+
stringContext: null,
|
|
107
|
+
});
|
|
108
|
+
// The user message should contain the literal {count} and {name} placeholders
|
|
109
|
+
expect(messages[1].content).toContain('{count} item in {name}');
|
|
110
|
+
expect(messages[1].content).toContain('{count} items in {name}');
|
|
111
|
+
// System message should still mention {name} as an example placeholder
|
|
112
|
+
expect(messages[0].content).toContain('{name}');
|
|
113
|
+
expect(messages[0].content).toContain('{count}');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('uses "none" for null string context', () => {
|
|
117
|
+
const messages = buildPluralMessages({
|
|
118
|
+
one: '{count} msg',
|
|
119
|
+
other: '{count} msgs',
|
|
120
|
+
sourceLang: 'EN',
|
|
121
|
+
targetLang: 'DE',
|
|
122
|
+
projectContext: 'test',
|
|
123
|
+
stringContext: null,
|
|
124
|
+
});
|
|
125
|
+
expect(messages[1].content).toContain('none');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('translatePlural', () => {
|
|
130
|
+
it('calls OpenAI and returns parsed JSON of plural forms', async () => {
|
|
131
|
+
const mockCreate = vi.fn().mockResolvedValue({
|
|
132
|
+
choices: [{ message: { content: '{"one": "{count} Nachricht", "other": "{count} Nachrichten"}' } }],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = await translatePlural({
|
|
136
|
+
one: '{count} message',
|
|
137
|
+
other: '{count} messages',
|
|
138
|
+
sourceLang: 'EN',
|
|
139
|
+
targetLang: 'DE',
|
|
140
|
+
projectContext: 'test',
|
|
141
|
+
stringContext: null,
|
|
142
|
+
apiKey: 'test-key',
|
|
143
|
+
model: 'gpt-4.1-mini',
|
|
144
|
+
timeout: 10,
|
|
145
|
+
maxRetries: 2,
|
|
146
|
+
_clientOverride: { chat: { completions: { create: mockCreate } } } as any,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
one: '{count} Nachricht',
|
|
151
|
+
other: '{count} Nachrichten',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns multiple categories for Russian', async () => {
|
|
156
|
+
const mockCreate = vi.fn().mockResolvedValue({
|
|
157
|
+
choices: [{ message: { content: JSON.stringify({
|
|
158
|
+
one: '{count} сообщение',
|
|
159
|
+
few: '{count} сообщения',
|
|
160
|
+
many: '{count} сообщений',
|
|
161
|
+
other: '{count} сообщений',
|
|
162
|
+
}) } }],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const result = await translatePlural({
|
|
166
|
+
one: '{count} message',
|
|
167
|
+
other: '{count} messages',
|
|
168
|
+
sourceLang: 'EN',
|
|
169
|
+
targetLang: 'RU',
|
|
170
|
+
projectContext: 'test',
|
|
171
|
+
stringContext: null,
|
|
172
|
+
apiKey: 'test-key',
|
|
173
|
+
model: 'gpt-4.1-mini',
|
|
174
|
+
timeout: 10,
|
|
175
|
+
maxRetries: 2,
|
|
176
|
+
_clientOverride: { chat: { completions: { create: mockCreate } } } as any,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(result.one).toBe('{count} сообщение');
|
|
180
|
+
expect(result.few).toBe('{count} сообщения');
|
|
181
|
+
expect(result.many).toBe('{count} сообщений');
|
|
182
|
+
expect(result.other).toBe('{count} сообщений');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { mkdtempSync } from 'fs';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
|
|
8
|
+
import { runInit, runStats, runTranslate, runTranslatePlural, runWarm } from '../src/cli.js';
|
|
9
|
+
import { TranslationStore } from '../src/storage.js';
|
|
10
|
+
|
|
11
|
+
function hash(text: string): string {
|
|
12
|
+
return createHash('sha256').update(text).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeTmpDir(): string {
|
|
16
|
+
return mkdtempSync(join(tmpdir(), 'transduck-cli-test-'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeConfig(dir: string): string {
|
|
20
|
+
const configPath = join(dir, 'transduck.yaml');
|
|
21
|
+
writeFileSync(configPath, `
|
|
22
|
+
project:
|
|
23
|
+
name: test-project
|
|
24
|
+
context: "A test site"
|
|
25
|
+
languages:
|
|
26
|
+
source: EN
|
|
27
|
+
targets:
|
|
28
|
+
- DE
|
|
29
|
+
storage:
|
|
30
|
+
path: ./translations.duckdb
|
|
31
|
+
backend:
|
|
32
|
+
api_key_env: OPENAI_API_KEY
|
|
33
|
+
model: gpt-4.1-mini
|
|
34
|
+
timeout_seconds: 10
|
|
35
|
+
max_retries: 2
|
|
36
|
+
`);
|
|
37
|
+
return configPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('CLI functions', () => {
|
|
41
|
+
let tmpDir: string;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tmpDir = makeTmpDir();
|
|
45
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('init creates config and db', async () => {
|
|
49
|
+
await runInit({
|
|
50
|
+
dir: tmpDir,
|
|
51
|
+
name: 'test-project',
|
|
52
|
+
context: 'A test site',
|
|
53
|
+
sourceLang: 'EN',
|
|
54
|
+
targetLangs: ['DE'],
|
|
55
|
+
});
|
|
56
|
+
const { existsSync } = await import('fs');
|
|
57
|
+
expect(existsSync(join(tmpDir, 'transduck.yaml'))).toBe(true);
|
|
58
|
+
expect(existsSync(join(tmpDir, 'translations.duckdb'))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('stats on empty db shows zero', async () => {
|
|
62
|
+
const configPath = writeConfig(tmpDir);
|
|
63
|
+
const output = await runStats({ configPath });
|
|
64
|
+
expect(output).toContain('0');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- translate with vars ---
|
|
68
|
+
|
|
69
|
+
it('translate returns cached with vars interpolated', async () => {
|
|
70
|
+
const configPath = writeConfig(tmpDir);
|
|
71
|
+
const dbPath = join(tmpDir, 'translations.duckdb');
|
|
72
|
+
|
|
73
|
+
// Pre-populate the DB
|
|
74
|
+
const store = new TranslationStore(dbPath);
|
|
75
|
+
await store.initialize();
|
|
76
|
+
await store.insert({
|
|
77
|
+
sourceText: 'Welcome {name}', sourceLang: 'EN', targetLang: 'DE',
|
|
78
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
79
|
+
translatedText: 'Willkommen {name}', model: 'gpt-4.1-mini', status: 'translated',
|
|
80
|
+
});
|
|
81
|
+
store.close();
|
|
82
|
+
|
|
83
|
+
const output = await runTranslate({
|
|
84
|
+
text: 'Welcome {name}', targetLang: 'DE',
|
|
85
|
+
configPath, vars: { name: 'Tim' },
|
|
86
|
+
});
|
|
87
|
+
expect(output).toBe('[cached] Willkommen Tim');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- translate-plural ---
|
|
91
|
+
|
|
92
|
+
it('translate-plural returns cached plural form', async () => {
|
|
93
|
+
const configPath = writeConfig(tmpDir);
|
|
94
|
+
const dbPath = join(tmpDir, 'translations.duckdb');
|
|
95
|
+
|
|
96
|
+
// Pre-populate the DB with plural forms
|
|
97
|
+
const store = new TranslationStore(dbPath);
|
|
98
|
+
await store.initialize();
|
|
99
|
+
const sourceKey = '{count} message\x00{count} messages';
|
|
100
|
+
const baseParams = {
|
|
101
|
+
sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
|
|
102
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
103
|
+
};
|
|
104
|
+
await store.insertPlural({
|
|
105
|
+
...baseParams,
|
|
106
|
+
pluralCategory: 'one',
|
|
107
|
+
translatedText: '{count} Nachricht',
|
|
108
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
109
|
+
});
|
|
110
|
+
await store.insertPlural({
|
|
111
|
+
...baseParams,
|
|
112
|
+
pluralCategory: 'other',
|
|
113
|
+
translatedText: '{count} Nachrichten',
|
|
114
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
115
|
+
});
|
|
116
|
+
store.close();
|
|
117
|
+
|
|
118
|
+
const output1 = await runTranslatePlural({
|
|
119
|
+
one: '{count} message', other: '{count} messages',
|
|
120
|
+
count: 1, targetLang: 'DE', configPath,
|
|
121
|
+
});
|
|
122
|
+
expect(output1).toBe('[cached] 1 Nachricht');
|
|
123
|
+
|
|
124
|
+
const output5 = await runTranslatePlural({
|
|
125
|
+
one: '{count} message', other: '{count} messages',
|
|
126
|
+
count: 5, targetLang: 'DE', configPath,
|
|
127
|
+
});
|
|
128
|
+
expect(output5).toBe('[cached] 5 Nachrichten');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- warm with plural entries ---
|
|
132
|
+
|
|
133
|
+
it('warm handles plural entries', async () => {
|
|
134
|
+
const configPath = writeConfig(tmpDir);
|
|
135
|
+
const dbPath = join(tmpDir, 'translations.duckdb');
|
|
136
|
+
|
|
137
|
+
// Pre-populate some plural forms so warm skips them
|
|
138
|
+
const store = new TranslationStore(dbPath);
|
|
139
|
+
await store.initialize();
|
|
140
|
+
const sourceKey = '{count} message\x00{count} messages';
|
|
141
|
+
await store.insertPlural({
|
|
142
|
+
sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
|
|
143
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
144
|
+
pluralCategory: 'one',
|
|
145
|
+
translatedText: '{count} Nachricht',
|
|
146
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
147
|
+
});
|
|
148
|
+
store.close();
|
|
149
|
+
|
|
150
|
+
// Write warm input with both regular and plural entries
|
|
151
|
+
const warmFile = join(tmpDir, 'warm.json');
|
|
152
|
+
writeFileSync(warmFile, JSON.stringify([
|
|
153
|
+
{ plural: true, one: '{count} message', other: '{count} messages' },
|
|
154
|
+
]));
|
|
155
|
+
|
|
156
|
+
const output = await runWarm({
|
|
157
|
+
filePath: warmFile, langs: ['DE'], configPath,
|
|
158
|
+
});
|
|
159
|
+
// Since plural forms already exist, should be skipped
|
|
160
|
+
expect(output).toContain('Skipped: 1');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { loadConfig, type TransduckConfig } from '../src/config.js';
|
|
3
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { mkdtempSync } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
|
|
8
|
+
function makeTmpDir(): string {
|
|
9
|
+
return mkdtempSync(join(tmpdir(), 'transduck-test-'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const VALID_YAML = `
|
|
13
|
+
project:
|
|
14
|
+
name: test-project
|
|
15
|
+
context: "A test travel site"
|
|
16
|
+
languages:
|
|
17
|
+
source: EN
|
|
18
|
+
targets:
|
|
19
|
+
- DE
|
|
20
|
+
- ES
|
|
21
|
+
storage:
|
|
22
|
+
path: ./translations.duckdb
|
|
23
|
+
backend:
|
|
24
|
+
api_key_env: OPENAI_API_KEY
|
|
25
|
+
model: gpt-4.1-mini
|
|
26
|
+
timeout_seconds: 10
|
|
27
|
+
max_retries: 2
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
describe('loadConfig', () => {
|
|
31
|
+
let tmpDir: string;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tmpDir = makeTmpDir();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('loads config from explicit path', () => {
|
|
38
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
39
|
+
writeFileSync(configPath, VALID_YAML);
|
|
40
|
+
const cfg = loadConfig(configPath);
|
|
41
|
+
expect(cfg.projectName).toBe('test-project');
|
|
42
|
+
expect(cfg.projectContext).toBe('A test travel site');
|
|
43
|
+
expect(cfg.sourceLang).toBe('EN');
|
|
44
|
+
expect(cfg.targetLangs).toEqual(['DE', 'ES']);
|
|
45
|
+
expect(cfg.backendModel).toBe('gpt-4.1-mini');
|
|
46
|
+
expect(cfg.backendTimeout).toBe(10);
|
|
47
|
+
expect(cfg.backendMaxRetries).toBe(2);
|
|
48
|
+
expect(cfg.apiKeyEnv).toBe('OPENAI_API_KEY');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('normalizes language codes to uppercase', () => {
|
|
52
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
53
|
+
writeFileSync(configPath, VALID_YAML.replace('EN', 'en').replace('DE', 'de').replace('ES', 'es'));
|
|
54
|
+
const cfg = loadConfig(configPath);
|
|
55
|
+
expect(cfg.sourceLang).toBe('EN');
|
|
56
|
+
expect(cfg.targetLangs).toEqual(['DE', 'ES']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('resolves storage path relative to config file', () => {
|
|
60
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
61
|
+
writeFileSync(configPath, VALID_YAML);
|
|
62
|
+
const cfg = loadConfig(configPath);
|
|
63
|
+
expect(cfg.storagePath).toBe(join(tmpDir, 'translations.duckdb'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('discovers config from TRANSDUCK_CONFIG env var', () => {
|
|
67
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
68
|
+
writeFileSync(configPath, VALID_YAML);
|
|
69
|
+
process.env.TRANSDUCK_CONFIG = configPath;
|
|
70
|
+
const cfg = loadConfig();
|
|
71
|
+
expect(cfg.projectName).toBe('test-project');
|
|
72
|
+
delete process.env.TRANSDUCK_CONFIG;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('throws if config not found', () => {
|
|
76
|
+
delete process.env.TRANSDUCK_CONFIG;
|
|
77
|
+
expect(() => loadConfig(join(tmpDir, 'nonexistent.yaml'))).toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|