mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(reader): add in-reader text translation with Google/Azure fallback for EPUB
This commit is contained in:
@@ -21,6 +21,8 @@ import SelectionPopup from './epub/components/SelectionPopup.vue'
|
||||
import ReaderSearchPanel from './epub/components/ReaderSearchPanel.vue'
|
||||
import NoteDialog from './epub/components/NoteDialog.vue'
|
||||
import DictionaryPopover from './epub/components/DictionaryPopover.vue'
|
||||
import TranslationPopover from './epub/components/TranslationPopover.vue'
|
||||
import TranslationSheet from './epub/components/TranslationSheet.vue'
|
||||
import PdfV4ReaderView from './pdf-v4/PdfV4ReaderView.vue'
|
||||
import CbzReaderView from './cbz/CbzReaderView.vue'
|
||||
import AudiobookReaderView from './audiobook/AudiobookReaderView.vue'
|
||||
@@ -101,6 +103,11 @@ const showDictionary = ref(false)
|
||||
const dictionaryWord = ref('')
|
||||
const dictionaryPosition = ref({ x: 0, y: 0, showBelow: false })
|
||||
|
||||
const showTranslation = ref(false)
|
||||
const translationText = ref('')
|
||||
const translationPosition = ref({ x: 0, y: 0, showBelow: false })
|
||||
const isMobile = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
|
||||
function handleDefine() {
|
||||
dictionaryWord.value = selection.text.value
|
||||
dictionaryPosition.value = {
|
||||
@@ -112,6 +119,17 @@ function handleDefine() {
|
||||
showDictionary.value = true
|
||||
}
|
||||
|
||||
function handleTranslate() {
|
||||
translationText.value = selection.text.value
|
||||
translationPosition.value = {
|
||||
x: selection.position.value.x,
|
||||
y: selection.position.value.y,
|
||||
showBelow: selection.showBelow.value,
|
||||
}
|
||||
selection.dismiss()
|
||||
showTranslation.value = true
|
||||
}
|
||||
|
||||
function onRelocateHandler(detail: RelocateDetail) {
|
||||
progress.onRelocate(detail)
|
||||
onActivity()
|
||||
@@ -434,6 +452,7 @@ function closeSearch() {
|
||||
@copy="selection.dismiss()"
|
||||
@highlight="handleHighlight"
|
||||
@search="() => openSearchWithText(selection.text.value)"
|
||||
@translate="handleTranslate"
|
||||
@define="handleDefine"
|
||||
@note="selection.openNoteDialog()"
|
||||
@deleteAnnotation="handleDeleteAnnotation"
|
||||
@@ -447,6 +466,15 @@ function closeSearch() {
|
||||
:lang="bookLanguage"
|
||||
@close="showDictionary = false"
|
||||
/>
|
||||
|
||||
<TranslationPopover
|
||||
v-if="showTranslation && !isMobile"
|
||||
:text="translationText"
|
||||
:position="translationPosition"
|
||||
@close="showTranslation = false"
|
||||
/>
|
||||
|
||||
<TranslationSheet v-if="showTranslation && isMobile" :text="translationText" @close="showTranslation = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { BookA, Copy, FileText, Highlighter, Search, Trash2 } from 'lucide-vue-next'
|
||||
import { BookA, Copy, FileText, Highlighter, Languages, Search, Trash2 } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -15,6 +15,7 @@ const emit = defineEmits<{
|
||||
copy: []
|
||||
highlight: [color: string, style: string]
|
||||
search: []
|
||||
translate: []
|
||||
define: []
|
||||
note: []
|
||||
deleteAnnotation: [id: number]
|
||||
@@ -115,6 +116,18 @@ function onCopy() {
|
||||
<TooltipContent>Search</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="emit('translate')"
|
||||
>
|
||||
<Languages :size="15" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Translate</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Copy, RefreshCw } from 'lucide-vue-next'
|
||||
import type { SupportedLanguage, TranslationResult } from '../services/translation.types'
|
||||
|
||||
const props = defineProps<{
|
||||
originalText: string
|
||||
loading: boolean
|
||||
error: string | null
|
||||
result: TranslationResult | null
|
||||
targetLang: string
|
||||
languages: SupportedLanguage[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeLanguage: [code: string]
|
||||
retry: []
|
||||
copy: []
|
||||
}>()
|
||||
|
||||
const selectedLangName = computed(() => props.languages.find((l) => l.code === props.targetLang)?.name ?? props.targetLang)
|
||||
|
||||
function handleLangChange(event: Event) {
|
||||
emit('changeLanguage', (event.target as HTMLSelectElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 flex flex-col gap-3 min-w-0">
|
||||
<!-- Original text -->
|
||||
<div>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground mb-1">Original</p>
|
||||
<div class="max-h-20 overflow-y-auto">
|
||||
<p class="text-sm text-foreground/70 leading-relaxed break-words">{{ originalText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border" />
|
||||
|
||||
<!-- Translation area -->
|
||||
<div>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground mb-1">Translation</p>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 py-2">
|
||||
<div class="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin shrink-0" />
|
||||
<span class="text-sm text-muted-foreground">Translating...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="py-2">
|
||||
<p class="text-sm text-destructive mb-2">{{ error }}</p>
|
||||
<button class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" @click="emit('retry')">
|
||||
<RefreshCw :size="12" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="result" class="max-h-32 overflow-y-auto">
|
||||
<p class="text-sm text-foreground leading-relaxed break-words">{{ result.translatedText }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-2">
|
||||
<p class="text-sm text-muted-foreground italic">No translation yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: language selector | provider chip | copy button -->
|
||||
<div class="flex items-center gap-2 pt-1 border-t border-border">
|
||||
<select
|
||||
class="flex-1 text-xs bg-muted text-foreground rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer min-w-0 truncate"
|
||||
:value="targetLang"
|
||||
:title="selectedLangName"
|
||||
@change="handleLangChange"
|
||||
>
|
||||
<option v-for="lang in languages" :key="lang.code" :value="lang.code">{{ lang.name }}</option>
|
||||
</select>
|
||||
|
||||
<span v-if="result" class="shrink-0 px-2 py-0.5 rounded-full text-[10px] font-medium bg-secondary text-secondary-foreground whitespace-nowrap">
|
||||
via {{ result.provider }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="result"
|
||||
class="shrink-0 flex items-center justify-center w-7 h-7 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Copy translation"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<Copy :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import TranslationPanel from './TranslationPanel.vue'
|
||||
import { useTranslation } from '../composables/useTranslation'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
position: { x: number; y: number; showBelow: boolean }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { supportedLanguages, targetLang, loading, error, result, setTargetLang, translate, reset } = useTranslation()
|
||||
|
||||
const popoverRef = ref<HTMLElement | null>(null)
|
||||
const popoverStyle = ref({ left: '0px', top: '0px' })
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const FALLBACK_WIDTH = 320
|
||||
const FALLBACK_HEIGHT = 240
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (max < min) return min
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const popoverWidth = popoverRef.value?.offsetWidth || FALLBACK_WIDTH
|
||||
const popoverHeight = popoverRef.value?.offsetHeight || FALLBACK_HEIGHT
|
||||
|
||||
const desiredLeft = props.position.x - popoverWidth / 2
|
||||
const desiredTop = props.position.showBelow ? props.position.y : props.position.y - popoverHeight
|
||||
|
||||
popoverStyle.value = {
|
||||
left: `${clamp(desiredLeft, VIEWPORT_MARGIN, window.innerWidth - popoverWidth - VIEWPORT_MARGIN)}px`,
|
||||
top: `${clamp(desiredTop, VIEWPORT_MARGIN, window.innerHeight - popoverHeight - VIEWPORT_MARGIN)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
async function runTranslation() {
|
||||
reset()
|
||||
await translate(props.text, targetLang.value)
|
||||
}
|
||||
|
||||
function handleChangeLanguage(code: string) {
|
||||
setTargetLang(code)
|
||||
runTranslation()
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (result.value) {
|
||||
navigator.clipboard.writeText(result.value.translatedText).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
if (typeof ResizeObserver !== 'undefined' && popoverRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => updatePosition())
|
||||
resizeObserver.observe(popoverRef.value)
|
||||
}
|
||||
await runTranslation()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.position.x, props.position.y, props.position.showBelow],
|
||||
() => updatePosition(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([loading, result, error], async () => {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[61]" @click="emit('close')" />
|
||||
<div ref="popoverRef" class="fixed z-[62] select-none" :style="popoverStyle" @mousedown.stop @click.stop>
|
||||
<div
|
||||
class="bg-card text-card-foreground rounded-lg shadow-xl border border-border w-80 min-w-64 min-h-28 max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)] overflow-auto resize"
|
||||
>
|
||||
<TranslationPanel
|
||||
:originalText="text"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:result="result"
|
||||
:targetLang="targetLang"
|
||||
:languages="supportedLanguages"
|
||||
@changeLanguage="handleChangeLanguage"
|
||||
@retry="runTranslation"
|
||||
@copy="handleCopy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import TranslationPanel from './TranslationPanel.vue'
|
||||
import { useTranslation } from '../composables/useTranslation'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { supportedLanguages, targetLang, loading, error, result, setTargetLang, translate, reset } = useTranslation()
|
||||
|
||||
async function runTranslation() {
|
||||
reset()
|
||||
await translate(props.text, targetLang.value)
|
||||
}
|
||||
|
||||
function handleChangeLanguage(code: string) {
|
||||
setTargetLang(code)
|
||||
runTranslation()
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (result.value) {
|
||||
navigator.clipboard.writeText(result.value.translatedText).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) emit('close')
|
||||
}
|
||||
|
||||
onMounted(runTranslation)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet :open="true" @update:open="handleOpenChange">
|
||||
<SheetContent side="bottom" class="p-0 rounded-t-xl max-h-[60vh] overflow-y-auto">
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1">
|
||||
<div class="w-9 h-1 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
<TranslationPanel
|
||||
:originalText="text"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:result="result"
|
||||
:targetLang="targetLang"
|
||||
:languages="supportedLanguages"
|
||||
@changeLanguage="handleChangeLanguage"
|
||||
@retry="runTranslation"
|
||||
@copy="handleCopy"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TranslationResult } from '../../services/translation.types'
|
||||
|
||||
vi.mock('../../services/google-translate', () => ({
|
||||
translateWithGoogle: vi.fn<(text: string, targetLang: string) => Promise<TranslationResult>>(),
|
||||
GoogleTranslateError: class GoogleTranslateError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg)
|
||||
this.name = 'GoogleTranslateError'
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/azure-translate', () => ({
|
||||
translateWithAzure: vi.fn<(text: string, targetLang: string) => Promise<TranslationResult>>(),
|
||||
resetTokenCache: vi.fn<() => void>(),
|
||||
AzureTranslateError: class AzureTranslateError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg)
|
||||
this.name = 'AzureTranslateError'
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import { translateWithGoogle } from '../../services/google-translate'
|
||||
import { translateWithAzure } from '../../services/azure-translate'
|
||||
import { SUPPORTED_LANGUAGES, TRANSLATION_CHAR_LIMIT } from '../../services/translation.types'
|
||||
|
||||
const mockGoogle = vi.mocked(translateWithGoogle)
|
||||
const mockAzure = vi.mocked(translateWithAzure)
|
||||
|
||||
const GOOGLE_SUCCESS: TranslationResult = { translatedText: 'Bonjour', detectedSourceLang: 'en', provider: 'Google' }
|
||||
const AZURE_SUCCESS: TranslationResult = { translatedText: 'Salut', detectedSourceLang: 'en', provider: 'Azure' }
|
||||
|
||||
async function loadComposable() {
|
||||
const mod = await import('../useTranslation')
|
||||
return mod.useTranslation()
|
||||
}
|
||||
|
||||
function makeLocalStorageMock(getItemImpl: (() => string | null) | (string | null) = null, setItemImpl?: () => void): Storage {
|
||||
const getItem =
|
||||
typeof getItemImpl === 'function'
|
||||
? vi.fn<(key: string) => string | null>().mockImplementation(getItemImpl)
|
||||
: vi.fn<(key: string) => string | null>().mockReturnValue(getItemImpl)
|
||||
|
||||
const setItem = setItemImpl
|
||||
? vi.fn<(key: string, value: string) => void>().mockImplementation(setItemImpl)
|
||||
: vi.fn<(key: string, value: string) => void>()
|
||||
|
||||
return { getItem, setItem, removeItem: vi.fn<(key: string) => void>() } as unknown as Storage
|
||||
}
|
||||
|
||||
describe('useTranslation', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', makeLocalStorageMock())
|
||||
mockGoogle.mockReset()
|
||||
mockAzure.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('starts with loading=false, error=null, result=null', async () => {
|
||||
const { loading, error, result } = await loadComposable()
|
||||
expect(loading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
expect(result.value).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes supportedLanguages matching the types constant', async () => {
|
||||
const { supportedLanguages } = await loadComposable()
|
||||
expect(supportedLanguages).toStrictEqual(SUPPORTED_LANGUAGES)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Language persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('defaults to "en" when localStorage has no stored value', async () => {
|
||||
const { targetLang } = await loadComposable()
|
||||
expect(targetLang.value).toBe('en')
|
||||
})
|
||||
|
||||
it('reads persisted lang from localStorage on init', async () => {
|
||||
vi.stubGlobal('localStorage', makeLocalStorageMock('fr'))
|
||||
const { targetLang } = await loadComposable()
|
||||
expect(targetLang.value).toBe('fr')
|
||||
})
|
||||
|
||||
it('falls back to "en" when localStorage returns an unsupported lang code', async () => {
|
||||
vi.stubGlobal('localStorage', makeLocalStorageMock('xx'))
|
||||
const { targetLang } = await loadComposable()
|
||||
expect(targetLang.value).toBe('en')
|
||||
})
|
||||
|
||||
it('falls back to "en" when localStorage.getItem throws', async () => {
|
||||
vi.stubGlobal(
|
||||
'localStorage',
|
||||
makeLocalStorageMock(() => {
|
||||
throw new Error('unavailable')
|
||||
}),
|
||||
)
|
||||
const { targetLang } = await loadComposable()
|
||||
expect(targetLang.value).toBe('en')
|
||||
})
|
||||
|
||||
it('setTargetLang updates targetLang and persists to localStorage', async () => {
|
||||
const setItemMock = vi.fn<(key: string, value: string) => void>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn<(key: string) => string | null>().mockReturnValue(null),
|
||||
setItem: setItemMock,
|
||||
removeItem: vi.fn<(key: string) => void>(),
|
||||
} as unknown as Storage)
|
||||
const { targetLang, setTargetLang } = await loadComposable()
|
||||
setTargetLang('ja')
|
||||
expect(targetLang.value).toBe('ja')
|
||||
expect(setItemMock).toHaveBeenCalledWith('translation_target_lang', 'ja')
|
||||
})
|
||||
|
||||
it('setTargetLang does not throw when localStorage.setItem throws', async () => {
|
||||
vi.stubGlobal(
|
||||
'localStorage',
|
||||
makeLocalStorageMock(null, () => {
|
||||
throw new Error('quota exceeded')
|
||||
}),
|
||||
)
|
||||
const { setTargetLang } = await loadComposable()
|
||||
expect(() => setTargetLang('de')).not.toThrow()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Character limit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('sets error and skips API calls when text exceeds limit', async () => {
|
||||
const { translate, error, result, loading } = await loadComposable()
|
||||
const longText = 'a'.repeat(TRANSLATION_CHAR_LIMIT + 1)
|
||||
await translate(longText, 'fr')
|
||||
expect(error.value).toMatch(/too long/i)
|
||||
expect(result.value).toBeNull()
|
||||
expect(loading.value).toBe(false)
|
||||
expect(mockGoogle).not.toHaveBeenCalled()
|
||||
expect(mockAzure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts text exactly at the character limit', async () => {
|
||||
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
|
||||
const { translate, error } = await loadComposable()
|
||||
const exactText = 'a'.repeat(TRANSLATION_CHAR_LIMIT)
|
||||
await translate(exactText, 'fr')
|
||||
expect(error.value).toBeNull()
|
||||
expect(mockGoogle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets error when trimmed text is empty', async () => {
|
||||
const { translate, error } = await loadComposable()
|
||||
await translate(' ', 'fr')
|
||||
expect(error.value).toMatch(/nothing to translate/i)
|
||||
expect(mockGoogle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Google success path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns Google result on success', async () => {
|
||||
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
|
||||
const { translate, result, loading, error } = await loadComposable()
|
||||
await translate('Hello', 'fr')
|
||||
expect(result.value).toEqual(GOOGLE_SUCCESS)
|
||||
expect(error.value).toBeNull()
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets loading=true during translation then false after', async () => {
|
||||
const states: boolean[] = []
|
||||
let resolve!: (r: typeof GOOGLE_SUCCESS) => void
|
||||
mockGoogle.mockReturnValueOnce(
|
||||
new Promise((res) => {
|
||||
resolve = res
|
||||
}),
|
||||
)
|
||||
const { translate, loading } = await loadComposable()
|
||||
const translatePromise = translate('Hello', 'fr')
|
||||
states.push(loading.value)
|
||||
resolve(GOOGLE_SUCCESS)
|
||||
await translatePromise
|
||||
states.push(loading.value)
|
||||
expect(states).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('trims whitespace from input before calling APIs', async () => {
|
||||
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
|
||||
const { translate } = await loadComposable()
|
||||
await translate(' Hello ', 'fr')
|
||||
expect(mockGoogle).toHaveBeenCalledWith('Hello', 'fr')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Azure fallback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('falls back to Azure when Google throws', async () => {
|
||||
mockGoogle.mockRejectedValueOnce(new Error('google fail'))
|
||||
mockAzure.mockResolvedValueOnce(AZURE_SUCCESS)
|
||||
const { translate, result, error } = await loadComposable()
|
||||
await translate('Hello', 'fr')
|
||||
expect(result.value).toEqual(AZURE_SUCCESS)
|
||||
expect(error.value).toBeNull()
|
||||
expect(mockAzure).toHaveBeenCalledWith('Hello', 'fr')
|
||||
})
|
||||
|
||||
it('sets error when both Google and Azure throw', async () => {
|
||||
mockGoogle.mockRejectedValueOnce(new Error('google fail'))
|
||||
mockAzure.mockRejectedValueOnce(new Error('azure fail'))
|
||||
const { translate, result, error, loading } = await loadComposable()
|
||||
await translate('Hello', 'fr')
|
||||
expect(result.value).toBeNull()
|
||||
expect(error.value).toMatch(/translation failed/i)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// reset()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('reset() clears result, error, and loading state', async () => {
|
||||
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
|
||||
const { translate, reset, result, error, loading } = await loadComposable()
|
||||
await translate('Hello', 'fr')
|
||||
expect(result.value).not.toBeNull()
|
||||
reset()
|
||||
expect(result.value).toBeNull()
|
||||
expect(error.value).toBeNull()
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Subsequent calls clear previous state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('clears previous result and error at the start of a new translate call', async () => {
|
||||
mockGoogle.mockRejectedValueOnce(new Error('fail'))
|
||||
mockAzure.mockRejectedValueOnce(new Error('fail'))
|
||||
const { translate, error, result } = await loadComposable()
|
||||
await translate('Hello', 'fr')
|
||||
expect(error.value).not.toBeNull()
|
||||
|
||||
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
|
||||
await translate('World', 'es')
|
||||
expect(error.value).toBeNull()
|
||||
expect(result.value).toEqual(GOOGLE_SUCCESS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ref } from 'vue'
|
||||
import { translateWithGoogle } from '../services/google-translate'
|
||||
import { translateWithAzure } from '../services/azure-translate'
|
||||
import { SUPPORTED_LANGUAGES, TRANSLATION_CHAR_LIMIT } from '../services/translation.types'
|
||||
import type { TranslationResult } from '../services/translation.types'
|
||||
|
||||
const STORAGE_KEY = 'translation_target_lang'
|
||||
const DEFAULT_LANG = 'en'
|
||||
|
||||
function loadPersistedLang(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && SUPPORTED_LANGUAGES.some((l) => l.code === stored)) {
|
||||
return stored
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. SSR or private mode)
|
||||
}
|
||||
return DEFAULT_LANG
|
||||
}
|
||||
|
||||
function persistLang(code: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, code)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const targetLang = ref<string>(loadPersistedLang())
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const result = ref<TranslationResult | null>(null)
|
||||
|
||||
function setTargetLang(code: string): void {
|
||||
targetLang.value = code
|
||||
persistLang(code)
|
||||
}
|
||||
|
||||
async function translate(text: string, lang: string): Promise<void> {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (trimmed.length > TRANSLATION_CHAR_LIMIT) {
|
||||
error.value = `Selection is too long. Please select ${TRANSLATION_CHAR_LIMIT} characters or fewer.`
|
||||
result.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
error.value = 'Nothing to translate.'
|
||||
result.value = null
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
result.value = null
|
||||
|
||||
try {
|
||||
const res = await translateWithGoogle(trimmed, lang)
|
||||
result.value = res
|
||||
} catch {
|
||||
try {
|
||||
const res = await translateWithAzure(trimmed, lang)
|
||||
result.value = res
|
||||
} catch {
|
||||
error.value = 'Translation failed. Check your connection and try again.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
loading.value = false
|
||||
error.value = null
|
||||
result.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
supportedLanguages: SUPPORTED_LANGUAGES,
|
||||
targetLang,
|
||||
loading,
|
||||
error,
|
||||
result,
|
||||
setTargetLang,
|
||||
translate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AzureTranslateError, translateWithAzure, resetTokenCache } from '../azure-translate'
|
||||
|
||||
describe('translateWithAzure', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
|
||||
|
||||
beforeEach(() => {
|
||||
resetTokenCache()
|
||||
fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
resetTokenCache()
|
||||
})
|
||||
|
||||
function mockText(text: string): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => text,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function mockOk(data: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
json: async () => data,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function mockStatus(status: number): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
text: async () => '',
|
||||
json: async () => ({}),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function makeAzureResponse(translatedText: string, detectedLang = 'en'): unknown {
|
||||
return [
|
||||
{
|
||||
translations: [{ text: translatedText }],
|
||||
detectedLanguage: { language: detectedLang },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Success paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('fetches auth token then translates successfully', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('test-jwt-token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Bonjour')))
|
||||
const result = await translateWithAzure('Hello', 'fr')
|
||||
expect(result.translatedText).toBe('Bonjour')
|
||||
expect(result.provider).toBe('Azure')
|
||||
expect(result.detectedSourceLang).toBe('en')
|
||||
})
|
||||
|
||||
it('sends auth token in Authorization header', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('my-token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
|
||||
await translateWithAzure('Hello', 'es')
|
||||
const translateCall = fetchMock.mock.calls[1]!
|
||||
const init = translateCall[1] as RequestInit
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer my-token')
|
||||
})
|
||||
|
||||
it('sends correct target language in URL', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
|
||||
await translateWithAzure('Hello', 'es')
|
||||
const translateUrl = fetchMock.mock.calls[1]![0] as string
|
||||
expect(translateUrl).toContain('to=es')
|
||||
expect(translateUrl).toContain('api-version=3.0')
|
||||
})
|
||||
|
||||
it('sends text as POST body with Text field', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
|
||||
await translateWithAzure('Hello', 'es')
|
||||
const init = fetchMock.mock.calls[1]![1] as RequestInit
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual([{ Text: 'Hello' }])
|
||||
})
|
||||
|
||||
it('uses detectedSourceLang from response', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola', 'fr')))
|
||||
const result = await translateWithAzure('Bonjour', 'es')
|
||||
expect(result.detectedSourceLang).toBe('fr')
|
||||
})
|
||||
|
||||
it('falls back to "auto" when detectedLanguage is missing from response', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [{ text: 'Hola' }] }]))
|
||||
const result = await translateWithAzure('Hello', 'es')
|
||||
expect(result.detectedSourceLang).toBe('auto')
|
||||
})
|
||||
|
||||
it('falls back to "auto" when detectedLanguage.language is not a string', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(mockText('token'))
|
||||
.mockResolvedValueOnce(mockOk([{ translations: [{ text: 'Hola' }], detectedLanguage: { language: 42 } }]))
|
||||
const result = await translateWithAzure('Hello', 'es')
|
||||
expect(result.detectedSourceLang).toBe('auto')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Token caching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('reuses cached token within TTL without fetching again', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(mockText('cached-token'))
|
||||
.mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
|
||||
.mockResolvedValueOnce(mockOk(makeAzureResponse('Hola2')))
|
||||
|
||||
await translateWithAzure('Hello', 'es')
|
||||
await translateWithAzure('World', 'es')
|
||||
|
||||
// auth token fetch only called once
|
||||
const authCalls = fetchMock.mock.calls.filter((c) => (c[0] as string).includes('translate/auth'))
|
||||
expect(authCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('invalidates token on 401 response and clears cache', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('bad-token')).mockResolvedValueOnce(mockStatus(401))
|
||||
await expect(translateWithAzure('Hello', 'es')).rejects.toThrow('Auth token rejected (401)')
|
||||
|
||||
// after 401, cache should be cleared — next call fetches a new token
|
||||
fetchMock.mockResolvedValueOnce(mockText('new-token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
|
||||
const result = await translateWithAzure('Hello', 'es')
|
||||
expect(result.translatedText).toBe('Hola')
|
||||
expect(fetchMock.mock.calls.filter((c) => (c[0] as string).includes('translate/auth'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resetTokenCache forces re-fetch on next call', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token-1')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
|
||||
await translateWithAzure('Hello', 'es')
|
||||
|
||||
resetTokenCache()
|
||||
fetchMock.mockResolvedValueOnce(mockText('token-2')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola2')))
|
||||
await translateWithAzure('Hello', 'es')
|
||||
|
||||
const authCalls = fetchMock.mock.calls.filter((c) => (c[0] as string).includes('translate/auth'))
|
||||
expect(authCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth error paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws AzureTranslateError when auth endpoint returns non-OK status', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(503))
|
||||
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(AzureTranslateError)
|
||||
expect((err as AzureTranslateError).message).toContain('Auth HTTP 503')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when auth endpoint returns empty token', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText(' '))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Empty auth token')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when auth fetch throws a network error', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('network'))
|
||||
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(AzureTranslateError)
|
||||
expect((err as AzureTranslateError).message).toContain('Auth network error')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Translation error paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws AzureTranslateError on non-OK translate response', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockStatus(500))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('HTTP 500')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when translate network fetch throws', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockRejectedValueOnce(new Error('network'))
|
||||
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(AzureTranslateError)
|
||||
expect((err as AzureTranslateError).message).toContain('Network error')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when response is not an array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk({ error: 'bad' }))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow(AzureTranslateError)
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when response is empty array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([]))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Unexpected response shape')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when translations array is empty', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [] }]))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('No translations in response')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when translations is missing', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ other: 'data' }]))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('No translations in response')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when translated text is empty string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [{ text: '' }] }]))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Empty translated text')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when translated text is not a string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [{ text: null }] }]))
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Empty translated text')
|
||||
})
|
||||
|
||||
it('throws AzureTranslateError when JSON parsing fails', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('bad json')
|
||||
},
|
||||
} as unknown as Response)
|
||||
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow(AzureTranslateError)
|
||||
})
|
||||
|
||||
it('error has name AzureTranslateError', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('network'))
|
||||
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(AzureTranslateError)
|
||||
expect((err as AzureTranslateError).name).toBe('AzureTranslateError')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GoogleTranslateError, translateWithGoogle } from '../google-translate'
|
||||
|
||||
const GOOGLE_URL = 'https://translate.googleapis.com/translate_a/single'
|
||||
|
||||
describe('translateWithGoogle', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function mockOk(data: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => data,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function mockStatus(status: number): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function makeGoogleResponse(sentences: unknown[][], detectedLang = 'en'): unknown {
|
||||
return [sentences, null, detectedLang]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Success paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns translated text from a single-sentence response', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeGoogleResponse([['Bonjour', 'Hello', null, null, 10]], 'en')))
|
||||
const result = await translateWithGoogle('Hello', 'fr')
|
||||
expect(result.translatedText).toBe('Bonjour')
|
||||
expect(result.provider).toBe('Google')
|
||||
expect(result.detectedSourceLang).toBe('en')
|
||||
})
|
||||
|
||||
it('concatenates multiple sentences into a single string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk(
|
||||
makeGoogleResponse(
|
||||
[
|
||||
['Bonjour', 'Hello', null],
|
||||
[' monde', ' world', null],
|
||||
],
|
||||
'en',
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await translateWithGoogle('Hello world', 'fr')
|
||||
expect(result.translatedText).toBe('Bonjour monde')
|
||||
})
|
||||
|
||||
it('uses "auto" as detectedSourceLang when data[2] is missing', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk([[[`Hola`, 'Hello']]]))
|
||||
const result = await translateWithGoogle('Hello', 'es')
|
||||
expect(result.detectedSourceLang).toBe('auto')
|
||||
})
|
||||
|
||||
it('uses "auto" as detectedSourceLang when data[2] is not a string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk([[[`Hola`, 'Hello']], null, 42]))
|
||||
const result = await translateWithGoogle('Hello', 'es')
|
||||
expect(result.detectedSourceLang).toBe('auto')
|
||||
})
|
||||
|
||||
it('builds correct URL with all required query params', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeGoogleResponse([['Hola', 'Hello']], 'en')))
|
||||
await translateWithGoogle('Hello', 'es')
|
||||
const calledUrl = fetchMock.mock.calls[0]![0] as string
|
||||
expect(calledUrl).toContain('client=gtx')
|
||||
expect(calledUrl).toContain('sl=auto')
|
||||
expect(calledUrl).toContain('tl=es')
|
||||
expect(calledUrl).toContain(encodeURIComponent('Hello'))
|
||||
expect(calledUrl).toContain(GOOGLE_URL)
|
||||
})
|
||||
|
||||
it('encodes special characters in the query text', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeGoogleResponse([['résultat', 'result']], 'en')))
|
||||
await translateWithGoogle('café & résumé', 'fr')
|
||||
const calledUrl = fetchMock.mock.calls[0]![0] as string
|
||||
// URLSearchParams encodes spaces as '+', so decode before asserting
|
||||
const decodedUrl = decodeURIComponent(calledUrl.replace(/\+/g, ' '))
|
||||
expect(decodedUrl).toContain('café & résumé')
|
||||
})
|
||||
|
||||
it('skips sentence entries that are not arrays', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk([[['Hello', 'Hi'], null, ['World', 'World']], null, 'en']))
|
||||
const result = await translateWithGoogle('Hi World', 'en')
|
||||
expect(result.translatedText).toBe('HelloWorld')
|
||||
})
|
||||
|
||||
it('skips sentence entries where first element is not a string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk([
|
||||
[
|
||||
[42, 'Hello'],
|
||||
['Valid', 'text'],
|
||||
],
|
||||
null,
|
||||
'en',
|
||||
]),
|
||||
)
|
||||
const result = await translateWithGoogle('Hello text', 'es')
|
||||
expect(result.translatedText).toBe('Valid')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws GoogleTranslateError on network failure', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('network down'))
|
||||
const err = await translateWithGoogle('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(GoogleTranslateError)
|
||||
expect((err as GoogleTranslateError).message).toContain('Network error')
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError on non-OK HTTP status', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(429))
|
||||
const err = await translateWithGoogle('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(GoogleTranslateError)
|
||||
expect((err as GoogleTranslateError).message).toContain('HTTP 429')
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError on 500 status', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(500))
|
||||
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow('HTTP 500')
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError when response is not an array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk({ error: 'bad' }))
|
||||
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError when response is empty array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk([]))
|
||||
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError when data[0] is not an array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(['not-sentences', null, 'en']))
|
||||
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError when all sentences produce empty string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk([[null, null], null, 'en']))
|
||||
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
|
||||
})
|
||||
|
||||
it('throws GoogleTranslateError when JSON parsing fails', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('invalid json')
|
||||
},
|
||||
} as unknown as Response)
|
||||
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
|
||||
})
|
||||
|
||||
it('error has name GoogleTranslateError', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('network'))
|
||||
const err = await translateWithGoogle('Hello', 'fr').catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(GoogleTranslateError)
|
||||
expect((err as GoogleTranslateError).name).toBe('GoogleTranslateError')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { TranslationResult } from './translation.types'
|
||||
|
||||
const AZURE_AUTH_URL = 'https://edge.microsoft.com/translate/auth'
|
||||
const AZURE_TRANSLATE_URL = 'https://api-edge.cognitive.microsofttranslator.com/translate'
|
||||
const TOKEN_TTL_MS = 7 * 60 * 1000
|
||||
|
||||
export class AzureTranslateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AzureTranslateError'
|
||||
}
|
||||
}
|
||||
|
||||
let cachedToken: string | null = null
|
||||
let tokenExpiresAt: number = 0
|
||||
|
||||
export function resetTokenCache(): void {
|
||||
cachedToken = null
|
||||
tokenExpiresAt = 0
|
||||
}
|
||||
|
||||
async function getAuthToken(): Promise<string> {
|
||||
if (cachedToken && Date.now() < tokenExpiresAt) {
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(AZURE_AUTH_URL)
|
||||
} catch (err) {
|
||||
throw new AzureTranslateError(`Auth network error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AzureTranslateError(`Auth HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const token = await response.text()
|
||||
if (!token || !token.trim()) {
|
||||
throw new AzureTranslateError('Empty auth token')
|
||||
}
|
||||
|
||||
cachedToken = token.trim()
|
||||
tokenExpiresAt = Date.now() + TOKEN_TTL_MS
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
export async function translateWithAzure(text: string, targetLang: string): Promise<TranslationResult> {
|
||||
const token = await getAuthToken()
|
||||
|
||||
const url = new URL(AZURE_TRANSLATE_URL)
|
||||
url.searchParams.set('to', targetLang)
|
||||
url.searchParams.set('api-version', '3.0')
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([{ Text: text }]),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new AzureTranslateError(`Network error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
resetTokenCache()
|
||||
throw new AzureTranslateError('Auth token rejected (401)')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AzureTranslateError(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new AzureTranslateError('Failed to parse response')
|
||||
}
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new AzureTranslateError('Unexpected response shape')
|
||||
}
|
||||
|
||||
const first = data[0] as Record<string, unknown>
|
||||
const translations = first['translations']
|
||||
if (!Array.isArray(translations) || translations.length === 0) {
|
||||
throw new AzureTranslateError('No translations in response')
|
||||
}
|
||||
|
||||
const translation = translations[0] as Record<string, unknown>
|
||||
const translatedText = translation['text']
|
||||
if (typeof translatedText !== 'string' || !translatedText) {
|
||||
throw new AzureTranslateError('Empty translated text in response')
|
||||
}
|
||||
|
||||
let detectedSourceLang = 'auto'
|
||||
const detectedLanguage = first['detectedLanguage']
|
||||
if (detectedLanguage && typeof detectedLanguage === 'object') {
|
||||
const lang = (detectedLanguage as Record<string, unknown>)['language']
|
||||
if (typeof lang === 'string') {
|
||||
detectedSourceLang = lang
|
||||
}
|
||||
}
|
||||
|
||||
return { translatedText, detectedSourceLang, provider: 'Azure' }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TranslationResult } from './translation.types'
|
||||
|
||||
const GOOGLE_TRANSLATE_URL = 'https://translate.googleapis.com/translate_a/single'
|
||||
|
||||
export class GoogleTranslateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'GoogleTranslateError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function translateWithGoogle(text: string, targetLang: string): Promise<TranslationResult> {
|
||||
const url = new URL(GOOGLE_TRANSLATE_URL)
|
||||
url.searchParams.set('client', 'gtx')
|
||||
url.searchParams.set('dt', 't')
|
||||
url.searchParams.set('sl', 'auto')
|
||||
url.searchParams.set('tl', targetLang)
|
||||
url.searchParams.set('q', text)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url.toString())
|
||||
} catch (err) {
|
||||
throw new GoogleTranslateError(`Network error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GoogleTranslateError(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new GoogleTranslateError('Failed to parse response')
|
||||
}
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new GoogleTranslateError('Unexpected response shape')
|
||||
}
|
||||
|
||||
const sentences = data[0]
|
||||
if (!Array.isArray(sentences)) {
|
||||
throw new GoogleTranslateError('Unexpected sentences shape')
|
||||
}
|
||||
|
||||
let translatedText = ''
|
||||
for (const sentence of sentences) {
|
||||
if (Array.isArray(sentence) && typeof sentence[0] === 'string') {
|
||||
translatedText += sentence[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (!translatedText) {
|
||||
throw new GoogleTranslateError('Empty translation in response')
|
||||
}
|
||||
|
||||
const detectedSourceLang = typeof data[2] === 'string' ? data[2] : 'auto'
|
||||
|
||||
return { translatedText, detectedSourceLang, provider: 'Google' }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const TRANSLATION_CHAR_LIMIT = 500
|
||||
|
||||
export interface TranslationResult {
|
||||
translatedText: string
|
||||
detectedSourceLang: string
|
||||
provider: 'Google' | 'Azure'
|
||||
}
|
||||
|
||||
export interface SupportedLanguage {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
]
|
||||
Reference in New Issue
Block a user