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:
parent
71c1ad3018
commit
d1c8b548bb
34 changed files with 3255 additions and 70 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 été 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue