feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription

- Gemini batch transcription (no Deepgram live)
- blobToBase64 helper (shared/lib/audio.ts)
- AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit
- Timer stops at maxSeconds and triggers auto-submission
- EnregistrementEOPage: audioBase64 to backend, fix race condition step=done
- SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible)
- MIME normalization (strip codec params)
- Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s)
- PresentationGenereeT1Page: localStorage persistence

Typecheck: OK · Tests: 159/159 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 08:28:51 +03:00
parent 71c1ad3018
commit d1c8b548bb
34 changed files with 3255 additions and 70 deletions

View file

@ -0,0 +1,145 @@
/**
* Tests du flow EO ajoutés en Sprint 4c-1.
*
* Couvre :
* - selectTask EO_T1 message inline, pas de création
* - selectTask EO_T3 création + step='choosing-subject' (navigation testée
* via le mock de useNavigate)
* - submitEoAudio appelle correctEo avec audioBase64 + mimeType (Sprint 4c-3)
* - non-régression EE : selectTask EE_T1 fonctionne toujours
*/
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/entities/production/api')
vi.mock('@/entities/report/api')
import { createSimulation, getSimulationState } from '@/entities/production/api'
import { correctEo } from '@/entities/report/api'
import { SimulationFlowProvider } from '../SimulationFlowProvider'
import { useSimulationFlow } from '../simulationFlow'
import type { Production } from '@/entities/production/types'
import type { Report } from '@/entities/report/types'
const mockCreate = vi.mocked(createSimulation)
const mockCorrectEo = vi.mocked(correctEo)
const mockGetState = vi.mocked(getSimulationState)
const eoT3Production: Production = {
id: 'sim-eo-1',
tache: 'EO_T3',
mode: 'entrainement',
created_at: '2026-04-25T00:00:00Z',
sujet: null,
}
const mockEoReport: Report = {
simulation_id: 'sim-eo-1',
score: 14,
nclc: 9,
nclc_cible: 9,
revelation: { croyance: '', realite: '', consequence: '' },
diagnostic: '',
criteres: [],
conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
erreurs_codes: [],
exercices: null,
exercices_status: 'pending',
modele: null,
modele_status: 'pending',
}
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
MemoryRouter,
null,
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(SimulationFlowProvider, null, children),
),
)
}
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockGetState.mockRejectedValue(new Error('no resume'))
})
describe('SimulationFlowProvider EO — Sprint 4c-1', () => {
it('Sprint 4c-2 — EO_T1 crée la simulation (interception 4c-1 levée)', async () => {
const eoT1: Production = { ...eoT3Production, tache: 'EO_T1' }
mockCreate.mockResolvedValue(eoT1)
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
act(() => {
result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' })
})
await waitFor(() => expect(result.current.step).toBe('task-selected'))
expect(mockCreate).toHaveBeenCalledTimes(1)
expect(result.current.taskUnavailableMessage).toBeNull()
})
it('EO_T3 : selectTask crée la simulation et passe en choosing-subject', async () => {
mockCreate.mockResolvedValue(eoT3Production)
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
act(() => {
result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
})
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
expect(result.current.production).toEqual(eoT3Production)
expect(mockCreate.mock.calls[0]?.[0]).toEqual({ tache: 'EO_T3', mode: 'entrainement' })
})
it('submitEoAudio appelle correctEo avec audioBase64 + mimeType', async () => {
mockCreate.mockResolvedValue(eoT3Production)
mockCorrectEo.mockResolvedValue(mockEoReport)
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
act(() => {
result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
})
await waitFor(() => expect(result.current.production).toEqual(eoT3Production))
act(() => {
result.current.submitEoAudio('AAAAAA==', 'audio/webm', 9)
})
await waitFor(() => {
expect(mockCorrectEo).toHaveBeenCalled()
})
expect(mockCorrectEo.mock.calls[0]?.[0]).toEqual({
simulationId: 'sim-eo-1',
tache: 'EO_T3',
audioBase64: 'AAAAAA==',
mimeType: 'audio/webm',
nclc_cible: 9,
})
})
it('non-régression EE : selectTask EE_T1 reste fonctionnel', async () => {
const eeProduction: Production = { ...eoT3Production, id: 'sim-ee', tache: 'EE_T1' }
mockCreate.mockResolvedValue(eeProduction)
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
act(() => {
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
})
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
expect(result.current.production).toEqual(eeProduction)
})
})

View file

@ -0,0 +1,96 @@
/**
* Tests du flow T1 Sprint 4c-2.
*
* Couvre :
* - setPresentationT1 expose la valeur via le hook + persiste en localStorage
* - reset() remet presentationT1 à null + nettoie localStorage
* - hydratation au mount lit la valeur depuis localStorage
*/
import React from 'react'
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/entities/production/api')
vi.mock('@/entities/report/api')
import { getSimulationState } from '@/entities/production/api'
import { SimulationFlowProvider } from '../SimulationFlowProvider'
import { useSimulationFlow } from '../simulationFlow'
const mockGetState = vi.mocked(getSimulationState)
const LS_KEY = 'expria_eo_t1_presentation'
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
MemoryRouter,
null,
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(SimulationFlowProvider, null, children),
),
)
}
}
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
mockGetState.mockRejectedValue(new Error('no resume'))
})
describe('SimulationFlowProvider T1 — Sprint 4c-2', () => {
it('setPresentationT1 expose la valeur et la persiste en localStorage', () => {
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
expect(result.current.presentationT1).toBeNull()
act(() => {
result.current.setPresentationT1('Bonjour je m appelle Marie...')
})
expect(result.current.presentationT1).toBe('Bonjour je m appelle Marie...')
expect(localStorage.getItem(LS_KEY)).toBe('Bonjour je m appelle Marie...')
})
it('setPresentationT1(null) supprime la clé localStorage', () => {
localStorage.setItem(LS_KEY, 'old')
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
act(() => {
result.current.setPresentationT1(null)
})
expect(result.current.presentationT1).toBeNull()
expect(localStorage.getItem(LS_KEY)).toBeNull()
})
it('hydrate presentationT1 depuis localStorage au mount', () => {
localStorage.setItem(LS_KEY, 'présentation persistée')
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
expect(result.current.presentationT1).toBe('présentation persistée')
})
it('reset() remet presentationT1 à null et nettoie localStorage', () => {
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
act(() => {
result.current.setPresentationT1('texte')
})
expect(result.current.presentationT1).toBe('texte')
act(() => {
result.current.reset()
})
expect(result.current.presentationT1).toBeNull()
expect(localStorage.getItem(LS_KEY)).toBeNull()
})
})