Merge pull request #144 from scub-france/fix/decoupling-audit

fix(decoupling): eliminate cross-feature imports & typed health endpoint
This commit is contained in:
Pier-Jean Malandrino 2026-04-10 14:04:33 +02:00 committed by GitHub
commit 6d0c4dd192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 276 additions and 239 deletions

View file

@ -26,6 +26,16 @@ class _CamelModel(BaseModel):
)
class HealthResponse(_CamelModel):
status: str
version: str
engine: str
deployment_mode: str
database: str
max_page_count: int | None = None
max_file_size_mb: int | None = None
class DocumentResponse(_CamelModel):
id: str
filename: str

View file

@ -20,6 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.analyses import router as analyses_router
from api.documents import router as documents_router
from api.schemas import HealthResponse
from infra.rate_limiter import RateLimiterMiddleware
from infra.settings import settings
from persistence.analysis_repo import SqliteAnalysisRepository
@ -141,8 +142,8 @@ app.include_router(documents_router)
app.include_router(analyses_router)
@app.get("/api/health")
async def health() -> dict[str, str | int]:
@app.get("/api/health", response_model=HealthResponse)
async def health() -> HealthResponse:
"""Health check endpoint — verifies database connectivity."""
db_status = "ok"
try:
@ -153,15 +154,12 @@ async def health() -> dict[str, str | int]:
logger.warning("Health check: database unreachable", exc_info=True)
status = "ok" if db_status == "ok" else "degraded"
result: dict[str, str | int] = {
"status": status,
"version": settings.app_version,
"engine": settings.conversion_engine,
"deploymentMode": settings.deployment_mode,
"database": db_status,
}
if settings.max_page_count > 0:
result["maxPageCount"] = settings.max_page_count
if settings.max_file_size_mb > 0:
result["maxFileSizeMb"] = settings.max_file_size_mb
return result
return HealthResponse(
status=status,
version=settings.app_version,
engine=settings.conversion_engine,
deployment_mode=settings.deployment_mode,
database=db_status,
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
)

View file

@ -45,26 +45,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
}
})
const rechunking = ref(false)
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
rechunking.value = true
error.value = null
try {
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
if (currentAnalysis.value?.id === jobId) {
currentAnalysis.value = await api.fetchAnalysis(jobId)
}
return chunks
} catch (e) {
error.value = (e as Error).message || 'Failed to rechunk'
console.error('Failed to rechunk', e)
throw e
} finally {
rechunking.value = false
}
}
async function run(
documentId: string,
pipelineOptions: PipelineOptions | null = null,
@ -157,12 +137,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
currentPages,
currentChunks,
running,
rechunking,
error,
clearError,
load,
run,
rechunk,
select,
remove,
stopPolling,

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { rechunkAnalysis, createAnalysis } from '../analysis/api'
import { rechunkAnalysis } from './api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
@ -12,30 +12,6 @@ describe('chunking API', () => {
vi.clearAllMocks()
})
it('createAnalysis sends chunkingOptions when provided', async () => {
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
apiFetch.mockResolvedValue(job)
const chunkingOpts = { chunker_type: 'hybrid' as const, max_tokens: 256 }
await createAnalysis('doc-1', null, chunkingOpts)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1', chunkingOptions: chunkingOpts }),
})
})
it('createAnalysis omits chunkingOptions when null', async () => {
apiFetch.mockResolvedValue({ id: '1' })
await createAnalysis('doc-1', null, null)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1' }),
})
})
it('rechunkAnalysis sends POST to rechunk endpoint', async () => {
const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }]
apiFetch.mockResolvedValue(chunks)

View file

@ -0,0 +1,9 @@
import type { Chunk, ChunkingOptions } from '../../shared/types'
import { apiFetch } from '../../shared/api/http'
export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
method: 'POST',
body: JSON.stringify({ chunkingOptions }),
})
}

View file

@ -1 +1,2 @@
export { default as ChunkPanel } from './ui/ChunkPanel.vue'
export { useChunkingStore } from './store'

View file

@ -1,109 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAnalysisStore } from '../analysis/store'
import { useChunkingStore } from './store'
vi.mock('../analysis/api', () => ({
createAnalysis: vi.fn(),
fetchAnalyses: vi.fn().mockResolvedValue([]),
fetchAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
vi.mock('./api', () => ({
rechunkAnalysis: vi.fn(),
}))
import * as api from '../analysis/api'
import * as api from './api'
describe('analysis store — chunking', () => {
describe('useChunkingStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('currentChunks parses chunksJson from current analysis', () => {
const store = useAnalysisStore()
const chunks = [
{
text: 'chunk1',
headings: ['H1'],
sourcePage: 1,
tokenCount: 10,
bboxes: [{ page: 1, bbox: [10, 20, 100, 80] }],
},
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20, bboxes: [] },
]
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: JSON.stringify(chunks),
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
expect(store.currentChunks).toEqual(chunks)
it('starts with default state', () => {
const store = useChunkingStore()
expect(store.rechunking).toBe(false)
expect(store.error).toBeNull()
})
it('currentChunks returns empty array when no chunksJson', () => {
const store = useAnalysisStore()
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: false,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
expect(store.currentChunks).toEqual([])
})
it('rechunk calls API and refreshes analysis', async () => {
const store = useAnalysisStore()
it('rechunk calls API and returns chunks', async () => {
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }]
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
vi.mocked(api.fetchAnalysis).mockResolvedValue({
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: JSON.stringify(chunks),
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
})
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
const store = useChunkingStore()
const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 })
expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', {
@ -114,28 +35,31 @@ describe('analysis store — chunking', () => {
expect(store.rechunking).toBe(false)
})
it('run passes chunkingOptions to API', async () => {
const store = useAnalysisStore()
vi.mocked(api.createAnalysis).mockResolvedValue({
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'PENDING',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: false,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
})
it('rechunk sets rechunking during execution', async () => {
let resolve: (v: any) => void
vi.mocked(api.rechunkAnalysis).mockImplementation(
() =>
new Promise((r) => {
resolve = r
}),
)
await store.run('d1', null, { chunker_type: 'hierarchical' })
const store = useChunkingStore()
const promise = store.rechunk('j1', { chunker_type: 'hybrid' })
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, {
chunker_type: 'hierarchical',
})
expect(store.rechunking).toBe(true)
resolve!([])
await promise
expect(store.rechunking).toBe(false)
})
it('rechunk handles errors', async () => {
vi.mocked(api.rechunkAnalysis).mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChunkingStore()
await expect(store.rechunk('j1', { chunker_type: 'hybrid' })).rejects.toThrow('fail')
expect(store.rechunking).toBe(false)
expect(store.error).toBe('fail')
})
})

View file

@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Chunk, ChunkingOptions } from '../../shared/types'
import * as api from './api'
export const useChunkingStore = defineStore('chunking', () => {
const rechunking = ref(false)
const error = ref<string | null>(null)
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
rechunking.value = true
error.value = null
try {
return await api.rechunkAnalysis(jobId, chunkingOptions)
} catch (e) {
error.value = (e as Error).message || 'Failed to rechunk'
console.error('Failed to rechunk', e)
throw e
} finally {
rechunking.value = false
}
}
return { rechunking, error, rechunk }
})

View file

@ -60,11 +60,11 @@
<button
class="chunk-btn primary"
data-e2e="chunk-btn"
:disabled="!canRechunk || analysisStore.rechunking"
:disabled="!canRechunk || chunkingStore.rechunking"
@click="doRechunk"
>
<div v-if="analysisStore.rechunking" class="spinner-sm" />
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
<div v-if="chunkingStore.rechunking" class="spinner-sm" />
{{ chunkingStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
</button>
<!-- Batch mode notice -->
@ -111,11 +111,9 @@
</div>
</div>
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
<div class="chunk-empty" v-else-if="!chunkingStore.rechunking">
<p>
{{
analysisStore.currentChunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks')
}}
{{ chunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks') }}
</p>
</div>
@ -132,7 +130,7 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useAnalysisStore } from '../../analysis/store'
import { useChunkingStore } from '../store'
import { useI18n } from '../../../shared/i18n'
import { usePagination } from '../../../shared/composables/usePagination'
import { PaginationBar } from '../../../shared/ui'
@ -140,13 +138,18 @@ import type { Chunk, ChunkBbox, ChunkingOptions } from '../../../shared/types'
const props = defineProps<{
currentPage: number
analysisId: string | null
analysisStatus: string | null
hasDocumentJson: boolean
chunks: Chunk[]
}>()
const emit = defineEmits<{
'highlight-bboxes': [bboxes: ChunkBbox[]]
rechunked: []
}>()
const analysisStore = useAnalysisStore()
const chunkingStore = useChunkingStore()
const { t } = useI18n()
const configOpen = ref(true)
@ -159,19 +162,15 @@ const options = reactive<Required<ChunkingOptions>>({
})
const canRechunk = computed(() => {
const analysis = analysisStore.currentAnalysis
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
return props.analysisStatus === 'COMPLETED' && props.hasDocumentJson
})
/** True when the analysis was batched (document_json unavailable). */
const isBatchedAnalysis = computed(() => {
const analysis = analysisStore.currentAnalysis
return analysis?.status === 'COMPLETED' && !analysis.hasDocumentJson
return props.analysisStatus === 'COMPLETED' && !props.hasDocumentJson
})
const pageChunks = computed(() =>
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
)
const pageChunks = computed(() => props.chunks.filter((c) => c.sourcePage === props.currentPage))
const pagination = usePagination(pageChunks, { pageSize: 20 })
function globalIndex(localIdx: number): number {
@ -192,8 +191,9 @@ function onChunkLeave() {
}
async function doRechunk() {
if (!analysisStore.currentAnalysis) return
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
if (!props.analysisId) return
await chunkingStore.rechunk(props.analysisId, { ...options })
emit('rechunked')
}
</script>

View file

@ -1,11 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Document } from '../../shared/types'
import { useFeatureFlagStore } from '../feature-flags/store'
import { appMaxFileSizeMb } from '../../shared/appConfig'
import * as api from './api'
export const useDocumentStore = defineStore('document', () => {
const flags = useFeatureFlagStore()
const documents = ref<Document[]>([])
const selectedId = ref<string | null>(null)
const uploading = ref(false)
@ -26,7 +25,7 @@ export const useDocumentStore = defineStore('document', () => {
}
async function upload(file: File): Promise<Document> {
const maxMb = flags.maxFileSizeMb
const maxMb = appMaxFileSizeMb.value
if (maxMb > 0 && file.size > maxMb * 1024 * 1024) {
error.value = `File too large (max ${maxMb} MB)`
throw new Error(error.value)

View file

@ -33,24 +33,23 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDocumentStore } from '../store'
import { useFeatureFlagStore } from '../../feature-flags/store'
import { useI18n } from '../../../shared/i18n'
import { appMaxFileSizeMb, appMaxPageCount } from '../../../shared/appConfig'
const emit = defineEmits<{ uploaded: [docId: string] }>()
const store = useDocumentStore()
const flags = useFeatureFlagStore()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const uploadHint = computed(() => {
const parts: string[] = []
if (flags.maxFileSizeMb > 0) {
parts.push(t('upload.maxSize').replace('{n}', String(flags.maxFileSizeMb)))
if (appMaxFileSizeMb.value > 0) {
parts.push(t('upload.maxSize').replace('{n}', String(appMaxFileSizeMb.value)))
}
if (flags.maxPageCount > 0) {
parts.push(t('upload.maxPages').replace('{n}', String(flags.maxPageCount)))
if (appMaxPageCount.value > 0) {
parts.push(t('upload.maxPages').replace('{n}', String(appMaxPageCount.value)))
}
return parts.join(' · ')
})

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '../../shared/api/http'
import { appMaxFileSizeMb, appMaxPageCount } from '../../shared/appConfig'
type ConversionEngine = 'local' | 'remote'
type DeploymentMode = 'self-hosted' | 'huggingface'
@ -64,6 +65,8 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
maxPageCount.value = data.maxPageCount ?? 0
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
appMaxFileSizeMb.value = maxFileSizeMb.value
appMaxPageCount.value = maxPageCount.value
if (data.version) appVersion.value = data.version
loaded.value = true
error.value = null

View file

@ -0,0 +1,10 @@
import type { Analysis } from '../../shared/types'
import { apiFetch } from '../../shared/api/http'
export function fetchHistory(): Promise<Analysis[]> {
return apiFetch<Analysis[]>('/api/analyses')
}
export function deleteHistoryEntry(id: string): Promise<unknown> {
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
}

View file

@ -14,6 +14,11 @@ vi.mock('../analysis/api', () => ({
deleteAnalysis: vi.fn(),
}))
vi.mock('./api', () => ({
fetchHistory: vi.fn(),
deleteHistoryEntry: vi.fn(),
}))
vi.mock('../document/api', () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
@ -33,8 +38,8 @@ describe('History → Studio navigation', () => {
describe('History store provides data for navigation', () => {
it('analyses contain documentId for document selection', async () => {
const { fetchAnalyses } = await import('../analysis/api')
fetchAnalyses.mockResolvedValue([
const { fetchHistory } = await import('./api')
fetchHistory.mockResolvedValue([
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
])

View file

@ -1,9 +1,70 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useHistoryStore } from './store'
import { useAnalysisStore } from '../analysis/store'
vi.mock('./api', () => ({
fetchHistory: vi.fn(),
deleteHistoryEntry: vi.fn(),
}))
import * as api from './api'
describe('useHistoryStore', () => {
it('is a re-export of useAnalysisStore', () => {
expect(useHistoryStore).toBe(useAnalysisStore)
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('starts with empty state', () => {
const store = useHistoryStore()
expect(store.analyses).toEqual([])
expect(store.error).toBeNull()
})
it('load() fetches analyses', async () => {
const analyses = [
{ id: 'a1', status: 'COMPLETED' },
{ id: 'a2', status: 'FAILED' },
]
vi.mocked(api.fetchHistory).mockResolvedValue(analyses)
const store = useHistoryStore()
await store.load()
expect(store.analyses).toEqual(analyses)
expect(store.error).toBeNull()
})
it('load() handles errors gracefully', async () => {
vi.mocked(api.fetchHistory).mockRejectedValue(new Error('network'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useHistoryStore()
await store.load()
expect(store.error).toBe('network')
})
it('remove() deletes and filters from list', async () => {
vi.mocked(api.deleteHistoryEntry).mockResolvedValue(null)
const store = useHistoryStore()
store.analyses = [{ id: 'a1' }, { id: 'a2' }] as any[]
await store.remove('a1')
expect(store.analyses).toEqual([{ id: 'a2' }])
expect(api.deleteHistoryEntry).toHaveBeenCalledWith('a1')
})
it('remove() handles errors gracefully', async () => {
vi.mocked(api.deleteHistoryEntry).mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useHistoryStore()
store.analyses = [{ id: 'a1' }] as any[]
await store.remove('a1')
expect(store.error).toBe('fail')
expect(store.analyses).toEqual([{ id: 'a1' }])
})
})

View file

@ -1 +1,31 @@
export { useAnalysisStore as useHistoryStore } from '../analysis/store'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Analysis } from '../../shared/types'
import * as api from './api'
export const useHistoryStore = defineStore('history', () => {
const analyses = ref<Analysis[]>([])
const error = ref<string | null>(null)
async function load(): Promise<void> {
try {
error.value = null
analyses.value = await api.fetchHistory()
} catch (e) {
error.value = (e as Error).message || 'Failed to load history'
console.error('Failed to load history', e)
}
}
async function remove(id: string): Promise<void> {
try {
await api.deleteHistoryEntry(id)
analyses.value = analyses.value.filter((a) => a.id !== id)
} catch (e) {
error.value = (e as Error).message || 'Failed to delete analysis'
console.error('Failed to delete analysis', e)
}
}
return { analyses, error, load, remove }
})

View file

@ -47,11 +47,11 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAnalysisStore } from '../../analysis/store'
import { useHistoryStore } from '../store'
import { useI18n } from '../../../shared/i18n'
import type { Analysis } from '../../../shared/types'
const store = useAnalysisStore()
const store = useHistoryStore()
const router = useRouter()
const { t } = useI18n()

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, watch, watchEffect } from 'vue'
import type { Locale, Theme } from '../../shared/types'
import { appLocale } from '../../shared/appConfig'
function safeGetItem(key: string): string | null {
try {
@ -24,7 +25,14 @@ export const useSettingsStore = defineStore('settings', () => {
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
watch(theme, (v) => safeSetItem('docling-theme', v))
watch(locale, (v) => safeSetItem('docling-locale', v))
watch(
locale,
(v) => {
safeSetItem('docling-locale', v)
appLocale.value = v
},
{ immediate: true },
)
watchEffect(() => {
document.documentElement.classList.toggle('light', theme.value === 'light')

View file

@ -441,7 +441,12 @@
<div v-if="mode === 'prepare' && chunkingEnabled" class="prepare-panel">
<ChunkPanel
:current-page="currentPage"
:analysis-id="analysisStore.currentAnalysis?.id ?? null"
:analysis-status="analysisStore.currentAnalysis?.status ?? null"
:has-document-json="analysisStore.currentAnalysis?.hasDocumentJson ?? false"
:chunks="analysisStore.currentChunks"
@highlight-bboxes="highlightedChunkBboxes = $event"
@rechunked="onRechunked"
/>
</div>
</div>
@ -563,6 +568,12 @@ function addMore() {
documentStore.selectedId = null
}
async function onRechunked() {
if (analysisStore.currentAnalysis?.id) {
await analysisStore.select(analysisStore.currentAnalysis.id)
}
}
// Clear highlights when switching modes or pages
watch(mode, () => {
highlightedElementIndex.value = -1

View file

@ -0,0 +1,11 @@
import { ref } from 'vue'
import type { Locale } from './types'
/**
* App-wide reactive configuration populated by feature stores,
* consumed by shared utilities and other features.
* Breaks the dependency cycle between features and shared.
*/
export const appLocale = ref<Locale>('fr')
export const appMaxFileSizeMb = ref<number>(0)
export const appMaxPageCount = ref<number>(0)

View file

@ -1,23 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock settings store before importing i18n
vi.mock('../features/settings/store', () => ({
useSettingsStore: vi.fn(),
}))
import { useSettingsStore } from '../features/settings/store'
import { describe, it, expect, beforeEach } from 'vitest'
import { appLocale } from './appConfig'
import { useI18n } from './i18n'
describe('useI18n', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
appLocale.value = 'fr'
})
it('returns French translation by default', () => {
useSettingsStore.mockReturnValue({ locale: 'fr' })
const { t } = useI18n()
expect(t('nav.studio')).toBe('Studio')
expect(t('nav.history')).toBe('Historique')
@ -25,7 +15,7 @@ describe('useI18n', () => {
})
it('returns English translation when locale is en', () => {
useSettingsStore.mockReturnValue({ locale: 'en' })
appLocale.value = 'en'
const { t } = useI18n()
expect(t('nav.history')).toBe('History')
@ -33,36 +23,30 @@ describe('useI18n', () => {
})
it('falls back to French when key missing in current locale', () => {
useSettingsStore.mockReturnValue({ locale: 'de' })
appLocale.value = 'de' as any
const { t } = useI18n()
expect(t('nav.studio')).toBe('Studio')
})
it('returns key when not found in any locale', () => {
useSettingsStore.mockReturnValue({ locale: 'fr' })
const { t } = useI18n()
expect(t('unknown.key')).toBe('unknown.key')
})
it('interpolates parameters', () => {
useSettingsStore.mockReturnValue({ locale: 'en' })
appLocale.value = 'en'
const { t } = useI18n()
expect(t('results.pageOf', { current: 3, total: 10 })).toBe('Page 3 of 10')
})
it('interpolates parameters in French', () => {
useSettingsStore.mockReturnValue({ locale: 'fr' })
const { t } = useI18n()
expect(t('results.pageOf', { current: 1, total: 5 })).toBe('Page 1 sur 5')
})
it('has history tab keys in French', () => {
useSettingsStore.mockReturnValue({ locale: 'fr' })
const { t } = useI18n()
expect(t('history.tabAnalyses')).toBe('Analyses')
expect(t('history.tabDocuments')).toBe('Documents')
@ -70,7 +54,7 @@ describe('useI18n', () => {
})
it('has history tab keys in English', () => {
useSettingsStore.mockReturnValue({ locale: 'en' })
appLocale.value = 'en'
const { t } = useI18n()
expect(t('history.tabAnalyses')).toBe('Analyses')
@ -79,10 +63,7 @@ describe('useI18n', () => {
})
it('has detailed pipeline option hints in French', () => {
useSettingsStore.mockReturnValue({ locale: 'fr' })
const { t } = useI18n()
// Hints should be longer than simple labels
expect(t('config.ocrHint').length).toBeGreaterThan(40)
expect(t('config.tableStructureHint').length).toBeGreaterThan(40)
expect(t('config.codeEnrichmentHint').length).toBeGreaterThan(40)
@ -94,7 +75,7 @@ describe('useI18n', () => {
})
it('has detailed pipeline option hints in English', () => {
useSettingsStore.mockReturnValue({ locale: 'en' })
appLocale.value = 'en'
const { t } = useI18n()
expect(t('config.ocrHint').length).toBeGreaterThan(40)

View file

@ -1,5 +1,5 @@
import type { Locale } from './types'
import { useSettingsStore } from '../features/settings/store'
import { appLocale } from './appConfig'
type MessageMap = Record<string, string>
type Messages = Record<Locale, MessageMap>
@ -268,10 +268,8 @@ const messages: Messages = {
}
export function useI18n() {
const settings = useSettingsStore()
function t(key: string, params: Record<string, string | number> = {}): string {
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
let str = messages[appLocale.value]?.[key] || messages['fr'][key] || key
for (const [k, v] of Object.entries(params)) {
str = str.replaceAll(`{${k}}`, String(v))
}