diff --git a/frontend/src/app/dashboard/glossaries/page.tsx b/frontend/src/app/dashboard/glossaries/page.tsx index 980370e..1eb06ca 100644 --- a/frontend/src/app/dashboard/glossaries/page.tsx +++ b/frontend/src/app/dashboard/glossaries/page.tsx @@ -19,14 +19,14 @@ import { SUPPORTED_LANGUAGES } from './types'; import { useTranslationStore } from '@/lib/store'; // ── Chips de suggestions pour les consignes de contexte ───────────────────── -// Prompt bodies stay in French (LLM content); labels translate with the UI. -const CONTEXT_SUGGESTIONS: { labelKey: string; value: string }[] = [ - { labelKey: 'glossaries.suggestion.formal', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' }, - { labelKey: 'glossaries.suggestion.proprietary', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' }, - { labelKey: 'glossaries.suggestion.numbers', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' }, - { labelKey: 'glossaries.suggestion.placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' }, - { labelKey: 'glossaries.suggestion.technical', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' }, - { labelKey: 'glossaries.suggestion.concise', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' }, +// Prompt bodies are LLM content — resolved through t() so they follow the UI locale. +const CONTEXT_SUGGESTIONS: { labelKey: string; valueKey: string }[] = [ + { labelKey: 'glossaries.suggestion.formal', valueKey: 'glossaries.suggestion.formal.value' }, + { labelKey: 'glossaries.suggestion.proprietary', valueKey: 'glossaries.suggestion.proprietary.value' }, + { labelKey: 'glossaries.suggestion.numbers', valueKey: 'glossaries.suggestion.numbers.value' }, + { labelKey: 'glossaries.suggestion.placeholders', valueKey: 'glossaries.suggestion.placeholders.value' }, + { labelKey: 'glossaries.suggestion.technical', valueKey: 'glossaries.suggestion.technical.value' }, + { labelKey: 'glossaries.suggestion.concise', valueKey: 'glossaries.suggestion.concise.value' }, ]; export default function GlossariesPage() { @@ -58,17 +58,13 @@ export default function GlossariesPage() { setPromptSaved(false); }, [settings.systemPrompt]); - const handleSavePrompt = async () => { + const handleSavePrompt = () => { setIsSavingPrompt(true); - try { - updateSettings({ systemPrompt }); - await new Promise(resolve => setTimeout(resolve, 300)); - setPromptSaved(true); + updateSettings({ systemPrompt }); + setPromptSaved(true); toast({ title: t('context.saved'), description: t('context.savedDesc') }); setTimeout(() => setPromptSaved(false), 3000); - } finally { - setIsSavingPrompt(false); - } + setIsSavingPrompt(false); }; const handleClearPrompt = () => { @@ -76,7 +72,8 @@ export default function GlossariesPage() { setSystemPrompt(''); }; - const handleAddSuggestion = (value: string) => { + const handleAddSuggestion = (valueKey: string) => { + const value = t(valueKey); const current = systemPrompt.trim(); const newPrompt = current ? `${current}\n${value}` : value; setSystemPrompt(newPrompt); @@ -227,7 +224,7 @@ export default function GlossariesPage() { {CONTEXT_SUGGESTIONS.map((s) => ( + ) : ( +
+ + +
+ )} + + + + + )} + {/* ── RECENT JOBS: client-side history with review access ── */} {showUpload && recentJobs.length > 0 && (
@@ -374,8 +562,33 @@ export default function TranslatePage() { {t('landing.translate.sourceDocument') || 'Document Source'} replaceInputRef.current?.click()} t={t} /> - + {upload.error &&

{t(upload.error)}

} + {upload.files.length > 1 && ( + + )}
)} @@ -383,26 +596,28 @@ export default function TranslatePage() { {(showUpload || showConfiguring) && (
- {!upload.file && ( + {upload.files.length === 0 && (

{t('translate.pleaseLoadFile')}

)} - {upload.file && !config.targetLang && ( + {upload.files.length > 0 && !config.targetLang && (

{t('translate.chooseTargetLang')}

)} @@ -567,7 +782,7 @@ export default function TranslatePage() {
{/* ── CONFIG (upload / configuring / failed) ──────────── */} - {(showUpload || showConfiguring || showFailed) && ( + {(showUpload || showConfiguring || showFailed) && !showBatch && (
{/* Scrollable config content */}
@@ -612,6 +827,17 @@ export default function TranslatePage() {
)} + {/* Context guidelines indicator — ties the glossaries tab to this flow */} + {config.mode === 'llm' && !!systemPrompt?.trim() && ( + + + {t('translate.contextActive')} + + )} + {/* Glossary selector — Pro only; hidden entirely for free users */} {config.isPro && ( {/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */} - {(showUpload || showConfiguring) && ( + {(showUpload || showConfiguring) && !showBatch && (
-
-
- - {/* File Preview */} -
- {loading ? ( -
- -
- ) : preview ? ( -
- {file.type.startsWith('image/') ? ( - Preview - ) : ( -
- {preview} -
- )} -
- ) : ( -
- -
- )} -
- - {/* File Actions */} -
-
- - {t('fileUploader.preview')} -
-
- - -
-
- - - ); -}; - -export function FileUploader() { - const { t } = useI18n(); - const { settings } = useTranslationStore(); - const webllm = useWebLLM(); - - const [file, setFile] = useState(null); - const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage); - const [provider, setProvider] = useState(settings.defaultProvider as ProviderType); - const [translateImages, setTranslateImages] = useState(settings.translateImages); - const [downloadUrl, setDownloadUrl] = useState(null); - const [error, setError] = useState(null); - const [translationStatus, setTranslationStatus] = useState(""); - const [showAdvanced, setShowAdvanced] = useState(false); - const [isTranslating, setTranslating] = useState(false); - const [progress, setProgress] = useState(0); - const fileInputRef = useRef(null); - - // Sync with store settings when they change - useEffect(() => { - setTargetLanguage(settings.defaultTargetLanguage); - setProvider(settings.defaultProvider as ProviderType); - setTranslateImages(settings.translateImages); - }, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]); - - const onDrop = useCallback((acceptedFiles: File[]) => { - if (acceptedFiles.length > 0) { - setFile(acceptedFiles[0]); - setDownloadUrl(null); - setError(null); - } - }, []); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - accept: { - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], - "application/vnd.ms-excel": [".xls"], - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], - "application/msword": [".doc"], - "application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"], - "application/vnd.ms-powerpoint": [".ppt"], - }, - multiple: false, - }); - - const handleTranslate = async () => { - if (!file) return; - - // WebLLM specific validation - if (provider === "webllm") { - if (!webllm.isWebGPUSupported()) { - setError(t('fileUploader.webgpuUnsupported')); - return; - } - if (!webllm.isLoaded) { - setError(t('fileUploader.webllmNotLoaded')); - return; - } - } - - setTranslating(true); - setProgress(0); - setError(null); - setDownloadUrl(null); - setTranslationStatus(""); - - try { - // For WebLLM, use client-side translation - if (provider === "webllm") { - await handleWebLLMTranslation(); - } else { - await handleServerTranslation(); - } - } catch (err) { - setError(err instanceof Error ? err.message : t('fileUploader.translationError')); - } finally { - setTranslating(false); - setTranslationStatus(""); - } - }; - - // Get language name from code - const getLanguageName = (code: string): string => { - const lang = languages.find(l => l.code === code); - return lang ? lang.name : code; - }; - - // WebLLM client-side translation - const handleWebLLMTranslation = async () => { - if (!file) return; - - try { - // Step 1: Extract texts from document - setTranslationStatus(t('fileUploader.extracting')); - setProgress(5); - const extractResult = await extractTextsFromDocument(file); - - if (extractResult.texts.length === 0) { - throw new Error(t('fileUploader.noTranslatable')); - } - - setTranslationStatus(t('fileUploader.foundTexts', { count: extractResult.texts.length })); - setProgress(10); - - // Step 2: Translate each text using WebLLM - const translations: TranslatedText[] = []; - const totalTexts = extractResult.texts.length; - const langName = getLanguageName(targetLanguage); - - for (let i = 0; i < totalTexts; i++) { - const item = extractResult.texts[i]; - setTranslationStatus(t('fileUploader.translatingItem', { - current: String(i + 1), - total: String(totalTexts), - preview: item.text.substring(0, 30), - })); - - const translatedText = await webllm.translate( - item.text, - langName, - settings.systemPrompt || undefined, - settings.glossary || undefined - ); - - translations.push({ - id: item.id, - translated_text: translatedText, - }); - - // Update progress (10% for extraction, 80% for translation, 10% for reconstruction) - const translationProgress = 10 + (80 * (i + 1)) / totalTexts; - setProgress(translationProgress); - } - - // Step 3: Reconstruct document with translations - setTranslationStatus(t('fileUploader.reconstructing')); - setProgress(92); - const blob = await reconstructDocument( - extractResult.session_id, - translations, - targetLanguage - ); - - setProgress(100); - setTranslationStatus(t('fileUploader.translationComplete')); - - const url = URL.createObjectURL(blob); - setDownloadUrl(url); - - } catch (err) { - throw err; - } - }; - - // Server-side translation (existing logic) - const handleServerTranslation = async () => { - if (!file) return; - - // Simulate progress for UX - let currentProgress = 0; - const progressInterval = setInterval(() => { - currentProgress = Math.min(currentProgress + Math.random() * 10, 90); - setProgress(currentProgress); - }, 500); - - try { - const blob = await translateDocument({ - file, - targetLanguage, - provider, - ollamaModel: settings.ollamaModel, - translateImages: translateImages || settings.translateImages, - systemPrompt: settings.systemPrompt, - glossary: settings.glossary, - libreUrl: settings.libreTranslateUrl, - openaiApiKey: settings.openaiApiKey, - openaiModel: settings.openaiModel, - openrouterApiKey: settings.openrouterApiKey, - openrouterModel: settings.openrouterModel, - }); - - clearInterval(progressInterval); - setProgress(100); - - const url = URL.createObjectURL(blob); - setDownloadUrl(url); - } catch (err) { - clearInterval(progressInterval); - throw err; - } - }; - - const handleDownload = () => { - if (!downloadUrl || !file) return; - - const a = document.createElement("a"); - a.href = downloadUrl; - const ext = getFileExtension(file.name); - const baseName = file.name.replace(`.${ext}`, ""); - a.download = `${baseName}_translated.${ext}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - }; - - const getFileExtension = (filename: string) => { - return filename.split(".").pop()?.toLowerCase() || ""; - }; - - const removeFile = () => { - setFile(null); - setDownloadUrl(null); - setError(null); - setProgress(0); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - const FileIcon = file ? fileIcons[getFileExtension(file.name)] : FileText; - - return ( -
- {/* Enhanced File Drop Zone */} - - - - - {t('fileUploader.uploadDocument')} - - - {t('fileUploader.uploadDesc')} - - - - {!file ? ( -
- - - {/* Upload Icon with animation */} -
- -
- -

- {isDragActive - ? t('fileUploader.dropHere') - : t('fileUploader.dragAndDrop')} -

-

- {t('fileUploader.orClickBrowse')} -

- - {/* Supported formats */} -
- {[ - { ext: "xlsx", name: "Excel", icon: FileSpreadsheet, color: "text-green-400" }, - { ext: "docx", name: "Word", icon: FileText, color: "text-blue-400" }, - { ext: "pptx", name: "PowerPoint", icon: Presentation, color: "text-orange-400" }, - ].map((format) => ( -
- - {format.name} - .{format.ext} -
- ))} -
-
- ) : ( - - )} -
-
- - {/* Enhanced Translation Options */} - {file && ( - - - - - {t('fileUploader.translationOptions')} - - - {t('fileUploader.configureSettings')} - - - - {/* Target Language */} -
- - -
- - {/* Provider Selection */} -
- - -
- - {/* Advanced Options Toggle */} - - - {/* Advanced Options */} - {showAdvanced && ( -
-
- - -
-
- )} - - {/* Translate Button */} - - - {/* Progress Bar */} - {isTranslating && ( -
-
- - {translationStatus || t('fileUploader.processing')} - - {Math.round(progress)}% -
- - {provider === "webllm" && ( -
- - {t('fileUploader.translatingLocally')} -
- )} -
- )} - - {/* Error Display */} - {error && ( -
-
- -
-

{t('fileUploader.translationError')}

-

{error}

-
-
-
- )} -
-
- )} - - {/* Enhanced Download Section */} - {downloadUrl && ( - - -
- -
- {t('fileUploader.translationComplete')} - - {t('fileUploader.translationCompleteDesc')} - - -
-
- )} -
- ); -} diff --git a/frontend/src/lib/i18n/messages/en/fileUploader.json b/frontend/src/lib/i18n/messages/en/fileUploader.json index 905da68..e7b8c7e 100644 --- a/frontend/src/lib/i18n/messages/en/fileUploader.json +++ b/frontend/src/lib/i18n/messages/en/fileUploader.json @@ -29,5 +29,6 @@ "fileUploader.reconstructing": "Reconstructing document...", "fileUploader.translatingLocally": "Translating locally with WebLLM...", "fileUploader.error.invalidFormat": "Unsupported format. Accepted: .xlsx, .docx, .pptx, .pdf", - "fileUploader.error.tooLarge": "File too large (max 50 MB)" + "fileUploader.error.tooLarge": "File too large (max 50 MB)", + "fileUploader.error.tooMany": "Queue limited to 10 documents per run." } diff --git a/frontend/src/lib/i18n/messages/en/glossaries.json b/frontend/src/lib/i18n/messages/en/glossaries.json index 25abd6a..28e28d7 100644 --- a/frontend/src/lib/i18n/messages/en/glossaries.json +++ b/frontend/src/lib/i18n/messages/en/glossaries.json @@ -226,5 +226,11 @@ "glossaries.suggestion.numbers": "Numbers", "glossaries.suggestion.placeholders": "Placeholders", "glossaries.suggestion.technical": "Technical terms", - "glossaries.suggestion.concise": "Concise style" + "glossaries.suggestion.concise": "Concise style", + "glossaries.suggestion.formal.value": "Always use a formal, professional tone in your translations.", + "glossaries.suggestion.proprietary.value": "Do not translate proper nouns, brand names, or people's names.", + "glossaries.suggestion.numbers.value": "Keep all figures, percentages, and amounts exactly as written.", + "glossaries.suggestion.placeholders.value": "Do not translate variables in braces such as {name}, {date}, {amount}.", + "glossaries.suggestion.technical.value": "Keep technical terms in the source language; do not translate them.", + "glossaries.suggestion.concise.value": "Prefer short, direct phrasing. Avoid circumlocutions." } diff --git a/frontend/src/lib/i18n/messages/en/translate.json b/frontend/src/lib/i18n/messages/en/translate.json index 4b5b1dc..80133e7 100644 --- a/frontend/src/lib/i18n/messages/en/translate.json +++ b/frontend/src/lib/i18n/messages/en/translate.json @@ -101,5 +101,12 @@ "translate.cancelledTitle": "Translation cancelled", "translate.cancelledDesc": "The job was stopped and the reserved document slot released.", "translate.cancelFailedTitle": "Could not cancel", - "translate.cancelFailedDesc": "The job may have already finished. The display will update shortly." + "translate.cancelFailedDesc": "The job may have already finished. The display will update shortly.", + "translate.contextActive": "Context guidelines active", + "translate.startBatch": "Translate {count} documents", + "translate.batch.runningTitle": "Translating your documents", + "translate.batch.doneTitle": "{done}/{total} translated", + "translate.batch.stop": "Stop the queue", + "translate.batch.downloadAll": "Download all", + "translate.batch.newSession": "New session" } diff --git a/frontend/src/lib/i18n/messages/fr/fileUploader.json b/frontend/src/lib/i18n/messages/fr/fileUploader.json index 239fba4..9752c74 100644 --- a/frontend/src/lib/i18n/messages/fr/fileUploader.json +++ b/frontend/src/lib/i18n/messages/fr/fileUploader.json @@ -29,5 +29,6 @@ "fileUploader.reconstructing": "Reconstruction du document…", "fileUploader.translatingLocally": "Traduction locale avec WebLLM…", "fileUploader.error.invalidFormat": "Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf", - "fileUploader.error.tooLarge": "Fichier trop volumineux (max 50 Mo)" + "fileUploader.error.tooLarge": "Fichier trop volumineux (max 50 Mo)", + "fileUploader.error.tooMany": "File limitée à 10 documents par exécution." } diff --git a/frontend/src/lib/i18n/messages/fr/glossaries.json b/frontend/src/lib/i18n/messages/fr/glossaries.json index 6968dec..948a4a1 100644 --- a/frontend/src/lib/i18n/messages/fr/glossaries.json +++ b/frontend/src/lib/i18n/messages/fr/glossaries.json @@ -226,5 +226,11 @@ "glossaries.suggestion.numbers": "Chiffres", "glossaries.suggestion.placeholders": "Placeholders", "glossaries.suggestion.technical": "Termes techniques", - "glossaries.suggestion.concise": "Style concis" + "glossaries.suggestion.concise": "Style concis", + "glossaries.suggestion.formal.value": "Utilise toujours un ton formel et professionnel dans tes traductions.", + "glossaries.suggestion.proprietary.value": "Ne traduis pas les noms propres, marques et noms de personnes.", + "glossaries.suggestion.numbers.value": "Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.", + "glossaries.suggestion.placeholders.value": "Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.", + "glossaries.suggestion.technical.value": "Conserve les termes techniques en langue originale et ne les traduis pas.", + "glossaries.suggestion.concise.value": "Préfère des formulations courtes et directes. Évite les périphrases." } diff --git a/frontend/src/lib/i18n/messages/fr/translate.json b/frontend/src/lib/i18n/messages/fr/translate.json index 441c907..a53abc0 100644 --- a/frontend/src/lib/i18n/messages/fr/translate.json +++ b/frontend/src/lib/i18n/messages/fr/translate.json @@ -101,5 +101,12 @@ "translate.cancelledTitle": "Traduction annulée", "translate.cancelledDesc": "Le job a été arrêté et le document réservé a été libéré.", "translate.cancelFailedTitle": "Annulation impossible", - "translate.cancelFailedDesc": "Le job est peut-être déjà terminé. L'affichage se mettra à jour sous peu." + "translate.cancelFailedDesc": "Le job est peut-être déjà terminé. L'affichage se mettra à jour sous peu.", + "translate.contextActive": "Consignes de contexte actives", + "translate.startBatch": "Traduire {count} documents", + "translate.batch.runningTitle": "Traduction de vos documents", + "translate.batch.doneTitle": "{done}/{total} traduits", + "translate.batch.stop": "Arrêter la file", + "translate.batch.downloadAll": "Tout télécharger", + "translate.batch.newSession": "Nouvelle session" } diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index e2aa8df..bf5700e 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -33,8 +33,12 @@ interface TranslationState { settings: TranslationSettings; updateSettings: (partial: Partial) => void; setAdminToken: (token: string | undefined) => void; - applyPreset: (preset: string) => void; - clearContext: () => void; +} + +interface TranslationState { + settings: TranslationSettings; + updateSettings: (partial: Partial) => void; + setAdminToken: (token: string | undefined) => void; } const PRESETS: Record = { @@ -116,17 +120,6 @@ export const useTranslationStore = create()( set((state) => ({ settings: { ...state.settings, adminToken: token }, })), - applyPreset: (preset) => - set((state) => ({ - settings: { - ...state.settings, - ...PRESETS[preset], - }, - })), - clearContext: () => - set((state) => ({ - settings: { ...state.settings, systemPrompt: "", glossary: "" }, - })), }), { name: "translation-settings", diff --git a/frontend/src/lib/webllm.ts b/frontend/src/lib/webllm.ts deleted file mode 100644 index f5f2d87..0000000 --- a/frontend/src/lib/webllm.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useState, useCallback } from "react"; - -interface WebLLMState { - isLoaded: boolean; - loading: boolean; - error: string | null; -} - -export function useWebLLM() { - const [state, setState] = useState({ - isLoaded: false, - loading: false, - error: null, - }); - - const isWebGPUSupported = useCallback(() => { - if (typeof navigator === "undefined") return false; - return "gpu" in navigator; - }, []); - - const translate = useCallback( - async ( - _text: string, - _targetLang: string, - _systemPrompt?: string, - _glossary?: string, - ): Promise => { - setState((s) => ({ ...s, loading: true, error: null })); - try { - throw new Error("WebLLM is not available in this environment"); - } catch (err) { - const message = - err instanceof Error ? err.message : "WebLLM translation failed"; - setState((s) => ({ ...s, loading: false, error: message })); - throw err; - } - }, - [], - ); - - return { - ...state, - isWebGPUSupported, - translate, - }; -} diff --git a/frontend/src/test/translationRunner.test.ts b/frontend/src/test/translationRunner.test.ts new file mode 100644 index 0000000..7d812a1 --- /dev/null +++ b/frontend/src/test/translationRunner.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { runTranslationJob } from '../app/dashboard/translate/translationRunner'; + +/** + * Dedicated tests for the multi-file queue runner — the piece that drives + * N sequential translations. Fetch is fully mocked; no network, no React. + */ + +const TEST_CONFIG = { + sourceLang: 'fr', + targetLang: 'en', + mode: 'classic' as const, + provider: 'google', + glossaryId: null, + translateImages: false, +}; + +function makeFile(name = 'rapport_q3.docx'): File { + return new File(['dummy content'], name, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); +} + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: async () => body, + } as unknown as Response; +} + +describe('runTranslationJob', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('submits, polls, and reports completion with progress callbacks', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + // POST /translate + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_1', file_name: 'rapport_q3.docx' } })) + // poll 1: processing at 45% + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_1', status: 'processing', progress_percent: 45, current_step: 'Translating' } })) + // poll 2: completed + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_1', status: 'completed', progress_percent: 100, file_name: 'rapport_q3.docx' } })); + + const onProgress = vi.fn(); + const onJobId = vi.fn(); + const result = await runTranslationJob(makeFile(), TEST_CONFIG, { onProgress, onJobId, pollIntervalMs: 10 }); + + expect(result.status).toBe('completed'); + expect(result.jobId).toBe('job_1'); + expect(result.fileName).toBe('rapport_q3.docx'); + expect(onJobId).toHaveBeenCalledWith('job_1'); + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'processing', progress: 45 })); + + // the multipart submit carried the core fields + const submitCall = fetchMock.mock.calls[0]; + expect(submitCall[0]).toContain('/api/v1/translate'); + const body = submitCall[1]?.body as FormData; + expect(body.get('source_lang')).toBe('fr'); + expect(body.get('target_lang')).toBe('en'); + expect(body.get('mode')).toBe('classic'); + }); + + it('surfaces a failed job with its backend error message', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_2' } })) + .mockResolvedValueOnce( + jsonResponse({ data: { id: 'job_2', status: 'failed', error_message: 'quota exceeded', progress_percent: 20 } }) + ); + + const result = await runTranslationJob(makeFile(), TEST_CONFIG, { pollIntervalMs: 10 }); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('quota exceeded'); + expect(result.jobId).toBe('job_2'); + }); + + it('reports a submit-time HTTP error without polling', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Insufficient credits' }, false, 402)); + + const onProgress = vi.fn(); + const result = await runTranslationJob(makeFile(), TEST_CONFIG, { onProgress, pollIntervalMs: 10 }); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('Insufficient credits'); + expect(fetchMock).toHaveBeenCalledTimes(1); // no polling started + expect(onProgress).not.toHaveBeenCalled(); + }); + + it('aborts between polls, cancels server-side, and never reports completion', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3' } })) + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3', status: 'processing', progress_percent: 30 } })) + // cancel call + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3', status: 'cancelled' } })) + // a late poll that must never be consumed + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3', status: 'completed', progress_percent: 100 } })); + + let calls = 0; + const result = await runTranslationJob(makeFile(), TEST_CONFIG, { + shouldAbort: () => calls++ > 1, // abort after submit + one poll + pollIntervalMs: 10, + }); + + expect(result.status).toBe('aborted'); + expect(result.jobId).toBe('job_3'); + // a cancel was sent for the in-flight job + const cancelCall = fetchMock.mock.calls.find((c) => String(c[0]).includes('/cancel')); + expect(cancelCall).toBeDefined(); + // and the trailing "completed" poll was never reached + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + expect(String(lastCall?.[0])).not.toContain('completed'); + }); + + it('stops polling after repeated network failures', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { id: 'job_4' } })) + .mockRejectedValue(new TypeError('network down')); + + const result = await runTranslationJob(makeFile(), TEST_CONFIG, { pollIntervalMs: 10 }); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('Lost connection'); + }); +});