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

@ -16,7 +16,7 @@ import {
getSimulationState,
updateSujet as updateSujetApi,
} from '@/entities/production/api'
import { correctEe } from '@/entities/report/api'
import { correctEe, correctEo } from '@/entities/report/api'
import type { CreateSimulationPayload, Production, Tache } from '@/entities/production/types'
import type { Report } from '@/entities/report/types'
import type { ApiError } from '@/shared/types/api'
@ -25,10 +25,29 @@ import { SimulationFlowContext, type FlowValue, type SimulationStep } from './si
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
const LS_EO_T1_PRESENTATION_KEY = 'expria_eo_t1_presentation'
function isEoTache(tache: Tache): boolean {
return tache.startsWith('EO_')
}
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
const [step, setStep] = useState<SimulationStep>('idle')
const [production, setProduction] = useState<Production | null>(null)
const [taskUnavailableMessage, setTaskUnavailableMessage] = useState<string | null>(null)
// Sprint 4c-2 — état initialisé depuis localStorage pour survivre au refresh
// tout au long du flux T1 (questionnaire → présentation → enregistrement).
const [presentationT1, setPresentationT1State] = useState<string | null>(() => {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
})
function setPresentationT1(text: string | null): void {
setPresentationT1State(text)
if (typeof window === 'undefined') return
if (text === null) window.localStorage.removeItem(LS_EO_T1_PRESENTATION_KEY)
else window.localStorage.setItem(LS_EO_T1_PRESENTATION_KEY, text)
}
const navigate = useNavigate()
const location = useLocation()
const hydratedRef = useRef(false)
@ -59,8 +78,25 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
sujet_id: state.sujet?.id,
})
setStep('task-selected')
if (!location.pathname.startsWith('/simulation/ee')) {
navigate('/simulation/ee')
// Sprint 4c-2 — restauration EO :
// - EO_T1 + présentation déjà générée → /t1/presentation
// - EO_T1 sans présentation → /t1/mode (choix mode)
// - EO_T3 → /pre-enregistrement
// - EE → /simulation/ee
let targetBase: string
if (state.tache === 'EO_T1') {
const stored =
typeof window !== 'undefined'
? window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
: null
targetBase = stored ? '/simulation/eo/t1/presentation' : '/simulation/eo/t1/mode'
} else if (isEoTache(state.tache)) {
targetBase = '/simulation/eo/pre-enregistrement'
} else {
targetBase = '/simulation/ee'
}
if (!location.pathname.startsWith(targetBase)) {
navigate(targetBase)
}
})
.catch(() => {
@ -75,11 +111,15 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
setProduction(data)
const hasCatalogue = !TACHES_SANS_CATALOGUE.includes(data.tache)
setStep(hasCatalogue ? 'choosing-subject' : 'task-selected')
// Navigation initiale vers /sujets pour les tâches avec catalogue —
// gérée ici (et non dans un useEffect sticky côté SimulationPage) pour
// éviter la boucle infinie quand l'utilisateur revient depuis /sujets.
if (hasCatalogue) {
navigate('/sujets')
// Sprint 4c-2 — routage post-création :
// - EE_T1/T2/T3 (avec catalogue) → /sujets (legacy)
// - EO_T3 (avec catalogue) → /simulation/eo/sujets
// - EO_T1 (sans catalogue) → /simulation/eo/t1/mode (choix génération
// vs enregistrement direct).
if (data.tache === 'EO_T1') {
navigate('/simulation/eo/t1/mode')
} else if (hasCatalogue) {
navigate(isEoTache(data.tache) ? '/simulation/eo/sujets' : '/sujets')
}
},
})
@ -98,7 +138,24 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
onError: () => setStep('task-selected'),
})
// Sprint 4c-1 — mutation EO. Sépare le pipeline pour éviter de devoir
// discriminer dynamiquement le payload (EE vs EO) côté mutationFn.
const correctEoMutation = useMutation({
mutationFn: correctEo,
onMutate: () => setStep('correcting'),
onSuccess: (_data, variables) => {
setStep('done')
localStorage.removeItem(LS_SIMULATION_ID_KEY)
navigate(`/rapport/${variables.simulationId}`)
},
onError: () => setStep('recording'),
})
function selectTask(payload: CreateSimulationPayload): void {
// Sprint 4c-2 — l'interception EO_T1 introduite en 4c-1 est levée :
// le flux T1 est désormais wired (cf. createMutation.onSuccess).
// `taskUnavailableMessage` reste exposé pour de futurs cas (ex. T2 Live).
setTaskUnavailableMessage(null)
createMutation.mutate(payload)
}
@ -112,6 +169,21 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
})
}
// Sprint 4c-3 — bascule transcription live → audio batch backend.
// Le frontend envoie l'audio brut en base64 + mimeType ; le backend appelle
// Gemini batch pour la transcription puis poursuit le pipeline correction
// (cf. POST /corrections/eo en mode audio).
function submitEoAudio(audioBase64: string, mimeType: string, nclcCible: 9 | 10 = 9): void {
if (!production) return
correctEoMutation.mutate({
simulationId: production.id,
tache: production.tache,
audioBase64,
mimeType,
nclc_cible: nclcCible,
})
}
function changeSubject(sujet: SujetData): void {
// FTD-21 — persiste le changement côté backend (best-effort : l'UI ne bloque pas).
if (production) {
@ -125,22 +197,29 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
function reset(): void {
setStep('idle')
setProduction(null)
setTaskUnavailableMessage(null)
setPresentationT1(null)
localStorage.removeItem(LS_SIMULATION_ID_KEY)
createMutation.reset()
correctMutation.reset()
correctEoMutation.reset()
}
const value: FlowValue = {
step,
production,
sujet: production?.sujet ?? null,
report: (correctMutation.data ?? null) as Report | null,
report: (correctMutation.data ?? correctEoMutation.data ?? null) as Report | null,
isCreating: createMutation.isPending,
isCorrecting: correctMutation.isPending,
isCorrecting: correctMutation.isPending || correctEoMutation.isPending,
createError: createMutation.error as ApiError | null,
correctError: correctMutation.error as ApiError | null,
correctError: (correctMutation.error ?? correctEoMutation.error) as ApiError | null,
taskUnavailableMessage,
presentationT1,
setPresentationT1,
selectTask,
submitText,
submitEoAudio,
changeSubject,
setStep,
reset,

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()
})
})

View file

@ -11,7 +11,13 @@ import type { CreateSimulationPayload, Production, SujetData } from '@/entities/
import type { Report } from '@/entities/report/types'
import type { ApiError } from '@/shared/types/api'
export type SimulationStep = 'idle' | 'choosing-subject' | 'task-selected' | 'correcting' | 'done'
export type SimulationStep =
| 'idle'
| 'choosing-subject'
| 'task-selected'
| 'recording'
| 'correcting'
| 'done'
export interface FlowValue {
step: SimulationStep
@ -22,8 +28,32 @@ export interface FlowValue {
isCorrecting: boolean
createError: ApiError | null
correctError: ApiError | null
/**
* Sprint 4c-1 message d'info non bloquant remonté par `selectTask` quand
* l'utilisateur clique sur une tâche temporairement indisponible (EO_T1
* dans 4c-1). La tâche n'est pas créée et l'UI affiche le message.
* Réinitialisé à null à chaque nouvelle action.
*/
taskUnavailableMessage: string | null
/**
* Sprint 4c-2 texte de présentation T1 généré par DeepSeek (ou édité
* manuellement par l'utilisateur). Utilisé comme texte de référence
* affiché pendant l'enregistrement EO_T1. Mirroré aussi dans
* `localStorage.expria_eo_t1_presentation` pour survivre aux refresh.
* `null` quand aucune présentation n'a encore é générée pour la session
* en cours, ou quand l'utilisateur a choisi le mode « enregistrer
* directement » (sans questionnaire).
*/
presentationT1: string | null
setPresentationT1: (text: string | null) => void
selectTask: (payload: CreateSimulationPayload) => void
submitText: (texte: string, nclcCible?: 9 | 10) => void
/**
* Sprint 4c-1 (transcript live Deepgram) 4c-3 (audio batch Gemini backend) :
* envoie l'audio brut en base64 au backend qui transcrit puis corrige. Le
* paramètre `mimeType` indique le format produit par MediaRecorder.
*/
submitEoAudio: (audioBase64: string, mimeType: string, nclcCible?: 9 | 10) => void
changeSubject: (sujet: SujetData) => void
setStep: (step: SimulationStep) => void
reset: () => void