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.
@@ -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
  });
@@ -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
  });
@@ -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
+ });