transduck 0.5.3 → 0.6.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/backend.d.ts +1 -0
- package/dist/backend.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +64 -12
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/handler.d.ts +2 -0
- package/dist/handler.js +79 -41
- package/dist/index.d.ts +17 -2
- package/dist/index.js +191 -92
- package/dist/providers/claude-api.d.ts +1 -0
- package/dist/providers/claude-api.js +11 -0
- package/dist/providers/claude-code.d.ts +1 -0
- package/dist/providers/claude-code.js +6 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/openai-provider.d.ts +1 -0
- package/dist/providers/openai-provider.js +17 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +26 -0
- package/dist/shared-store.d.ts +18 -0
- package/dist/shared-store.js +126 -0
- package/package.json +5 -1
- package/src/backend.ts +10 -0
- package/src/cli.ts +64 -12
- package/src/config.ts +4 -0
- package/src/handler.ts +81 -54
- package/src/index.ts +277 -98
- package/src/providers/claude-api.ts +16 -0
- package/src/providers/claude-code.ts +10 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/openai-provider.ts +24 -0
- package/src/result.ts +30 -0
- package/src/shared-store.ts +157 -0
- package/tests/ait.test.ts +152 -14
- package/tests/backend.test.ts +34 -1
- package/tests/cli.test.ts +33 -0
- package/tests/config.test.ts +40 -0
- package/tests/result.test.ts +62 -0
- package/tests/shared-store.test.ts +210 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import type { LookupParams, InsertParams, InsertPluralParams } from './storage.js';
|
|
3
|
+
|
|
4
|
+
const TABLE_NAME = 'transduck_translations';
|
|
5
|
+
|
|
6
|
+
const CREATE_TABLE = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
|
|
8
|
+
source_lang TEXT NOT NULL,
|
|
9
|
+
target_lang TEXT NOT NULL,
|
|
10
|
+
content_hash TEXT NOT NULL,
|
|
11
|
+
plural_category TEXT NOT NULL DEFAULT '',
|
|
12
|
+
source_text TEXT NOT NULL,
|
|
13
|
+
translated_text TEXT NOT NULL,
|
|
14
|
+
model TEXT NOT NULL,
|
|
15
|
+
status TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL,
|
|
17
|
+
project_context_hash TEXT NOT NULL,
|
|
18
|
+
string_context_hash TEXT NOT NULL,
|
|
19
|
+
string_context TEXT NOT NULL DEFAULT '',
|
|
20
|
+
PRIMARY KEY (source_lang, target_lang, content_hash, plural_category)
|
|
21
|
+
)`;
|
|
22
|
+
|
|
23
|
+
function contentHash(sourceText: string, projectContextHash: string, stringContextHash: string): string {
|
|
24
|
+
return createHash('sha256')
|
|
25
|
+
.update(sourceText + '\x00' + projectContextHash + '\x00' + stringContextHash)
|
|
26
|
+
.digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SharedStore {
|
|
30
|
+
private pool: any;
|
|
31
|
+
private url: string;
|
|
32
|
+
|
|
33
|
+
constructor(url: string) {
|
|
34
|
+
this.url = url;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async initialize(): Promise<void> {
|
|
38
|
+
if (!this.pool) {
|
|
39
|
+
let pg: any;
|
|
40
|
+
try {
|
|
41
|
+
pg = await import('pg');
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'pg is required for shared Postgres storage. Install it with: npm install pg'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const Pool = pg.default?.Pool ?? pg.Pool;
|
|
48
|
+
this.pool = new Pool({ connectionString: this.url });
|
|
49
|
+
}
|
|
50
|
+
await this.getPool().query(CREATE_TABLE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private getPool(): any {
|
|
54
|
+
if (!this.pool) throw new Error('SharedStore not initialized. Call initialize() first.');
|
|
55
|
+
return this.pool;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async lookup(params: LookupParams): Promise<string | null> {
|
|
59
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
60
|
+
const result = await this.getPool().query(
|
|
61
|
+
`SELECT translated_text FROM ${TABLE_NAME}
|
|
62
|
+
WHERE source_lang = $1 AND target_lang = $2 AND content_hash = $3
|
|
63
|
+
AND plural_category = '' AND status = 'translated'`,
|
|
64
|
+
[params.sourceLang, params.targetLang, chash],
|
|
65
|
+
);
|
|
66
|
+
if (result.rows.length > 0) {
|
|
67
|
+
return result.rows[0].translated_text;
|
|
68
|
+
}
|
|
69
|
+
const altResult = await this.getPool().query(
|
|
70
|
+
`SELECT 1 FROM ${TABLE_NAME}
|
|
71
|
+
WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
|
|
72
|
+
AND status = 'translated' LIMIT 1`,
|
|
73
|
+
[params.sourceText, params.sourceLang, params.targetLang],
|
|
74
|
+
);
|
|
75
|
+
if (altResult.rows.length > 0) {
|
|
76
|
+
console.debug(
|
|
77
|
+
`Translation exists in shared store for '${params.sourceText}' (${params.sourceLang}→${params.targetLang}) but with a different project context hash. Check project.context in transduck.yaml.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async insert(params: InsertParams): Promise<void> {
|
|
84
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
85
|
+
await this.getPool().query(
|
|
86
|
+
`INSERT INTO ${TABLE_NAME}
|
|
87
|
+
(source_lang, target_lang, content_hash, plural_category, source_text,
|
|
88
|
+
translated_text, model, status, created_at, project_context_hash,
|
|
89
|
+
string_context_hash, string_context)
|
|
90
|
+
VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11)
|
|
91
|
+
ON CONFLICT DO NOTHING`,
|
|
92
|
+
[
|
|
93
|
+
params.sourceLang, params.targetLang, chash,
|
|
94
|
+
params.sourceText, params.translatedText, params.model, params.status,
|
|
95
|
+
new Date().toISOString(),
|
|
96
|
+
params.projectContextHash, params.stringContextHash, params.stringContext ?? '',
|
|
97
|
+
],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async lookupPlural(params: LookupParams): Promise<Record<string, string>> {
|
|
102
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
103
|
+
const result = await this.getPool().query(
|
|
104
|
+
`SELECT plural_category, translated_text FROM ${TABLE_NAME}
|
|
105
|
+
WHERE source_lang = $1 AND target_lang = $2 AND content_hash = $3
|
|
106
|
+
AND plural_category != '' AND status = 'translated'`,
|
|
107
|
+
[params.sourceLang, params.targetLang, chash],
|
|
108
|
+
);
|
|
109
|
+
const forms: Record<string, string> = {};
|
|
110
|
+
for (const row of result.rows) {
|
|
111
|
+
forms[row.plural_category] = row.translated_text;
|
|
112
|
+
}
|
|
113
|
+
return forms;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async insertPlural(params: InsertPluralParams): Promise<void> {
|
|
117
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
118
|
+
await this.getPool().query(
|
|
119
|
+
`INSERT INTO ${TABLE_NAME}
|
|
120
|
+
(source_lang, target_lang, content_hash, plural_category, source_text,
|
|
121
|
+
translated_text, model, status, created_at, project_context_hash,
|
|
122
|
+
string_context_hash, string_context)
|
|
123
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
124
|
+
ON CONFLICT DO NOTHING`,
|
|
125
|
+
[
|
|
126
|
+
params.sourceLang, params.targetLang, chash, params.pluralCategory,
|
|
127
|
+
params.sourceText, params.translatedText, params.model, params.status,
|
|
128
|
+
new Date().toISOString(),
|
|
129
|
+
params.projectContextHash, params.stringContextHash, params.stringContext ?? '',
|
|
130
|
+
],
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async stats(): Promise<{ totalTranslations: number; totalFailed: number; byLanguage: Record<string, number> }> {
|
|
135
|
+
const pool = this.getPool();
|
|
136
|
+
const result = await pool.query(
|
|
137
|
+
`SELECT target_lang, status, COUNT(*) as count FROM ${TABLE_NAME} GROUP BY target_lang, status`,
|
|
138
|
+
);
|
|
139
|
+
let totalTranslations = 0;
|
|
140
|
+
let totalFailed = 0;
|
|
141
|
+
const byLanguage: Record<string, number> = {};
|
|
142
|
+
for (const row of result.rows) {
|
|
143
|
+
const count = parseInt(row.count, 10);
|
|
144
|
+
if (row.status === 'translated') {
|
|
145
|
+
totalTranslations += count;
|
|
146
|
+
byLanguage[row.target_lang] = (byLanguage[row.target_lang] ?? 0) + count;
|
|
147
|
+
} else if (row.status === 'failed') {
|
|
148
|
+
totalFailed += count;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { totalTranslations, totalFailed, byLanguage };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async close(): Promise<void> {
|
|
155
|
+
if (this.pool) await this.pool.end();
|
|
156
|
+
}
|
|
157
|
+
}
|
package/tests/ait.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ function hash(text: string): string {
|
|
|
9
9
|
return createHash('sha256').update(text).digest('hex');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
function makeConfig(tmpDir: string): TransduckConfig {
|
|
12
|
+
function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): TransduckConfig {
|
|
13
13
|
return {
|
|
14
14
|
projectName: 'test',
|
|
15
15
|
projectContext: 'A test site',
|
|
@@ -22,7 +22,9 @@ function makeConfig(tmpDir: string): TransduckConfig {
|
|
|
22
22
|
backendModel: 'gpt-4.1-mini',
|
|
23
23
|
backendTimeout: 10,
|
|
24
24
|
backendMaxRetries: 2,
|
|
25
|
+
sharedUrl: null,
|
|
25
26
|
readOnly: false,
|
|
27
|
+
...overrides,
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -42,7 +44,10 @@ describe('ait', () => {
|
|
|
42
44
|
await initialize(makeConfig(tmpDir));
|
|
43
45
|
setLanguage('EN');
|
|
44
46
|
const result = await ait('Hello');
|
|
45
|
-
expect(result).toBe('Hello');
|
|
47
|
+
expect(result.toString()).toBe('Hello');
|
|
48
|
+
expect(result.pending).toBe(false);
|
|
49
|
+
expect(result.sourceLang).toBe('EN');
|
|
50
|
+
expect(result.lang).toBe('EN');
|
|
46
51
|
});
|
|
47
52
|
|
|
48
53
|
it('returns cached translation', async () => {
|
|
@@ -60,7 +65,9 @@ describe('ait', () => {
|
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
const result = await ait('Hello');
|
|
63
|
-
expect(result).toBe('Hallo');
|
|
68
|
+
expect(result.toString()).toBe('Hallo');
|
|
69
|
+
expect(result.pending).toBe(false);
|
|
70
|
+
expect(result.lang).toBe('DE');
|
|
64
71
|
});
|
|
65
72
|
|
|
66
73
|
it('throws if not initialized', async () => {
|
|
@@ -81,7 +88,7 @@ describe('ait', () => {
|
|
|
81
88
|
await initialize(makeConfig(tmpDir));
|
|
82
89
|
setLanguage('EN');
|
|
83
90
|
const result = await ait('Welcome {name}', undefined, { name: 'Tim' });
|
|
84
|
-
expect(result).toBe('Welcome Tim');
|
|
91
|
+
expect(result.toString()).toBe('Welcome Tim');
|
|
85
92
|
});
|
|
86
93
|
|
|
87
94
|
it('interpolates vars in cached translation', async () => {
|
|
@@ -98,7 +105,7 @@ describe('ait', () => {
|
|
|
98
105
|
});
|
|
99
106
|
|
|
100
107
|
const result = await ait('Welcome {name}', undefined, { name: 'Tim' });
|
|
101
|
-
expect(result).toBe('Willkommen Tim');
|
|
108
|
+
expect(result.toString()).toBe('Willkommen Tim');
|
|
102
109
|
});
|
|
103
110
|
|
|
104
111
|
it('returns text with placeholders when no vars provided', async () => {
|
|
@@ -106,7 +113,72 @@ describe('ait', () => {
|
|
|
106
113
|
await initialize(makeConfig(tmpDir));
|
|
107
114
|
setLanguage('EN');
|
|
108
115
|
const result = await ait('Welcome {name}');
|
|
109
|
-
expect(result).toBe('Welcome {name}');
|
|
116
|
+
expect(result.toString()).toBe('Welcome {name}');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- sourceLang override ---
|
|
120
|
+
|
|
121
|
+
it('uses custom sourceLang when provided', async () => {
|
|
122
|
+
const { ait, setLanguage, initialize, _getStore } = await import('../src/index.js');
|
|
123
|
+
const cfg = makeConfig(tmpDir);
|
|
124
|
+
await initialize(cfg);
|
|
125
|
+
setLanguage('DE');
|
|
126
|
+
|
|
127
|
+
const store = _getStore();
|
|
128
|
+
await store!.insert({
|
|
129
|
+
sourceText: 'Hola', sourceLang: 'ES', targetLang: 'DE',
|
|
130
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
131
|
+
translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated', stringContext: '',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = await ait('Hola', { sourceLang: 'ES' });
|
|
135
|
+
expect(result.toString()).toBe('Hallo');
|
|
136
|
+
expect(result.sourceLang).toBe('ES');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- background mode ---
|
|
140
|
+
|
|
141
|
+
it('background mode returns immediately with pending=true', async () => {
|
|
142
|
+
const { ait, setLanguage, initialize } = await import('../src/index.js');
|
|
143
|
+
await initialize(makeConfig(tmpDir));
|
|
144
|
+
setLanguage('DE');
|
|
145
|
+
|
|
146
|
+
const result = await ait('Hello', { background: true });
|
|
147
|
+
expect(result.pending).toBe(true);
|
|
148
|
+
expect(result.toString()).toBe('Hello'); // returns source text
|
|
149
|
+
expect(result.sourceLang).toBe('EN');
|
|
150
|
+
expect(result.lang).toBe('DE');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --- two-tier lookup ---
|
|
154
|
+
|
|
155
|
+
it('falls back to shared store on local miss', async () => {
|
|
156
|
+
const { ait, setLanguage, initialize, _setSharedStore, _getStore } = await import('../src/index.js');
|
|
157
|
+
await initialize(makeConfig(tmpDir));
|
|
158
|
+
setLanguage('DE');
|
|
159
|
+
|
|
160
|
+
// Create a mock shared store
|
|
161
|
+
const mockShared = {
|
|
162
|
+
lookup: vi.fn().mockResolvedValue('Hallo von shared'),
|
|
163
|
+
lookupPlural: vi.fn().mockResolvedValue({}),
|
|
164
|
+
insert: vi.fn().mockResolvedValue(undefined),
|
|
165
|
+
insertPlural: vi.fn().mockResolvedValue(undefined),
|
|
166
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
167
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
168
|
+
stats: vi.fn().mockResolvedValue({ totalTranslations: 0, totalFailed: 0, byLanguage: {} }),
|
|
169
|
+
};
|
|
170
|
+
_setSharedStore(mockShared as any);
|
|
171
|
+
|
|
172
|
+
const result = await ait('Hello');
|
|
173
|
+
expect(result.toString()).toBe('Hallo von shared');
|
|
174
|
+
expect(result.pending).toBe(false);
|
|
175
|
+
// Should have propagated to local store
|
|
176
|
+
const local = _getStore();
|
|
177
|
+
const localResult = await local!.lookup({
|
|
178
|
+
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
179
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
180
|
+
});
|
|
181
|
+
expect(localResult).toBe('Hallo von shared');
|
|
110
182
|
});
|
|
111
183
|
});
|
|
112
184
|
|
|
@@ -130,7 +202,8 @@ describe('aitPlural', () => {
|
|
|
130
202
|
await initialize(makeConfig(tmpDir));
|
|
131
203
|
setLanguage('EN');
|
|
132
204
|
const result = await aitPlural('{count} message', '{count} messages', 1);
|
|
133
|
-
expect(result).toBe('1 message');
|
|
205
|
+
expect(result.toString()).toBe('1 message');
|
|
206
|
+
expect(result.pending).toBe(false);
|
|
134
207
|
});
|
|
135
208
|
|
|
136
209
|
it('same-language 2-form: selects other for count=5', async () => {
|
|
@@ -138,7 +211,7 @@ describe('aitPlural', () => {
|
|
|
138
211
|
await initialize(makeConfig(tmpDir));
|
|
139
212
|
setLanguage('EN');
|
|
140
213
|
const result = await aitPlural('{count} message', '{count} messages', 5);
|
|
141
|
-
expect(result).toBe('5 messages');
|
|
214
|
+
expect(result.toString()).toBe('5 messages');
|
|
142
215
|
});
|
|
143
216
|
|
|
144
217
|
it('same-language 2-form: selects other for count=0', async () => {
|
|
@@ -146,7 +219,7 @@ describe('aitPlural', () => {
|
|
|
146
219
|
await initialize(makeConfig(tmpDir));
|
|
147
220
|
setLanguage('EN');
|
|
148
221
|
const result = await aitPlural('{count} message', '{count} messages', 0);
|
|
149
|
-
expect(result).toBe('0 messages');
|
|
222
|
+
expect(result.toString()).toBe('0 messages');
|
|
150
223
|
});
|
|
151
224
|
|
|
152
225
|
it('same-language: includes custom vars', async () => {
|
|
@@ -159,7 +232,7 @@ describe('aitPlural', () => {
|
|
|
159
232
|
3,
|
|
160
233
|
{ vars: { name: 'Tim' } },
|
|
161
234
|
);
|
|
162
|
-
expect(result).toBe("3 items in Tim's cart");
|
|
235
|
+
expect(result.toString()).toBe("3 items in Tim's cart");
|
|
163
236
|
});
|
|
164
237
|
|
|
165
238
|
it('returns cached plural form', async () => {
|
|
@@ -189,10 +262,10 @@ describe('aitPlural', () => {
|
|
|
189
262
|
});
|
|
190
263
|
|
|
191
264
|
const result1 = await aitPlural('{count} message', '{count} messages', 1);
|
|
192
|
-
expect(result1).toBe('1 Nachricht');
|
|
265
|
+
expect(result1.toString()).toBe('1 Nachricht');
|
|
193
266
|
|
|
194
267
|
const result5 = await aitPlural('{count} message', '{count} messages', 5);
|
|
195
|
-
expect(result5).toBe('5 Nachrichten');
|
|
268
|
+
expect(result5.toString()).toBe('5 Nachrichten');
|
|
196
269
|
});
|
|
197
270
|
|
|
198
271
|
it('auto-includes count in vars', async () => {
|
|
@@ -201,7 +274,7 @@ describe('aitPlural', () => {
|
|
|
201
274
|
setLanguage('EN');
|
|
202
275
|
// count is auto-added even without explicit vars
|
|
203
276
|
const result = await aitPlural('{count} message', '{count} messages', 42);
|
|
204
|
-
expect(result).toBe('42 messages');
|
|
277
|
+
expect(result.toString()).toBe('42 messages');
|
|
205
278
|
});
|
|
206
279
|
|
|
207
280
|
it('does not override explicit count in vars', async () => {
|
|
@@ -211,6 +284,71 @@ describe('aitPlural', () => {
|
|
|
211
284
|
// If user provides count in vars, that value should be used
|
|
212
285
|
const result = await aitPlural('{count} message', '{count} messages', 1, { vars: { count: 99 } });
|
|
213
286
|
// count=1 selects "one" form, but the {count} var is 99 from explicit vars
|
|
214
|
-
expect(result).toBe('99 message');
|
|
287
|
+
expect(result.toString()).toBe('99 message');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// --- sourceLang override for plural ---
|
|
291
|
+
|
|
292
|
+
it('uses custom sourceLang for plural', async () => {
|
|
293
|
+
const { aitPlural, setLanguage, initialize, _getStore } = await import('../src/index.js');
|
|
294
|
+
const cfg = makeConfig(tmpDir);
|
|
295
|
+
await initialize(cfg);
|
|
296
|
+
setLanguage('DE');
|
|
297
|
+
|
|
298
|
+
const store = _getStore();
|
|
299
|
+
const sourceKey = '{count} mensaje\x00{count} mensajes';
|
|
300
|
+
await store!.insertPlural({
|
|
301
|
+
sourceText: sourceKey, sourceLang: 'ES', targetLang: 'DE',
|
|
302
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
303
|
+
pluralCategory: 'other',
|
|
304
|
+
translatedText: '{count} Nachrichten',
|
|
305
|
+
model: 'gpt-4.1-mini', status: 'translated', stringContext: '',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = await aitPlural('{count} mensaje', '{count} mensajes', 5, { sourceLang: 'ES' });
|
|
309
|
+
expect(result.toString()).toBe('5 Nachrichten');
|
|
310
|
+
expect(result.sourceLang).toBe('ES');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// --- background mode for plural ---
|
|
314
|
+
|
|
315
|
+
it('background mode returns immediately with pending=true for plural', async () => {
|
|
316
|
+
const { aitPlural, setLanguage, initialize } = await import('../src/index.js');
|
|
317
|
+
await initialize(makeConfig(tmpDir));
|
|
318
|
+
setLanguage('DE');
|
|
319
|
+
|
|
320
|
+
const result = await aitPlural('{count} message', '{count} messages', 5, { background: true });
|
|
321
|
+
expect(result.pending).toBe(true);
|
|
322
|
+
expect(result.toString()).toBe('5 messages'); // source fallback
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('detectLanguage', () => {
|
|
327
|
+
let tmpDir: string;
|
|
328
|
+
|
|
329
|
+
beforeEach(async () => {
|
|
330
|
+
const mod = await import('../src/index.js');
|
|
331
|
+
mod._resetState();
|
|
332
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'transduck-detect-test-'));
|
|
333
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('throws if not initialized', async () => {
|
|
337
|
+
const { detectLanguage } = await import('../src/index.js');
|
|
338
|
+
await expect(detectLanguage('Hola')).rejects.toThrow('transduck not initialized');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('delegates to backend detectLanguage with stored config', async () => {
|
|
342
|
+
const backendMod = await import('../src/backend.js');
|
|
343
|
+
const spy = vi.spyOn(backendMod, 'detectLanguage').mockResolvedValue('ES');
|
|
344
|
+
|
|
345
|
+
const { detectLanguage, initialize } = await import('../src/index.js');
|
|
346
|
+
await initialize(makeConfig(tmpDir));
|
|
347
|
+
|
|
348
|
+
const result = await detectLanguage('Hola, como estas?');
|
|
349
|
+
expect(result).toBe('ES');
|
|
350
|
+
expect(spy).toHaveBeenCalledWith('Hola, como estas?', expect.objectContaining({ projectName: 'test' }));
|
|
351
|
+
|
|
352
|
+
spy.mockRestore();
|
|
215
353
|
});
|
|
216
354
|
});
|
package/tests/backend.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { buildMessages, buildPluralMessages, translate, translatePlural } from '../src/backend.js';
|
|
2
|
+
import { buildMessages, buildPluralMessages, translate, translatePlural, detectLanguage } from '../src/backend.js';
|
|
3
3
|
import type { TransduckConfig } from '../src/config.js';
|
|
4
4
|
|
|
5
5
|
function makeConfig(): TransduckConfig {
|
|
@@ -15,6 +15,7 @@ function makeConfig(): TransduckConfig {
|
|
|
15
15
|
backendModel: 'gpt-4.1-mini',
|
|
16
16
|
backendTimeout: 10,
|
|
17
17
|
backendMaxRetries: 2,
|
|
18
|
+
sharedUrl: null,
|
|
18
19
|
readOnly: false,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
@@ -184,3 +185,35 @@ describe('translatePlural', () => {
|
|
|
184
185
|
expect(result.other).toBe('{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439');
|
|
185
186
|
});
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
describe('detectLanguage', () => {
|
|
190
|
+
it('calls OpenAI and returns language code', async () => {
|
|
191
|
+
const mockCreate = vi.fn().mockResolvedValue({
|
|
192
|
+
choices: [{ message: { content: 'ES' } }],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const cfg = makeConfig();
|
|
196
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
197
|
+
|
|
198
|
+
const result = await detectLanguage(
|
|
199
|
+
'Hola, como estas?', cfg,
|
|
200
|
+
{ chat: { completions: { create: mockCreate } } } as any,
|
|
201
|
+
);
|
|
202
|
+
expect(result).toBe('ES');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('normalizes response to uppercase and trims whitespace', async () => {
|
|
206
|
+
const mockCreate = vi.fn().mockResolvedValue({
|
|
207
|
+
choices: [{ message: { content: ' fr \n' } }],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const cfg = makeConfig();
|
|
211
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
212
|
+
|
|
213
|
+
const result = await detectLanguage(
|
|
214
|
+
'Bonjour le monde', cfg,
|
|
215
|
+
{ chat: { completions: { create: mockCreate } } } as any,
|
|
216
|
+
);
|
|
217
|
+
expect(result).toBe('FR');
|
|
218
|
+
});
|
|
219
|
+
});
|
package/tests/cli.test.ts
CHANGED
|
@@ -220,4 +220,37 @@ describe('CLI functions', () => {
|
|
|
220
220
|
});
|
|
221
221
|
expect(output).toContain('Skipped: 1');
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
it('init includes shared_url in config when provided', async () => {
|
|
225
|
+
await runInit({
|
|
226
|
+
dir: tmpDir,
|
|
227
|
+
name: 'test-project',
|
|
228
|
+
context: 'A test site',
|
|
229
|
+
sourceLang: 'EN',
|
|
230
|
+
targetLangs: ['DE'],
|
|
231
|
+
sharedUrl: 'postgres://localhost/transduck',
|
|
232
|
+
});
|
|
233
|
+
const configContent = readFileSync(join(tmpDir, 'transduck.yaml'), 'utf-8');
|
|
234
|
+
expect(configContent).toContain('shared_url');
|
|
235
|
+
expect(configContent).toContain('postgres://localhost/transduck');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('init omits shared_url when not provided', async () => {
|
|
239
|
+
await runInit({
|
|
240
|
+
dir: tmpDir,
|
|
241
|
+
name: 'test-project',
|
|
242
|
+
context: 'A test site',
|
|
243
|
+
sourceLang: 'EN',
|
|
244
|
+
targetLangs: ['DE'],
|
|
245
|
+
});
|
|
246
|
+
const configContent = readFileSync(join(tmpDir, 'transduck.yaml'), 'utf-8');
|
|
247
|
+
expect(configContent).not.toContain('shared_url');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('stats shows local store header', async () => {
|
|
251
|
+
const configPath = writeConfig(tmpDir);
|
|
252
|
+
const output = await runStats({ configPath });
|
|
253
|
+
expect(output).toContain('Local store:');
|
|
254
|
+
expect(output).toContain('Total translations: 0');
|
|
255
|
+
});
|
|
223
256
|
});
|
package/tests/config.test.ts
CHANGED
|
@@ -72,6 +72,46 @@ describe('loadConfig', () => {
|
|
|
72
72
|
delete process.env.TRANSDUCK_CONFIG;
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('defaults shared_url to null', () => {
|
|
76
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
77
|
+
writeFileSync(configPath, VALID_YAML);
|
|
78
|
+
const cfg = loadConfig(configPath);
|
|
79
|
+
expect(cfg.sharedUrl).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reads shared_url from yaml', () => {
|
|
83
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
84
|
+
const yaml = VALID_YAML.replace(
|
|
85
|
+
' path: ./translations.db',
|
|
86
|
+
' path: ./translations.db\n shared_url: postgres://localhost/transduck'
|
|
87
|
+
);
|
|
88
|
+
writeFileSync(configPath, yaml);
|
|
89
|
+
const cfg = loadConfig(configPath);
|
|
90
|
+
expect(cfg.sharedUrl).toBe('postgres://localhost/transduck');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('env var TRANSDUCK_SHARED_URL overrides yaml shared_url', () => {
|
|
94
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
95
|
+
const yaml = VALID_YAML.replace(
|
|
96
|
+
' path: ./translations.db',
|
|
97
|
+
' path: ./translations.db\n shared_url: postgres://localhost/from-yaml'
|
|
98
|
+
);
|
|
99
|
+
writeFileSync(configPath, yaml);
|
|
100
|
+
process.env.TRANSDUCK_SHARED_URL = 'postgres://localhost/from-env';
|
|
101
|
+
const cfg = loadConfig(configPath);
|
|
102
|
+
expect(cfg.sharedUrl).toBe('postgres://localhost/from-env');
|
|
103
|
+
delete process.env.TRANSDUCK_SHARED_URL;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reads shared_url from env var when not in yaml', () => {
|
|
107
|
+
const configPath = join(tmpDir, 'transduck.yaml');
|
|
108
|
+
writeFileSync(configPath, VALID_YAML);
|
|
109
|
+
process.env.TRANSDUCK_SHARED_URL = 'postgres://localhost/env-only';
|
|
110
|
+
const cfg = loadConfig(configPath);
|
|
111
|
+
expect(cfg.sharedUrl).toBe('postgres://localhost/env-only');
|
|
112
|
+
delete process.env.TRANSDUCK_SHARED_URL;
|
|
113
|
+
});
|
|
114
|
+
|
|
75
115
|
it('throws if config not found', () => {
|
|
76
116
|
delete process.env.TRANSDUCK_CONFIG;
|
|
77
117
|
expect(() => loadConfig(join(tmpDir, 'nonexistent.yaml'))).toThrow();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TranslationResult } from '../src/result.js';
|
|
3
|
+
|
|
4
|
+
describe('TranslationResult', () => {
|
|
5
|
+
it('stores the translated text', () => {
|
|
6
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
7
|
+
expect(r.toString()).toBe('Hallo');
|
|
8
|
+
expect(r.valueOf()).toBe('Hallo');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('exposes metadata properties', () => {
|
|
12
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
13
|
+
expect(r.pending).toBe(false);
|
|
14
|
+
expect(r.sourceLang).toBe('EN');
|
|
15
|
+
expect(r.lang).toBe('DE');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('works in template literals', () => {
|
|
19
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
20
|
+
expect(`Say ${r}`).toBe('Say Hallo');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('works with string concatenation', () => {
|
|
24
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
25
|
+
expect(r + ' Welt').toBe('Hallo Welt');
|
|
26
|
+
expect('Sag ' + r).toBe('Sag Hallo');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('supports loose equality with plain strings', () => {
|
|
30
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
31
|
+
expect(r == 'Hallo').toBe(true);
|
|
32
|
+
expect(r == 'Other').toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('has correct length and indexing', () => {
|
|
36
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
37
|
+
expect(r.length).toBe(5);
|
|
38
|
+
expect(r[0]).toBe('H');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('supports string methods', () => {
|
|
42
|
+
const r = new TranslationResult('Hallo Welt', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
43
|
+
expect(r.includes('Welt')).toBe(true);
|
|
44
|
+
expect(r.toUpperCase()).toBe('HALLO WELT');
|
|
45
|
+
expect(r.slice(0, 5)).toBe('Hallo');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('tracks pending state', () => {
|
|
49
|
+
const pending = new TranslationResult('Hello', { pending: true, sourceLang: 'EN', lang: 'DE' });
|
|
50
|
+
expect(pending.pending).toBe(true);
|
|
51
|
+
expect(pending.toString()).toBe('Hello');
|
|
52
|
+
|
|
53
|
+
const done = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
54
|
+
expect(done.pending).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is an instance of String and TranslationResult', () => {
|
|
58
|
+
const r = new TranslationResult('Hallo', { pending: false, sourceLang: 'EN', lang: 'DE' });
|
|
59
|
+
expect(r instanceof String).toBe(true);
|
|
60
|
+
expect(r instanceof TranslationResult).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|