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

@ -8,6 +8,13 @@ import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
import { SujetsPage } from '@/features/simulations/pages/SujetsPage'
import { RapportPage } from '@/features/simulations/pages/RapportPage'
import { SimulationEOPage } from '@/features/simulations/pages/SimulationEOPage'
import { SujetsEOPage } from '@/features/simulations/pages/SujetsEOPage'
import { PreEnregistrementEOPage } from '@/features/simulations/pages/PreEnregistrementEOPage'
import { EnregistrementEOPage } from '@/features/simulations/pages/EnregistrementEOPage'
import { ModeChoixT1Page } from '@/features/simulations/pages/ModeChoixT1Page'
import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT1Page'
import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page'
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
@ -65,9 +72,17 @@ export function AppRouter() {
<Route element={<SimulationFlowLayout />}>
<Route path="/simulation/ee" element={<SimulationPage />} />
<Route path="/sujets" element={<SujetsPage />} />
{/* Sprint 4c-1 — flow EO */}
<Route path="/simulation/eo" element={<SimulationEOPage />} />
<Route path="/simulation/eo/sujets" element={<SujetsEOPage />} />
<Route path="/simulation/eo/pre-enregistrement" element={<PreEnregistrementEOPage />} />
<Route path="/simulation/eo/enregistrement" element={<EnregistrementEOPage />} />
{/* Sprint 4c-2 — flux T1 (questionnaire + génération + présentation) */}
<Route path="/simulation/eo/t1/mode" element={<ModeChoixT1Page />} />
<Route path="/simulation/eo/t1/questionnaire" element={<QuestionnaireT1Page />} />
<Route path="/simulation/eo/t1/presentation" element={<PresentationGenereeT1Page />} />
<Route path="/rapport/:id" element={<RapportPage />} />
</Route>
<Route path="/simulation/eo" element={<ComingSoon />} />
{/* Autres sections — Sprint 4+ */}
<Route path="/examen" element={<ComingSoon />} />

View file

@ -0,0 +1,59 @@
/**
* Tests du domaine `presentation` Sprint 4c-2.
*
* Valide :
* - succès : retourne { presentation }
* - erreur : ApiError propagée par apiFetch
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@/shared/lib/api-client', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '@/shared/lib/api-client'
import { generatePresentation } from '../api'
import type { PresentationReponses } from '../types'
const mocked = vi.mocked(apiFetch)
const VALID_REPONSES: PresentationReponses = {
prenom_age_ville: 'Marie, 32 ans, Douala',
formation_metier: 'Master en gestion, comptable',
situation_familiale: 'Mariée, deux enfants',
loisirs: 'Lecture, cuisine',
motivation_canada: 'Opportunités, départ 2025',
}
describe('generatePresentation', () => {
beforeEach(() => {
mocked.mockReset()
})
it('retourne la présentation générée et appelle le bon endpoint', async () => {
mocked.mockResolvedValueOnce({ presentation: 'Bonjour, je m appelle Marie...' })
const result = await generatePresentation(VALID_REPONSES)
expect(result.presentation).toContain('Marie')
expect(mocked).toHaveBeenCalledWith('/presentations/generate', {
method: 'POST',
body: { reponses: VALID_REPONSES },
timeoutMs: 25_000,
retry: { max: 0, baseDelayMs: 0 },
})
})
it('propage les ApiError du backend', async () => {
mocked.mockRejectedValueOnce({
error: true,
code: 'INTERNAL_ERROR',
message: 'DeepSeek down',
})
await expect(generatePresentation(VALID_REPONSES)).rejects.toMatchObject({
code: 'INTERNAL_ERROR',
})
})
})

View file

@ -0,0 +1,23 @@
/**
* Appels API du domaine `presentation` Sprint 4c-2.
*
* `POST /presentations/generate` : timeout 25 s (DeepSeek peut mettre 10-20 s),
* retry désactivé volontairement un POST non-idempotent qui consomme un
* appel DeepSeek ne doit pas être rejoué silencieusement sur erreur réseau.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { PresentationGenerated, PresentationReponses } from './types'
const GENERATE_TIMEOUT_MS = 25_000
export function generatePresentation(
reponses: PresentationReponses,
): Promise<PresentationGenerated> {
return apiFetch<PresentationGenerated>('/presentations/generate', {
method: 'POST',
body: { reponses },
timeoutMs: GENERATE_TIMEOUT_MS,
retry: { max: 0, baseDelayMs: 0 },
})
}

View file

@ -0,0 +1,23 @@
/**
* Types publics du domaine `presentation` Sprint 4c-2.
*
* Le domaine couvre la génération assistée d'un texte de présentation
* personnelle (Tâche 1 EO). Aucune persistance backend : le texte généré
* est mirroré côté client (localStorage `expria_eo_t1_presentation`) et
* porté dans le state du `SimulationFlowProvider` pour servir de
* référence pendant l'enregistrement.
*/
/** Réponses au questionnaire — alignées sur le body du backend. */
export interface PresentationReponses {
prenom_age_ville: string
formation_metier: string
situation_familiale: string
loisirs: string
motivation_canada: string
}
/** Réponse de `POST /presentations/generate`. */
export interface PresentationGenerated {
presentation: string
}

View file

@ -48,7 +48,14 @@ export function getReport(id: string): Promise<Report> {
// Sprint 3.6a — le nouveau prompt maître (taxonomie + revelation + diagnostic +
// criteres×6 champs + conseil_nclc + erreurs_codes) produit un JSON long ;
// DeepSeek met typiquement 25-45 s pour répondre. Backend abort à 55 s.
const CORRECTION_TIMEOUT_MS = 60_000
const CORRECTION_EE_TIMEOUT_MS = 60_000
// Sprint 4b.3 — EO en mode audio enchaîne Gemini transcribe (jusqu'à 60 s,
// 30 s + 1 retry de 30 s) puis DeepSeek correction (55 s côté backend).
// Pire cas serveur ≈ 115 s : on alloue 120 s côté client pour ne pas couper
// avant que la mutation aboutisse (le rapport apparaissait sinon dans
// l'historique sans navigation vers /rapport/:id).
const CORRECTION_EO_TIMEOUT_MS = 120_000
/** Soumet une production écrite pour correction. Endpoint : `POST /corrections/ee`.
* Payload : { simulationId, contenu, tache }
@ -57,7 +64,7 @@ export function correctEe(payload: CorrectEePayload): Promise<Report> {
return apiFetch<Report>('/corrections/ee', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_TIMEOUT_MS,
timeoutMs: CORRECTION_EE_TIMEOUT_MS,
})
}
@ -69,7 +76,7 @@ export function correctEo(payload: CorrectEoPayload): Promise<Report> {
return apiFetch<Report>('/corrections/eo', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_TIMEOUT_MS,
timeoutMs: CORRECTION_EO_TIMEOUT_MS,
})
}

View file

@ -127,10 +127,23 @@ export interface CorrectEePayload {
* Corps de `POST /corrections/eo`.
* transcript : transcription audio envoyée au backend (implémenté Sprint 4).
*/
/**
* Corps de `POST /corrections/eo`.
*
* Modes (XOR exactement un des deux) :
* - `transcript` (Sprint 4) : transcription texte fournie directement par le client.
* - `audioBase64` + `mimeType` (Sprint 4b.2) : audio brut, le backend transcrit
* via Gemini batch puis poursuit le pipeline correction.
*/
export interface CorrectEoPayload {
simulationId: string
transcript: string
tache: string
/** Sprint 4a backend — cible NCLC (9 par défaut, 10 pour viser plus haut). */
nclc_cible?: 9 | 10
transcript?: string
audioBase64?: string
/** MIME du payload audio (audio/webm | audio/mp4 | audio/wav). */
mimeType?: string
}
/**

View file

@ -0,0 +1,49 @@
/**
* Tests du domaine `transcription` Sprint 4c-1.
*
* Valide :
* - succès : retourne le token et expires_in
* - erreur : ApiError propagée par apiFetch
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@/shared/lib/api-client', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '@/shared/lib/api-client'
import { requestDeepgramToken } from '../api'
const mocked = vi.mocked(apiFetch)
describe('requestDeepgramToken', () => {
beforeEach(() => {
mocked.mockReset()
})
it('retourne le token et expires_in en cas de succès', async () => {
mocked.mockResolvedValueOnce({ token: 'dg-temp-abc', expires_in: 600 })
const result = await requestDeepgramToken()
expect(result).toEqual({ token: 'dg-temp-abc', expires_in: 600 })
expect(mocked).toHaveBeenCalledWith('/transcriptions/token', {
method: 'POST',
timeoutMs: 10_000,
retry: { max: 0, baseDelayMs: 0 },
})
})
it('propage les ApiError du backend', async () => {
mocked.mockRejectedValueOnce({
error: true,
code: 'AUTH_REQUIRED',
message: 'Auth required',
})
await expect(requestDeepgramToken()).rejects.toMatchObject({
code: 'AUTH_REQUIRED',
})
})
})

View file

@ -0,0 +1,21 @@
/**
* Appels API du domaine `transcription`.
*
* `POST /transcriptions/token` : timeout 10 s, retry désactivé.
* Le retry est désactivé volontairement : un POST non-idempotent qui
* consomme un crédit Deepgram à chaque appel ne doit pas être rejoué
* silencieusement en cas d'erreur réseau transitoire.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { TranscriptionToken } from './types'
const TOKEN_TIMEOUT_MS = 10_000
export function requestDeepgramToken(): Promise<TranscriptionToken> {
return apiFetch<TranscriptionToken>('/transcriptions/token', {
method: 'POST',
timeoutMs: TOKEN_TIMEOUT_MS,
retry: { max: 0, baseDelayMs: 0 },
})
}

View file

@ -0,0 +1,15 @@
/**
* Types publics du domaine `transcription`.
*
* Le frontend obtient un token Deepgram éphémère via le backend
* (`POST /transcriptions/token`) puis ouvre une connexion WebSocket
* directe vers Deepgram pour la transcription live. La clé maître
* Deepgram reste côté backend (cf. SECURITY.md).
*/
export interface TranscriptionToken {
/** JWT éphémère Deepgram (durée de vie ~10 min). */
token: string
/** Durée de validité du token, en secondes. */
expires_in: number
}

View file

@ -0,0 +1,187 @@
/**
* Composant d'enregistrement audio pour les productions orales
* Sprint 4c-1, simplifié au Sprint 4c-3.
*
* Encapsule `useAudioRecorder` côté UI : timer montant MM:SS, indicateur
* visuel d'enregistrement, garde-fou minimum 30 s, bouton de téléchargement
* local de l'audio (le backend ne stocke aucun audio).
*
* Le streaming chunk-par-chunk a é retiré au Sprint 4c-3 : l'audio est
* envoyé entier au backend après stop, le backend appelle Gemini batch pour
* transcrire. `useAudioRecorder.subscribeChunks` reste exposé côté hook
* pour un usage futur (ex. réactivation Deepgram live).
*/
import { useEffect } from 'react'
import { Download, Mic, MicOff, Square } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { formatTimer } from '../lib/simulationConfig'
import { useAudioRecorder } from '../hooks/useAudioRecorder'
interface Props {
/** Durée minimale (s) avant que la soumission soit autorisée. */
minSeconds: number
/**
* Sprint 4b.3 durée maximale recommandée (s). À l'atteinte, le hook
* arrête automatiquement l'enregistrement et l'`onSubmit` est déclenché
* via le chemin existant (status='stopped' useEffect onSubmit).
*/
maxSeconds?: number
/** Notification optionnelle quand `maxSeconds` est atteint. */
onMaxReached?: () => void
/** Nom de fichier proposé au téléchargement local (sans extension). */
downloadFilename: string
/** Appelé au clic « Arrêter et soumettre » avec le blob final + son MIME. */
onSubmit: (audioBlob: Blob, audioMimeType: string | null) => void
onCancel: () => void
/** Initialisé à true l'utilisateur démarre l'enregistrement automatiquement
* au mount. Sinon, un bouton « Démarrer » est affiché. */
autoStart?: boolean
/**
* Sprint 4c-3 désactive les contrôles tant qu'une soumission est en
* cours côté parent (transcription + correction backend ~30-60 s).
*/
disabled?: boolean
}
export function AudioRecorder({
minSeconds,
maxSeconds,
onMaxReached,
downloadFilename,
onSubmit,
onCancel,
autoStart = true,
disabled = false,
}: Props) {
const recorder = useAudioRecorder({ maxSeconds, onMaxReached })
// Auto-start au mount si demandé. Pas de dépendance sur `recorder.start`
// pour éviter les re-runs au changement d'identité de la fonction.
useEffect(() => {
if (!autoStart) return
void recorder.start()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStart])
const isRecording = recorder.status === 'recording'
const isStopped = recorder.status === 'stopped'
const remaining = Math.max(0, minSeconds - recorder.elapsedSeconds)
const submitEnabled = isRecording && remaining === 0
function handleSubmitClick() {
recorder.stop()
}
// Quand le recorder passe en 'stopped', on remonte le blob au parent.
useEffect(() => {
if (recorder.status === 'stopped' && recorder.audioBlob) {
onSubmit(recorder.audioBlob, recorder.audioMimeType)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recorder.status, recorder.audioBlob])
if (recorder.status === 'error') {
return (
<div
role="alert"
className="rounded-lg border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
>
<div className="flex items-start gap-2">
<MicOff className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div className="flex-1">
<p className="font-medium">{recorder.error ?? 'Erreur audio.'}</p>
{recorder.permissionDenied && (
<p className="mt-1 text-xs">
Vérifiez que le site a l'autorisation d'utiliser le micro dans les réglages du
navigateur, puis réessayez.
</p>
)}
</div>
</div>
<div className="mt-3 flex gap-2">
<Button variant="secondary" size="sm" onClick={() => void recorder.start()}>
Réessayer
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
Annuler
</Button>
</div>
</div>
)
}
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className={
isRecording
? 'inline-block size-3 animate-pulse rounded-pill bg-danger'
: 'inline-block size-3 rounded-pill bg-ink-tertiary/40'
}
/>
<span className="text-sm font-medium text-ink-primary">
{recorder.status === 'requesting' && 'Autorisation du micro…'}
{isRecording && 'Enregistrement actif'}
{isStopped && 'Enregistrement terminé'}
{recorder.status === 'idle' && 'Prêt'}
</span>
<span
className="ml-auto font-mono text-xl tabular-nums text-ink-primary"
aria-live="polite"
>
{formatTimer(recorder.elapsedSeconds)}
</span>
</div>
{isRecording && remaining > 0 && (
<p className="mt-3 text-xs text-ink-secondary">
Minimum 30 secondes requis ({remaining} s restantes).
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{isRecording && (
<>
<Button
variant="primary"
size="sm"
icon={<Square className="size-4" aria-hidden="true" />}
onClick={handleSubmitClick}
disabled={!submitEnabled || disabled}
>
{submitEnabled ? 'Arrêter et soumettre' : `Arrêter et soumettre (${remaining}s)`}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={disabled}>
Annuler
</Button>
</>
)}
{recorder.status === 'idle' && (
<Button
variant="primary"
size="sm"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={() => void recorder.start()}
>
Démarrer l'enregistrement
</Button>
)}
{isStopped && (
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={() => recorder.downloadAudio(`${downloadFilename}.webm`)}
>
Télécharger l'audio
</Button>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,60 @@
/**
* Affichage du transcript live Deepgram Sprint 4c-1.
*
* Présente le transcript final accumulé + l'interim en cours (en italique).
* Compteur de mots informatif. Empty state explicite tant qu'aucun mot n'a
* é retourné.
*/
import { Loader2 } from 'lucide-react'
import { countWords } from '../lib/simulationConfig'
interface Props {
/** Transcript final accumulé (segments is_final=true). */
transcript: string
/** Buffer interim (segment is_final=false en cours). */
interim?: string
/** True quand la WS Deepgram est ouverte. */
isConnected: boolean
}
export function TranscriptionDisplay({ transcript, interim = '', isConnected }: Props) {
const total = transcript + (interim ? ` ${interim}` : '')
const wordCount = countWords(transcript)
const isEmpty = total.trim().length === 0
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Loader2 className="size-3.5 animate-spin text-brand-text" aria-hidden="true" />
<span className="text-sm font-medium text-ink-primary">Transcription en cours</span>
</>
) : (
<span className="text-sm font-medium text-ink-secondary">Transcription en attente</span>
)}
</div>
<span className="font-mono text-xs tabular-nums text-ink-secondary">
{wordCount} mot{wordCount > 1 ? 's' : ''}
</span>
</div>
<div
className="max-h-64 overflow-y-auto rounded-md bg-canvas p-3 text-sm leading-relaxed text-ink-primary"
aria-live="polite"
aria-atomic="false"
>
{isEmpty ? (
<span className="italic text-ink-tertiary">En attente du premier mot</span>
) : (
<>
<span>{transcript}</span>
{interim && <span className="ml-1 italic text-ink-secondary">{interim}</span>}
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { TranscriptionDisplay } from '../TranscriptionDisplay'
describe('TranscriptionDisplay', () => {
it('affiche un état "Transcription en attente" quand non connecté et vide', () => {
render(<TranscriptionDisplay transcript="" isConnected={false} />)
expect(screen.getByText(/Transcription en attente/i)).toBeInTheDocument()
expect(screen.getByText(/En attente du premier mot/i)).toBeInTheDocument()
expect(screen.getByText(/^0 mot$/)).toBeInTheDocument()
})
it('affiche le label "Transcription en cours…" quand connecté', () => {
render(<TranscriptionDisplay transcript="" isConnected={true} />)
expect(screen.getByText(/Transcription en cours/i)).toBeInTheDocument()
})
it("compte les mots du transcript final (ignore l'interim)", () => {
render(
<TranscriptionDisplay
transcript="Bonjour je m appelle Pierre"
interim="et je"
isConnected={true}
/>,
)
expect(screen.getByText(/^5 mots$/)).toBeInTheDocument()
})
it('rend transcript final + interim concaténés', () => {
const { container } = render(
<TranscriptionDisplay transcript="Bonjour" interim="je continue" isConnected={true} />,
)
expect(container.textContent).toContain('Bonjour')
expect(container.textContent).toContain('je continue')
})
})

View file

@ -0,0 +1,195 @@
/**
* Tests du hook useAudioRecorder Sprint 4c-1.
*
* jsdom ne fournit ni MediaRecorder ni navigator.mediaDevices : on les mocke.
* On valide :
* - permission denied status 'error' + permissionDenied=true
* - start status 'recording', timer incrémente
* - stop status 'stopped' + audioBlob produit
* - subscribeChunks reçoit les chunks pendant l'enregistrement
*/
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAudioRecorder } from '../useAudioRecorder'
// ── Mocks MediaRecorder + getUserMedia ──────────────────────────────────
class FakeMediaStream {
getTracks() {
return [{ stop: vi.fn() }]
}
}
interface FakeRecorderInstance {
state: 'inactive' | 'recording'
start: (timeslice?: number) => void
stop: () => void
ondataavailable: ((e: { data: Blob }) => void) | null
onstop: (() => void) | null
onerror: ((e: unknown) => void) | null
emitChunk: (chunk: Blob) => void
}
const recorderInstances: FakeRecorderInstance[] = []
class FakeMediaRecorder {
state: 'inactive' | 'recording' = 'inactive'
ondataavailable: ((e: { data: Blob }) => void) | null = null
onstop: (() => void) | null = null
onerror: ((e: unknown) => void) | null = null
constructor() {
const inst: FakeRecorderInstance = {
get state() {
return self.state
},
set state(v: 'inactive' | 'recording') {
self.state = v
},
start: (timeslice?: number) => self.start(timeslice),
stop: () => self.stop(),
get ondataavailable() {
return self.ondataavailable
},
set ondataavailable(v) {
self.ondataavailable = v
},
get onstop() {
return self.onstop
},
set onstop(v) {
self.onstop = v
},
get onerror() {
return self.onerror
},
set onerror(v) {
self.onerror = v
},
emitChunk: (chunk: Blob) => self.ondataavailable?.({ data: chunk }),
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this
recorderInstances.push(inst)
}
static isTypeSupported(_t: string): boolean {
return true
}
start(_timeslice?: number) {
this.state = 'recording'
}
stop() {
this.state = 'inactive'
this.onstop?.()
}
}
function setupMediaMocks(opts: { allow: boolean } = { allow: true }) {
;(globalThis as unknown as { MediaRecorder: typeof FakeMediaRecorder }).MediaRecorder =
FakeMediaRecorder
Object.defineProperty(globalThis, 'navigator', {
value: {
mediaDevices: {
getUserMedia: vi.fn().mockImplementation(() => {
if (!opts.allow) {
const err = new Error('denied')
err.name = 'NotAllowedError'
return Promise.reject(err)
}
return Promise.resolve(new FakeMediaStream())
}),
},
},
writable: true,
configurable: true,
})
}
// ── Tests ───────────────────────────────────────────────────────────────
describe('useAudioRecorder', () => {
beforeEach(() => {
recorderInstances.length = 0
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("permission denied → status 'error' et permissionDenied=true", async () => {
setupMediaMocks({ allow: false })
const { result } = renderHook(() => useAudioRecorder())
await act(async () => {
await result.current.start()
})
expect(result.current.status).toBe('error')
expect(result.current.permissionDenied).toBe(true)
})
it("start passe en 'recording' et le timer incrémente", async () => {
setupMediaMocks({ allow: true })
const { result } = renderHook(() => useAudioRecorder())
await act(async () => {
await result.current.start()
})
expect(result.current.status).toBe('recording')
expect(result.current.elapsedSeconds).toBe(0)
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000)
})
expect(result.current.elapsedSeconds).toBe(3)
})
it("stop produit un audioBlob et passe en 'stopped'", async () => {
setupMediaMocks({ allow: true })
const { result } = renderHook(() => useAudioRecorder())
await act(async () => {
await result.current.start()
})
const inst = recorderInstances[0]!
act(() => {
inst.emitChunk(new Blob(['chunk1'], { type: 'audio/webm' }))
})
act(() => {
result.current.stop()
})
expect(result.current.status).toBe('stopped')
expect(result.current.audioBlob).toBeInstanceOf(Blob)
})
it('subscribeChunks reçoit les chunks émis pendant lenregistrement', async () => {
setupMediaMocks({ allow: true })
const { result } = renderHook(() => useAudioRecorder())
const received: Blob[] = []
const unsub = result.current.subscribeChunks((c) => received.push(c))
await act(async () => {
await result.current.start()
})
const inst = recorderInstances[0]!
act(() => {
inst.emitChunk(new Blob(['a'], { type: 'audio/webm' }))
inst.emitChunk(new Blob(['b'], { type: 'audio/webm' }))
})
expect(received).toHaveLength(2)
unsub()
})
})

View file

@ -0,0 +1,216 @@
/**
* Tests du hook useDeepgramLive Sprint 4c-1.
*
* jsdom ne fournit pas de WebSocket utilisable : on installe un fake
* minimaliste pilotable depuis les tests. On valide :
* - connect demande un token + ouvre une WS sur le bon endpoint
* - is_final append au transcript ; sinon interim
* - sendChunk envoie via la WS ouverte ; bufferise sinon
* - close envoie CloseStream et passe en status='closed'
* - rotation : un nouveau token est demandé avant expiration
*/
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/entities/transcription/api', () => ({
requestDeepgramToken: vi.fn(),
}))
import { requestDeepgramToken } from '@/entities/transcription/api'
import { useDeepgramLive } from '../useDeepgramLive'
const mockedToken = vi.mocked(requestDeepgramToken)
// ── Fake WebSocket ──────────────────────────────────────────────────────
type WSListener = (e: { data: string }) => void
interface FakeWS {
url: string
readyState: number
send: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
onopen: (() => void) | null
onmessage: WSListener | null
onerror: (() => void) | null
onclose: (() => void) | null
addEventListener: (e: string, cb: () => void) => void
removeEventListener: (e: string, cb: () => void) => void
emitOpen: () => void
emitMessage: (payload: unknown) => void
protocols: string | string[] | undefined
}
const wsInstances: FakeWS[] = []
class FakeWebSocket implements Partial<FakeWS> {
static OPEN = 1
static CLOSED = 3
url: string
protocols: string | string[] | undefined
readyState = 0
send = vi.fn()
close = vi.fn(() => {
this.readyState = FakeWebSocket.CLOSED
})
onopen: (() => void) | null = null
onmessage: WSListener | null = null
onerror: (() => void) | null = null
onclose: (() => void) | null = null
private listeners: Map<string, Array<() => void>> = new Map()
constructor(url: string, protocols?: string | string[]) {
this.url = url
this.protocols = protocols
wsInstances.push(this as unknown as FakeWS)
}
addEventListener(event: string, cb: () => void) {
const arr = this.listeners.get(event) ?? []
arr.push(cb)
this.listeners.set(event, arr)
}
removeEventListener(event: string, cb: () => void) {
const arr = this.listeners.get(event) ?? []
this.listeners.set(
event,
arr.filter((c) => c !== cb),
)
}
emitOpen() {
this.readyState = FakeWebSocket.OPEN
this.onopen?.()
;(this.listeners.get('open') ?? []).forEach((cb) => cb())
}
emitMessage(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) })
}
}
beforeEach(() => {
wsInstances.length = 0
;(globalThis as unknown as { WebSocket: typeof FakeWebSocket }).WebSocket = FakeWebSocket
mockedToken.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// ── Tests ───────────────────────────────────────────────────────────────
describe('useDeepgramLive', () => {
it('connect demande un token et ouvre une WS sur Deepgram', async () => {
mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
const { result } = renderHook(() => useDeepgramLive())
await act(async () => {
await result.current.connect()
})
expect(mockedToken).toHaveBeenCalledTimes(1)
expect(wsInstances).toHaveLength(1)
expect(wsInstances[0]!.url).toContain('wss://api.deepgram.com/v1/listen')
expect(wsInstances[0]!.url).toContain('language=fr')
expect(wsInstances[0]!.url).toContain('model=nova-2')
// Le token n'est PAS dans l'URL — il est passé via Sec-WebSocket-Protocol.
expect(wsInstances[0]!.url).not.toContain('token=')
expect(wsInstances[0]!.protocols).toEqual(['token', 'tok-1'])
})
it('is_final accumule le transcript ; interim non accumulé', async () => {
mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
const { result } = renderHook(() => useDeepgramLive())
await act(async () => {
await result.current.connect()
})
const ws = wsInstances[0]!
act(() => ws.emitOpen())
act(() => {
ws.emitMessage({
channel: { alternatives: [{ transcript: 'Bonjour' }] },
is_final: false,
})
})
expect(result.current.interim).toBe('Bonjour')
expect(result.current.transcript).toBe('')
act(() => {
ws.emitMessage({
channel: { alternatives: [{ transcript: 'Bonjour je m appelle Pierre' }] },
is_final: true,
})
})
expect(result.current.transcript).toBe('Bonjour je m appelle Pierre')
expect(result.current.interim).toBe('')
})
it('sendChunk envoie sur la WS ouverte', async () => {
mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
const { result } = renderHook(() => useDeepgramLive())
await act(async () => {
await result.current.connect()
})
const ws = wsInstances[0]!
act(() => ws.emitOpen())
const blob = new Blob(['chunk'], { type: 'audio/webm' })
act(() => result.current.sendChunk(blob))
expect(ws.send).toHaveBeenCalledWith(blob)
})
it("close envoie CloseStream et passe en status='closed'", async () => {
mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
const { result } = renderHook(() => useDeepgramLive())
await act(async () => {
await result.current.connect()
})
const ws = wsInstances[0]!
act(() => ws.emitOpen())
act(() => result.current.close())
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'CloseStream' }))
expect(ws.close).toHaveBeenCalled()
expect(result.current.status).toBe('closed')
})
it('rotation : un nouveau token est demandé à T-60 s avant expiration', async () => {
mockedToken
.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
.mockResolvedValueOnce({ token: 'tok-2', expires_in: 600 })
const { result } = renderHook(() => useDeepgramLive())
await act(async () => {
await result.current.connect()
})
act(() => wsInstances[0]!.emitOpen())
expect(mockedToken).toHaveBeenCalledTimes(1)
// Avancer juste avant l'échéance (rotation à T-60 s = 540 s).
await act(async () => {
await vi.advanceTimersByTimeAsync(539_000)
})
expect(mockedToken).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(mockedToken).toHaveBeenCalledTimes(2)
expect(wsInstances).toHaveLength(2)
expect(wsInstances[1]!.url).not.toContain('token=')
expect(wsInstances[1]!.protocols).toEqual(['token', 'tok-2'])
})
})

View file

@ -114,9 +114,11 @@ describe('useSimulation — selectTask', () => {
expect(result.current.production).toEqual(mockProduction)
})
it('step passe directement à task-selected pour EO_T1 (sans catalogue)', async () => {
const eoProduction: Production = { ...mockProduction, tache: 'EO_T1' }
mockCreateSimulation.mockResolvedValue(eoProduction)
it('Sprint 4c-2 — selectTask EO_T1 crée la simulation et passe à task-selected (sans catalogue)', async () => {
// L'interception de 4c-1 est levée : EO_T1 dispose désormais d'un flux
// dédié (/simulation/eo/t1/mode). Sans catalogue → step=task-selected.
const eoT1Production: Production = { ...mockProduction, tache: 'EO_T1' }
mockCreateSimulation.mockResolvedValue(eoT1Production)
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
@ -125,7 +127,8 @@ describe('useSimulation — selectTask', () => {
})
await waitFor(() => expect(result.current.step).toBe('task-selected'))
expect(result.current.production).toEqual(eoProduction)
expect(result.current.production).toEqual(eoT1Production)
expect(mockCreateSimulation).toHaveBeenCalledTimes(1)
})
it('isCreating = true pendant la mutation createSimulation', async () => {

View file

@ -0,0 +1,271 @@
/**
* Hook MediaRecorder pour les productions orales Sprint 4c-1.
*
* Capture le micro via getUserMedia + MediaRecorder, expose un timer montant
* et un Blob webm/opus à l'arrêt. Permet aussi de s'abonner aux chunks
* (timeslice 250 ms) pour streamer en parallèle vers Deepgram.
*
* Compat : préfère `audio/webm;codecs=opus`, fallback `audio/webm`, puis
* `audio/mp4` (Safari iOS cf. FTD audio iOS).
*
* Le hook ne stocke pas l'audio côté serveur la sauvegarde locale via
* `downloadAudio` est une commodité utilisateur (cf. Sprint 4b backend).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopped' | 'error'
export interface UseAudioRecorderOptions {
/**
* Sprint 4b.3 durée maximale d'enregistrement en secondes. Quand
* `elapsedSeconds` atteint cette valeur, le hook stoppe automatiquement
* le MediaRecorder et appelle `onMaxReached` une fois.
*/
maxSeconds?: number
onMaxReached?: () => void
}
export interface UseAudioRecorderResult {
status: RecorderStatus
elapsedSeconds: number
audioBlob: Blob | null
audioMimeType: string | null
error: string | null
permissionDenied: boolean
start: () => Promise<void>
stop: () => void
cancel: () => void
downloadAudio: (filename: string) => void
/** S'abonne aux chunks (timeslice). Retourne un unsubscribe. */
subscribeChunks: (cb: (chunk: Blob) => void) => () => void
}
/** Choisit le mimeType supporté par le navigateur, par ordre de préférence. */
function pickMimeType(): string | null {
if (typeof MediaRecorder === 'undefined') return null
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']
for (const m of candidates) {
if (MediaRecorder.isTypeSupported(m)) return m
}
return null
}
const TIMESLICE_MS = 250
export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderResult {
const [status, setStatus] = useState<RecorderStatus>('idle')
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const [audioBlob, setAudioBlob] = useState<Blob | null>(null)
const [audioMimeType, setAudioMimeType] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [permissionDenied, setPermissionDenied] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const subscribersRef = useRef<Set<(chunk: Blob) => void>>(new Set())
// Capture options dans une ref pour éviter de réabonner les effets sur
// chaque render (les callers fournissent souvent des fonctions inline).
const optionsRef = useRef(options)
optionsRef.current = options
const maxReachedFiredRef = useRef(false)
const cleanupTimer = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
}, [])
const start = useCallback(async () => {
if (status === 'recording' || status === 'requesting') return
setError(null)
setPermissionDenied(false)
setAudioBlob(null)
setElapsedSeconds(0)
chunksRef.current = []
setStatus('requesting')
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
setError('Votre navigateur ne supporte pas la capture audio.')
setStatus('error')
return
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
const name = err instanceof Error ? err.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
setPermissionDenied(true)
setError("L'accès au micro est refusé. Autorisez-le dans les réglages du navigateur.")
} else {
setError("Impossible d'accéder au micro. Vérifiez vos périphériques.")
}
setStatus('error')
return
}
streamRef.current = stream
const mimeType = pickMimeType()
if (!mimeType) {
cleanupStream()
setError('Aucun format audio supporté par votre navigateur.')
setStatus('error')
return
}
setAudioMimeType(mimeType)
let recorder: MediaRecorder
try {
recorder = new MediaRecorder(stream, { mimeType })
} catch {
cleanupStream()
setError("Impossible d'initialiser l'enregistreur audio.")
setStatus('error')
return
}
recorderRef.current = recorder
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunksRef.current.push(event.data)
subscribersRef.current.forEach((cb) => cb(event.data))
}
}
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType })
setAudioBlob(blob)
cleanupStream()
cleanupTimer()
setStatus('stopped')
}
recorder.onerror = () => {
cleanupStream()
cleanupTimer()
setError("L'enregistrement a échoué.")
setStatus('error')
}
recorder.start(TIMESLICE_MS)
setStatus('recording')
maxReachedFiredRef.current = false
timerRef.current = setInterval(() => {
setElapsedSeconds((s) => {
const next = s + 1
const max = optionsRef.current.maxSeconds
// Cap visuel à `max` et arrête d'incrémenter au-delà. L'auto-stop
// est déclenché par l'effet observant `elapsedSeconds`.
return max && next >= max ? max : next
})
}, 1000)
}, [status, cleanupStream, cleanupTimer])
const stop = useCallback(() => {
// Arrêter le timer SYNCHRONE — sinon il continue d'incrémenter pendant
// les ~50-200 ms entre l'appel à `recorder.stop()` et la réception du
// callback `onstop` (qui appelle aussi cleanupTimer en sécurité).
cleanupTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.stop()
}
}, [cleanupTimer])
const cancel = useCallback(() => {
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
// Vide les chunks AVANT le stop pour produire un blob nul.
chunksRef.current = []
recorder.stop()
}
cleanupStream()
cleanupTimer()
setStatus('idle')
setElapsedSeconds(0)
setAudioBlob(null)
}, [cleanupStream, cleanupTimer])
const downloadAudio = useCallback(
(filename: string) => {
if (!audioBlob) return
const url = URL.createObjectURL(audioBlob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
[audioBlob],
)
const subscribeChunks = useCallback((cb: (chunk: Blob) => void) => {
subscribersRef.current.add(cb)
return () => {
subscribersRef.current.delete(cb)
}
}, [])
// Sprint 4b.3 — auto-stop à expiration de la durée recommandée.
// Quand le timer atteint `maxSeconds`, on stoppe le MediaRecorder (ce qui
// déclenche `onstop` → audioBlob, status='stopped') et on notifie le caller
// une seule fois via `onMaxReached`. Le composant parent peut câbler son
// onSubmit sur le passage en status='stopped' (cf. AudioRecorder).
useEffect(() => {
if (status !== 'recording') return
const max = optionsRef.current.maxSeconds
if (!max || elapsedSeconds < max) return
if (maxReachedFiredRef.current) return
maxReachedFiredRef.current = true
cleanupTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.stop()
}
optionsRef.current.onMaxReached?.()
}, [elapsedSeconds, status, cleanupTimer])
// Cleanup global au démontage : libère le micro même si l'utilisateur
// navigue ailleurs sans cliquer sur Stop ou Annuler.
useEffect(() => {
return () => {
cleanupTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
try {
recorder.stop()
} catch {
/* noop */
}
}
cleanupStream()
}
}, [cleanupStream, cleanupTimer])
return {
status,
elapsedSeconds,
audioBlob,
audioMimeType,
error,
permissionDenied,
start,
stop,
cancel,
downloadAudio,
subscribeChunks,
}
}

View file

@ -0,0 +1,226 @@
/**
* Hook de transcription live Deepgram Sprint 4c-1.
*
* Demande un token éphémère au backend (`POST /transcriptions/token`),
* ouvre une connexion WebSocket directe vers Deepgram, expose le
* transcript final accumulé + l'interim en cours.
*
* Rotation de token : Deepgram fournit un token valide ~10 min. On
* redemande un nouveau token à T-60 s avant expiration et on bascule
* la WebSocket en hot-swap (open new ferme l'ancienne). Pendant le
* gap (typiquement < 200 ms), des chunks peuvent être perdus
* acceptable au MVP, durci en Sprint 4c-2 (FTD à tracer).
*
* Paramètres Deepgram (cf. consigne Hermann) :
* language=fr, model=nova-2, smart_format=true,
* interim_results=true, punctuate=true.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { requestDeepgramToken } from '@/entities/transcription/api'
const DEEPGRAM_BASE = 'wss://api.deepgram.com/v1/listen'
const DEEPGRAM_QUERY =
'language=fr&model=nova-2&smart_format=true&interim_results=true&punctuate=true'
const ROTATION_LEAD_SECONDS = 60
export type DeepgramStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
export interface UseDeepgramLiveResult {
status: DeepgramStatus
/** Transcript final accumulé (chaque segment is_final=true ajouté). */
transcript: string
/** Buffer interim courant (segment is_final=false en attente). */
interim: string
isConnected: boolean
error: string | null
connect: () => Promise<void>
sendChunk: (chunk: Blob) => void
close: () => void
}
interface DeepgramMessage {
channel?: { alternatives?: { transcript?: string }[] }
is_final?: boolean
type?: string
}
export function useDeepgramLive(): UseDeepgramLiveResult {
const [status, setStatus] = useState<DeepgramStatus>('idle')
const [transcript, setTranscript] = useState('')
const [interim, setInterim] = useState('')
const [error, setError] = useState<string | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const rotationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingChunksRef = useRef<Blob[]>([])
const clearRotationTimer = useCallback(() => {
if (rotationTimerRef.current !== null) {
clearTimeout(rotationTimerRef.current)
rotationTimerRef.current = null
}
}, [])
const wireWs = useCallback((ws: WebSocket) => {
ws.onopen = () => {
setStatus('open')
// Vider le buffer FIFO — chunks accumulés pendant `connecting`.
const pending = pendingChunksRef.current
pendingChunksRef.current = []
for (const chunk of pending) {
if (ws.readyState === WebSocket.OPEN) ws.send(chunk)
}
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as DeepgramMessage
const text = data.channel?.alternatives?.[0]?.transcript ?? ''
if (!text) return
if (data.is_final) {
setTranscript((prev) => (prev ? `${prev} ${text}` : text))
setInterim('')
} else {
setInterim(text)
}
} catch {
/* messages non-JSON ignorés (keep-alive, metadata) */
}
}
ws.onerror = () => {
setError('Erreur de connexion à la transcription.')
setStatus('error')
}
ws.onclose = () => {
// Ne pas écraser un état 'error' déjà posé.
setStatus((s) => (s === 'error' ? s : 'closed'))
}
}, [])
const openConnection = useCallback(async (): Promise<WebSocket> => {
const { token, expires_in } = await requestDeepgramToken()
// Le navigateur ne permet pas de header custom à l'init d'une WebSocket :
// Deepgram accepte le JWT via Sec-WebSocket-Protocol en passant
// ['token', '<jwt>'] comme sous-protocoles. Ne PAS mettre le token dans
// l'URL — l'auth via query string est rejetée pour les tokens éphémères
// (cf. doc Deepgram « WebSocket authentication »).
const url = `${DEEPGRAM_BASE}?${DEEPGRAM_QUERY}`
const ws = new WebSocket(url, ['token', token])
wireWs(ws)
// Programmer la rotation de token avant expiration.
const leadMs = Math.max((expires_in - ROTATION_LEAD_SECONDS) * 1000, 5_000)
clearRotationTimer()
rotationTimerRef.current = setTimeout(() => {
void rotateToken()
}, leadMs)
return ws
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wireWs, clearRotationTimer])
// Hot-swap : ouvre une nouvelle WS avec un nouveau token, attend 'open',
// puis ferme l'ancienne. Si l'ouverture échoue, on garde l'ancienne.
const rotateToken = useCallback(async () => {
const oldWs = wsRef.current
try {
const newWs = await openConnection()
const swap = () => {
wsRef.current = newWs
if (oldWs && oldWs.readyState === WebSocket.OPEN) {
try {
oldWs.send(JSON.stringify({ type: 'CloseStream' }))
} catch {
/* noop */
}
oldWs.close()
}
}
if (newWs.readyState === WebSocket.OPEN) {
swap()
} else {
const onOpen = () => {
newWs.removeEventListener('open', onOpen)
swap()
}
newWs.addEventListener('open', onOpen)
}
} catch {
// Conserver l'ancienne connexion ; nouvelle tentative à la prochaine échéance.
// FTD-XX : durcir avec une retry policy plus fine en Sprint 4c-2.
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openConnection])
const connect = useCallback(async () => {
if (status === 'connecting' || status === 'open') return
setError(null)
setTranscript('')
setInterim('')
pendingChunksRef.current = []
setStatus('connecting')
try {
const ws = await openConnection()
wsRef.current = ws
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur d'initialisation."
setError(message)
setStatus('error')
}
}, [status, openConnection])
const sendChunk = useCallback((chunk: Blob) => {
const ws = wsRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(chunk)
return
}
// FIFO borné — évite la fuite mémoire si la WS reste closed.
const buf = pendingChunksRef.current
buf.push(chunk)
if (buf.length > 5) buf.shift()
}, [])
const close = useCallback(() => {
clearRotationTimer()
const ws = wsRef.current
if (ws) {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'CloseStream' }))
}
ws.close()
} catch {
/* noop */
}
wsRef.current = null
}
setStatus('closed')
}, [clearRotationTimer])
// Cleanup global — coupe la WS et annule la rotation au démontage.
useEffect(() => {
return () => {
clearRotationTimer()
const ws = wsRef.current
if (ws) {
try {
ws.close()
} catch {
/* noop */
}
}
}
}, [clearRotationTimer])
return {
status,
transcript,
interim,
isConnected: status === 'open',
error,
connect,
sendChunk,
close,
}
}

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { countWords, formatTimer, getSimulationConfig, isEOTache } from '../simulationConfig'
describe('simulationConfig — Sprint 4c-1', () => {
it('EO_T1 : durée recommandée 120 s, min 30 s', () => {
const c = getSimulationConfig('EO_T1')
expect(c.dureeRecommandeeSecondes).toBe(120)
expect(c.enregistrementMinSecondes).toBe(30)
})
it('EO_T3 : durée recommandée 270 s (4 min 30), min 30 s', () => {
const c = getSimulationConfig('EO_T3')
expect(c.dureeRecommandeeSecondes).toBe(270)
expect(c.enregistrementMinSecondes).toBe(30)
})
it('EE_T1 : aucune durée recommandée (champ EO uniquement)', () => {
const c = getSimulationConfig('EE_T1')
expect(c.dureeRecommandeeSecondes).toBeUndefined()
expect(c.enregistrementMinSecondes).toBeUndefined()
})
it('isEOTache distingue EO de EE', () => {
expect(isEOTache('EO_T1')).toBe(true)
expect(isEOTache('EO_T3')).toBe(true)
expect(isEOTache('EE_T1')).toBe(false)
expect(isEOTache('EE_T2')).toBe(false)
expect(isEOTache('EE_T3')).toBe(false)
})
it('formatTimer pad correctement MM:SS', () => {
expect(formatTimer(0)).toBe('00:00')
expect(formatTimer(59)).toBe('00:59')
expect(formatTimer(60)).toBe('01:00')
expect(formatTimer(270)).toBe('04:30')
})
it('countWords sur transcript oral (espaces multiples ignorés)', () => {
expect(countWords('')).toBe(0)
expect(countWords(' ')).toBe(0)
expect(countWords('un mot')).toBe(2)
expect(countWords("Bonjour je m'appelle Pierre")).toBe(4)
})
})

View file

@ -11,22 +11,53 @@
import type { Tache } from '@/entities/production/types'
export interface SimulationConfig {
/** Durée du minuteur en minutes. */
/** Durée du minuteur EE en minutes. Pour EO, durée informative non bloquante. */
dureeMinutes: number
/** Seuil minimum de mots pour autoriser la soumission. */
/** Seuil minimum de mots EE. Non utilisé pour EO. */
motsMin: number
/** Borne basse de la cible TCF. */
/** Borne basse de la cible TCF (mots). EE uniquement. */
motsCibleMin: number
/** Borne haute de la cible TCF. */
/** Borne haute de la cible TCF (mots). EE uniquement. */
motsCibleMax: number
/**
* EO uniquement durée recommandée d'enregistrement, en secondes.
* Affichée comme repère pédagogique, sans coupure automatique.
*/
dureeRecommandeeSecondes?: number
/**
* EO uniquement durée minimale d'enregistrement avant que la soumission
* soit autorisée (sécurité contre les soumissions vides).
*/
enregistrementMinSecondes?: number
}
const EO_MIN_RECORDING_SECONDS = 30
const SIMULATION_CONFIG: Record<Tache, SimulationConfig> = {
EE_T1: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 120 },
EE_T2: { dureeMinutes: 20, motsMin: 30, motsCibleMin: 120, motsCibleMax: 150 },
EE_T3: { dureeMinutes: 30, motsMin: 30, motsCibleMin: 120, motsCibleMax: 180 },
EO_T1: { dureeMinutes: 5, motsMin: 30, motsCibleMin: 30, motsCibleMax: 80 },
EO_T3: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 150 },
EO_T1: {
dureeMinutes: 2,
motsMin: 0,
motsCibleMin: 0,
motsCibleMax: 0,
dureeRecommandeeSecondes: 120,
enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
},
EO_T3: {
dureeMinutes: 5,
motsMin: 0,
motsCibleMin: 0,
motsCibleMax: 0,
dureeRecommandeeSecondes: 270,
enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
},
}
/** True si la tâche est une production orale (EO_T1 ou EO_T3). */
export function isEOTache(tache: Tache): boolean {
return tache.startsWith('EO_')
}
export function getSimulationConfig(tache: Tache): SimulationConfig {

View file

@ -0,0 +1,171 @@
/**
* Page /simulation/eo/enregistrement Sprint 4c-1, refondue Sprint 4c-3.
*
* Capture audio via `<AudioRecorder>` (basé sur `useAudioRecorder`). À l'arrêt :
* 1. Conversion du Blob en base64 via `blobToBase64`.
* 2. Appel `submitEoAudio(base64, mimeType)` du provider.
* 3. Le backend transcrit via Gemini batch puis corrige via DeepSeek.
*
* Aucun audio n'est stocké côté serveur `<AudioRecorder>` propose un
* téléchargement local après stop pour que l'utilisateur conserve son
* enregistrement s'il le souhaite.
*/
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, Timer } from 'lucide-react'
import { formatTache } from '@/entities/production/lib'
import { Badge } from '@/shared/ui/Badge'
import { blobToBase64 } from '@/shared/lib/audio'
import { useSimulationFlow } from '../state/simulationFlow'
import { AudioRecorder } from '../components/AudioRecorder'
import { SujetDisplay } from '../components/SujetDisplay'
import { getSimulationConfig, formatTimer } from '../lib/simulationConfig'
export function EnregistrementEOPage() {
const navigate = useNavigate()
const {
step,
production,
sujet,
presentationT1,
isCorrecting,
correctError,
submitEoAudio,
reset,
} = useSimulationFlow()
// Sprint 4c-3 — `submitting` couvre la fenêtre entre le clic « Arrêter » et
// le démarrage effectif de la mutation : conversion base64 du Blob (peut
// prendre quelques centaines de ms sur gros enregistrements) + petit décalage
// avant que `isCorrecting` ne passe à true.
const [submitting, setSubmitting] = useState(false)
const [encodingError, setEncodingError] = useState<string | null>(null)
// Garde-fous : refresh direct sans état → retour TaskSelector EO.
// NOTE : on n'inclut PAS `step === 'done'` ici. Quand correctEoMutation.onSuccess
// passe step à 'done' et navigate vers /rapport/:id, ce useEffect tirerait
// une seconde navigation (replace) qui écraserait la première — résultat :
// l'utilisateur reste sur /simulation/eo au lieu de voir son rapport.
const shouldRedirect = !production || step === 'idle'
useEffect(() => {
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
}, [shouldRedirect, navigate])
const handleSubmit = useCallback(
async (audioBlob: Blob, audioMimeType: string | null) => {
setEncodingError(null)
setSubmitting(true)
try {
const base64 = await blobToBase64(audioBlob)
// Normalisation du MIME : MediaRecorder produit souvent
// `audio/webm;codecs=opus`. Le backend compare par égalité stricte
// contre `audio/webm` / `audio/mp4` / `audio/wav` — on strip le
// suffixe `;codecs=...` ici. Fallback `audio/webm` si vide.
const rawMime = audioMimeType ?? 'audio/webm'
const normalizedMime = rawMime.split(';')[0]!.trim() || 'audio/webm'
submitEoAudio(base64, normalizedMime)
} catch (err) {
const message = err instanceof Error ? err.message : 'Encodage audio impossible.'
setEncodingError(message)
setSubmitting(false)
}
},
[submitEoAudio],
)
const handleCancel = useCallback(() => {
reset()
navigate('/simulation/eo')
}, [reset, navigate])
if (shouldRedirect || !production) return null
const config = getSimulationConfig(production.tache)
const dureeRecommandee = config.dureeRecommandeeSecondes ?? 0
const minSeconds = config.enregistrementMinSecondes ?? 30
// Le composant AudioRecorder reste visible (pour le bouton « Télécharger
// l'audio ») mais ses contrôles d'arrêt/annulation sont désactivés pendant
// la soumission backend.
const lockControls = submitting || isCorrecting
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
{/* T1 affiche la présentation générée comme texte de référence à lire.
T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */}
{production.tache === 'EO_T1' && presentationT1 && (
<section
className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
aria-label="Texte de présentation de référence"
>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Ta présentation (référence)
</p>
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
{presentationT1}
</div>
</section>
)}
{production.tache !== 'EO_T1' && sujet && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div>
)}
<AudioRecorder
minSeconds={minSeconds}
maxSeconds={dureeRecommandee || undefined}
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
onSubmit={handleSubmit}
onCancel={handleCancel}
autoStart
disabled={lockControls}
/>
{lockControls && (
<div
role="status"
aria-live="polite"
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
>
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
<div>
<p className="font-medium">Transcription et correction en cours</p>
<p className="mt-0.5 text-xs">
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
automatiquement.
</p>
</div>
</div>
)}
{encodingError && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{encodingError}
</div>
)}
{correctError && !lockControls && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
La correction a échoué. Réessayez dans quelques instants.
</div>
)}
</main>
)
}

View file

@ -0,0 +1,78 @@
/**
* Page /simulation/eo/t1/mode Sprint 4c-2.
*
* Choix du mode d'entraînement pour la Tâche 1 EO :
* a) Générer ma présentation /simulation/eo/t1/questionnaire
* b) Enregistrer directement /simulation/eo/pre-enregistrement
*
* Garde-fou : si la simulation courante n'est pas EO_T1 retour TaskSelector EO.
* Aucune logique de plan ici (déjà vérifiée à la création de la simulation).
*/
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Sparkles } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import { useSimulationFlow } from '../state/simulationFlow'
export function ModeChoixT1Page() {
const navigate = useNavigate()
const { production, step, reset } = useSimulationFlow()
const shouldRedirect =
!production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done'
useEffect(() => {
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
}, [shouldRedirect, navigate])
if (shouldRedirect) return null
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => {
reset()
navigate('/simulation/eo')
}}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
</div>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Présentation personnelle</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card
variant="interactive"
className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/t1/questionnaire')}
>
<Sparkles className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
<p className="text-sm leading-relaxed text-ink-secondary">
Réponds à 5 questions Expria génère ton texte personnalisé que tu lis avant
d'enregistrer.
</p>
</Card>
<Card
variant="interactive"
className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/pre-enregistrement')}
>
<Mic className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
<p className="text-sm leading-relaxed text-ink-secondary">
Tu as déjà préparé ta présentation enregistre-toi directement sans passer par le
formulaire.
</p>
</Card>
</div>
</main>
)
}

View file

@ -0,0 +1,105 @@
/**
* Page /simulation/eo/pre-enregistrement Sprint 4c-1.
*
* Affiche le sujet sélectionné, la durée recommandée pour la tâche, des
* instructions courtes, puis un bouton primaire qui démarre l'enregistrement.
* Fait le lien entre `SujetsEOPage` (choix d'un sujet T3) et
* `EnregistrementEOPage` (capture audio + transcription).
*
* Aucune logique de quota / plan ici : déjà vérifiée à la création.
*/
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Timer } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { formatTache } from '@/entities/production/lib'
import { useSimulationFlow } from '../state/simulationFlow'
import { SujetDisplay } from '../components/SujetDisplay'
import { getSimulationConfig, formatTimer } from '../lib/simulationConfig'
export function PreEnregistrementEOPage() {
const navigate = useNavigate()
const { step, production, sujet, setStep } = useSimulationFlow()
// Garde-fous : refresh direct sans état → retour au TaskSelector EO.
const shouldRedirect = !production || step === 'idle' || step === 'done'
useEffect(() => {
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
}, [shouldRedirect, navigate])
if (shouldRedirect || !production) return null
const config = getSimulationConfig(production.tache)
const dureeRecommandee = config.dureeRecommandeeSecondes ?? 0
const isT1 = production.tache === 'EO_T1'
const isT3 = production.tache === 'EO_T3'
// Sprint 4c-2 — T1 : pas de sujet pré-défini (présentation personnelle).
// Le titre, l'encart d'instructions et l'absence du bouton « Changer de
// sujet » diffèrent de T3.
const heading = isT1 ? 'Tâche 1 — Présentation personnelle' : formatTache(production.tache)
function handleStart() {
setStep('recording')
navigate('/simulation/eo/enregistrement')
}
function handleChangeSujet() {
navigate('/simulation/eo/sujets')
}
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
{sujet && !isT1 && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div>
)}
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
<p className="font-medium text-ink-primary">Avant de commencer</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.</li>
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
{isT1 && (
<li>
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
projet d'immigration au Canada.
</li>
)}
<li>
Vous pourrez télécharger votre enregistrement à la fin il n'est pas conservé sur nos
serveurs.
</li>
</ul>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStart}
>
Démarrer l'enregistrement
</Button>
{isT3 && (
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
Changer de sujet
</Button>
)}
</div>
</main>
)
}

View file

@ -0,0 +1,198 @@
/**
* Page /simulation/eo/t1/presentation Sprint 4c-2.
*
* Affiche la présentation générée par DeepSeek et permet :
* - Lecture (mode readonly par défaut)
* - Édition manuelle (toggle « Modifier » / « Enregistrer les modifications »)
* - Copier dans le presse-papier
* - Télécharger en .txt
* - Refaire (efface localStorage + retour questionnaire)
*
* Source du texte au mount, par ordre :
* 1. `presentationT1` du provider (vient de finir le questionnaire)
* 2. localStorage (refresh direct ou retour différé)
* 3. Aucune redirection /simulation/eo/t1/mode
*
* Les modifications manuelles sont persistées localStorage + provider.
*/
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, Copy, Download, Mic, Pencil, RotateCcw, Save } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { useSimulationFlow } from '../state/simulationFlow'
export function PresentationGenereeT1Page() {
const navigate = useNavigate()
const { production, step, presentationT1, setPresentationT1, setStep } = useSimulationFlow()
// Garde-fou tâche EO_T1 + présence d'une présentation. Si absente, redirection
// vers /t1/mode pour relancer un questionnaire.
const shouldRedirect =
!production ||
production.tache !== 'EO_T1' ||
step === 'idle' ||
step === 'done' ||
presentationT1 === null
useEffect(() => {
if (!production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done') {
navigate('/simulation/eo', { replace: true })
return
}
if (presentationT1 === null) {
navigate('/simulation/eo/t1/mode', { replace: true })
}
}, [production, step, presentationT1, navigate])
const [text, setText] = useState<string>(presentationT1 ?? '')
const [isEditing, setIsEditing] = useState(false)
const [copied, setCopied] = useState(false)
// Resync si le provider change (ex : retour depuis « Refaire » → null → re-générer).
useEffect(() => {
if (presentationT1 !== null && !isEditing) {
setText(presentationT1)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presentationT1])
const downloadFilename = useMemo(
() => `expria-presentation-t1-${(production?.id ?? 'session').slice(0, 8)}.txt`,
[production?.id],
)
if (shouldRedirect) return null
function handleCopy() {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
void navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
function handleDownload() {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = downloadFilename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function handleToggleEdit() {
if (isEditing) {
// Sauvegarder les modifications.
setPresentationT1(text)
}
setIsEditing((v) => !v)
}
function handleRefaire() {
setPresentationT1(null)
navigate('/simulation/eo/t1/questionnaire')
}
function handleStartRecording() {
// S'assurer que le provider porte bien la dernière version du texte
// (au cas où l'utilisateur a édité sans cliquer sur Enregistrer).
if (text !== presentationT1) {
setPresentationT1(text)
}
setStep('recording')
navigate('/simulation/eo/enregistrement')
}
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
<p className="mt-1 text-sm text-ink-secondary">
Lis-la, modifie-la si nécessaire, puis enregistre-toi.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
icon={
copied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)
}
onClick={handleCopy}
>
{copied ? 'Copié' : 'Copier'}
</Button>
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
>
.txt
</Button>
<Button
variant="secondary"
size="sm"
icon={
isEditing ? (
<Save className="size-4" aria-hidden="true" />
) : (
<Pencil className="size-4" aria-hidden="true" />
)
}
onClick={handleToggleEdit}
>
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
</Button>
</div>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={!isEditing}
rows={12}
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
/>
<div
role="note"
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
>
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois avant
d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
</div>
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
<span>Présentation sauvegardée retrouvée automatiquement à ta prochaine visite.</span>
<button
type="button"
onClick={handleRefaire}
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
>
<RotateCcw className="size-3.5" aria-hidden="true" />
Refaire
</button>
</div>
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStartRecording}
className="mt-6 w-full"
>
Je suis prêt Enregistrer
</Button>
</main>
)
}

View file

@ -0,0 +1,243 @@
/**
* Page /simulation/eo/t1/questionnaire Sprint 4c-2.
*
* Formulaire des 5 réponses utilisées pour générer la présentation T1
* via DeepSeek (POST /presentations/generate). State 100 % local (pas de
* provider) : les réponses ne survivent pas au refresh, c'est volontaire
* c'est la *présentation générée* qui est persistée (cf. provider).
*
* SEC-04 : validation Zod côté client (chaque champ trim non vide, max 500 chars).
* Garde-fou : tâche EO_T1 obligatoire, sinon retour TaskSelector EO.
*/
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { Sparkles } from 'lucide-react'
import { z } from 'zod'
import { Button } from '@/shared/ui/Button'
import type { ApiError } from '@/shared/types/api'
import { generatePresentation } from '@/entities/presentation/api'
import type { PresentationReponses } from '@/entities/presentation/types'
import { useSimulationFlow } from '../state/simulationFlow'
const FIELD_MAX = 500
const reponsesSchema = z.object({
prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
})
type FieldKey = keyof PresentationReponses
interface FieldDef {
key: FieldKey
label: string
placeholder: string
multiline?: boolean
}
const FIELDS: FieldDef[] = [
{
key: 'prenom_age_ville',
label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
},
{
key: 'formation_metier',
label: 'Quelle est ta formation et ton métier actuel ou passé ?',
placeholder: 'Ex : Master en gestion, comptable dans une PME',
},
{
key: 'situation_familiale',
label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
},
{
key: 'loisirs',
label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
placeholder: 'Ex : Lecture, cuisine, randonnée',
multiline: true,
},
{
key: 'motivation_canada',
label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
multiline: true,
},
]
const EMPTY_REPONSES: PresentationReponses = {
prenom_age_ville: '',
formation_metier: '',
situation_familiale: '',
loisirs: '',
motivation_canada: '',
}
const inputBase =
'w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus'
function mapApiError(err: ApiError | null): string | null {
if (!err) return null
switch (err.code) {
case 'AUTH_REQUIRED':
return 'Votre session a expiré. Reconnectez-vous.'
case 'VALIDATION_ERROR':
case 'INVALID_BODY':
return 'Les réponses saisies sont invalides. Vérifiez chaque champ.'
default:
return 'La génération a échoué. Réessayez dans quelques instants.'
}
}
export function QuestionnaireT1Page() {
const navigate = useNavigate()
const { production, step, setPresentationT1 } = useSimulationFlow()
const shouldRedirect =
!production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done'
useEffect(() => {
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
}, [shouldRedirect, navigate])
const [reponses, setReponses] = useState<PresentationReponses>(EMPTY_REPONSES)
const [touched, setTouched] = useState<Record<FieldKey, boolean>>({
prenom_age_ville: false,
formation_metier: false,
situation_familiale: false,
loisirs: false,
motivation_canada: false,
})
const mutation = useMutation({
mutationFn: generatePresentation,
onSuccess: (data) => {
setPresentationT1(data.presentation)
navigate('/simulation/eo/t1/presentation')
},
})
const parsed = reponsesSchema.safeParse(reponses)
const fieldErrors = !parsed.success
? parsed.error.issues.reduce<Partial<Record<FieldKey, string>>>((acc, issue) => {
const key = issue.path[0] as FieldKey | undefined
if (key && !acc[key]) acc[key] = issue.message
return acc
}, {})
: {}
const formValid = parsed.success
const apiErrorMessage = mapApiError(mutation.error as ApiError | null)
function handleChange(key: FieldKey, value: string) {
setReponses((r) => ({ ...r, [key]: value.slice(0, FIELD_MAX) }))
}
function handleBlur(key: FieldKey) {
setTouched((t) => ({ ...t, [key]: true }))
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setTouched({
prenom_age_ville: true,
formation_metier: true,
situation_familiale: true,
loisirs: true,
motivation_canada: true,
})
if (!parsed.success) return
mutation.mutate(parsed.data)
}
if (shouldRedirect) return null
return (
<main className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/simulation/eo/t1/mode')}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
disabled={mutation.isPending}
>
Retour
</button>
</div>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Questionnaire</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{FIELDS.map((field) => {
const value = reponses[field.key]
const showError = touched[field.key] && fieldErrors[field.key]
const id = `q-${field.key}`
return (
<div key={field.key} className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
{field.label}
</label>
{field.multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
rows={2}
className={inputBase}
/>
) : (
<input
id={id}
type="text"
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
className={inputBase}
/>
)}
{showError && (
<p className="text-xs text-danger" role="alert">
{fieldErrors[field.key]}
</p>
)}
</div>
)
})}
{apiErrorMessage && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{apiErrorMessage}
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
icon={<Sparkles className="size-4" aria-hidden="true" />}
loading={mutation.isPending}
disabled={!formValid || mutation.isPending}
>
Générer ma présentation
</Button>
</form>
</main>
)
}

View file

@ -0,0 +1,77 @@
/**
* Page de simulation Expression Orale Sprint 4c-1.
*
* Affiche le TaskSelector type='EO' (T1 / T3 / T2 Live verrouillé) +
* un bandeau d'info quand l'utilisateur clique sur une tâche temporairement
* indisponible (EO_T1 livré en 4c-2).
*
* Règle D : quotas et permissions passent par canSimulate / hasAccess.
* Règle H : aucune logique métier délègue au provider.
*/
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { Button } from '@/shared/ui/Button'
import { TaskSelector } from '../components/TaskSelector'
import { useSimulationFlow } from '../state/simulationFlow'
function SimulationEOSkeleton() {
return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement…">
<div className="h-6 w-40 animate-pulse rounded bg-surface" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-surface" />
))}
</div>
</div>
)
}
export function SimulationEOPage() {
const {
data: planData,
isLoading: isPlanLoading,
isError: isPlanError,
refetch: refetchPlan,
} = usePlan()
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
return (
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationEOSkeleton />}
{isPlanError && (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{planData && (
<div className="space-y-4">
<TaskSelector
type="EO"
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
{taskUnavailableMessage && (
<div
role="status"
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
>
{taskUnavailableMessage}
</div>
)}
</div>
)}
</main>
)
}

View file

@ -0,0 +1,126 @@
/**
* Page /simulation/eo/sujets sélection d'un sujet EO_T3 en cartes.
*
* Clone fonctionnel de SujetsPage (EE) : même grille, même bouton aléatoire,
* mêmes redirections de garde adapté pour rester dans le flow EO et
* naviguer vers /simulation/eo/pre-enregistrement après sélection.
*/
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Shuffle } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { formatTache } from '@/entities/production/lib'
import type { SujetData } from '@/entities/production/types'
import { useSimulationFlow } from '../state/simulationFlow'
import { useSujets } from '../hooks/useSujets'
import { SujetCard } from '../components/SujetCard'
function SujetsSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-lg bg-surface" />
))}
</div>
)
}
export function SujetsEOPage() {
const navigate = useNavigate()
const { step, production, changeSubject, setStep, reset } = useSimulationFlow()
// Garde-fous identiques à SujetsPage : refresh direct ou état incohérent
// → retour au TaskSelector EO. step='done' = simulation déjà corrigée,
// /sujets ne doit pas patcher (cf. FTD-23).
const shouldRedirect = !production || step === 'idle' || step === 'done'
useEffect(() => {
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
}, [shouldRedirect, navigate])
const {
data: sujets,
isLoading,
isError,
refetch,
} = useSujets(production?.tache ?? 'EO_T3', !!production && !shouldRedirect)
if (shouldRedirect || !production) return null
function handleSelect(sujet: SujetData) {
changeSubject(sujet)
setStep('task-selected')
navigate('/simulation/eo/pre-enregistrement')
}
function handleRandom() {
if (!sujets || sujets.length === 0) return
const pool = production?.sujet ? sujets.filter((s) => s.id !== production.sujet?.id) : sujets
const list = pool.length > 0 ? pool : sujets
const pick = list[Math.floor(Math.random() * list.length)]
if (pick) handleSelect(pick)
}
const hasSujets = (sujets?.length ?? 0) > 0
return (
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="mb-4 flex items-center gap-3">
<button
type="button"
onClick={() => {
reset()
navigate('/simulation/eo')
}}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet {formatTache(production.tache)}
</h2>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-ink-secondary">
{isLoading
? 'Chargement des sujets…'
: hasSujets
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
: 'Aucun sujet disponible pour cette tâche.'}
</p>
<Button
variant="secondary"
size="sm"
icon={<Shuffle className="size-4" aria-hidden="true" />}
onClick={handleRandom}
disabled={!hasSujets}
>
Sujet aléatoire
</Button>
</div>
{isError && (
<div
role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
Impossible de charger les sujets.{' '}
<button type="button" onClick={() => refetch()} className="underline underline-offset-2">
Réessayer
</button>
</div>
)}
{isLoading ? (
<SujetsSkeleton />
) : hasSujets ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sujets!.map((sujet) => (
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
))}
</div>
) : null}
</main>
)
}

View file

@ -0,0 +1,123 @@
/**
* Tests QuestionnaireT1Page (Sprint 4c-2).
*
* Couvre :
* - bouton désactivé tant que des champs sont vides, actif quand tous remplis
* - submit appelle generatePresentation avec le bon payload
* - onSuccess : setPresentationT1 + navigate vers /simulation/eo/t1/presentation
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
const { navigateMock, generatePresentationMock, setPresentationT1Mock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
generatePresentationMock: vi.fn(),
setPresentationT1Mock: vi.fn(),
}))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return { ...actual, useNavigate: () => navigateMock }
})
vi.mock('@/entities/presentation/api', () => ({
generatePresentation: generatePresentationMock,
}))
vi.mock('../../state/simulationFlow', () => ({
useSimulationFlow: () => ({
production: {
id: 'sim-eo-1',
tache: 'EO_T1',
mode: 'entrainement',
created_at: '2026-04-25',
sujet: null,
},
step: 'task-selected',
setPresentationT1: setPresentationT1Mock,
}),
}))
import { QuestionnaireT1Page } from '../QuestionnaireT1Page'
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<QuestionnaireT1Page />
</QueryClientProvider>
</MemoryRouter>,
)
}
const FIELDS = [
{ label: /prénom, âge/i, value: 'Marie, 32 ans, Douala' },
{ label: /formation et ton métier/i, value: 'Master gestion, comptable' },
{ label: /situation familiale/i, value: 'Mariée, 2 enfants' },
{ label: /loisirs ou passions/i, value: 'Lecture, cuisine' },
{ label: /immigrer au Canada/i, value: 'Opportunités, départ 2025' },
] as const
beforeEach(() => {
cleanup()
navigateMock.mockReset()
generatePresentationMock.mockReset()
setPresentationT1Mock.mockReset()
})
describe('QuestionnaireT1Page', () => {
it('bouton désactivé tant que les champs sont vides', () => {
renderPage()
const submit = screen.getByRole('button', { name: /Générer ma présentation/i })
expect(submit).toBeDisabled()
})
it('bouton actif quand les 5 champs sont remplis', async () => {
renderPage()
const user = userEvent.setup()
for (const field of FIELDS) {
await user.type(screen.getByLabelText(field.label), field.value)
}
const submit = screen.getByRole('button', { name: /Générer ma présentation/i })
expect(submit).not.toBeDisabled()
})
it('submit appelle generatePresentation, setPresentationT1, puis navigate', async () => {
generatePresentationMock.mockResolvedValueOnce({
presentation: 'Bonjour, je m appelle Marie. Voilà.',
})
renderPage()
const user = userEvent.setup()
for (const field of FIELDS) {
await user.type(screen.getByLabelText(field.label), field.value)
}
await user.click(screen.getByRole('button', { name: /Générer ma présentation/i }))
await waitFor(() => {
expect(generatePresentationMock).toHaveBeenCalledTimes(1)
})
expect(generatePresentationMock.mock.calls[0]?.[0]).toEqual({
prenom_age_ville: 'Marie, 32 ans, Douala',
formation_metier: 'Master gestion, comptable',
situation_familiale: 'Mariée, 2 enfants',
loisirs: 'Lecture, cuisine',
motivation_canada: 'Opportunités, départ 2025',
})
await waitFor(() => {
expect(setPresentationT1Mock).toHaveBeenCalledWith('Bonjour, je m appelle Marie. Voilà.')
})
expect(navigateMock).toHaveBeenCalledWith('/simulation/eo/t1/presentation')
})
})

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

View file

@ -0,0 +1,54 @@
/**
* Tests de `blobToBase64` Sprint 4c-3.
*
* jsdom fournit FileReader. On vérifie :
* - encodage correct (base64 sans préfixe data URI)
* - rejet propre si le reader émet onerror
*/
import { describe, it, expect, vi } from 'vitest'
import { blobToBase64 } from '../audio'
describe('blobToBase64', () => {
it('encode un Blob en base64 sans le préfixe data URI', async () => {
const blob = new Blob(['hello'], { type: 'audio/webm' })
const base64 = await blobToBase64(blob)
// 'hello' en base64 = 'aGVsbG8='
expect(base64).toBe('aGVsbG8=')
})
it('reject si FileReader émet une erreur', async () => {
class FailingFileReader {
onerror: (() => void) | null = null
onload: (() => void) | null = null
result: unknown = null
readAsDataURL() {
// Simule une erreur asynchrone.
setTimeout(() => this.onerror?.(), 0)
}
}
vi.stubGlobal('FileReader', FailingFileReader)
await expect(blobToBase64(new Blob(['x']))).rejects.toThrow(
/FileReader: lecture du Blob audio impossible/,
)
vi.unstubAllGlobals()
})
it("reject si le résultat n'est pas une data URI bien formée", async () => {
class WeirdFileReader {
onerror: (() => void) | null = null
onload: (() => void) | null = null
result: string = 'pas-une-data-uri'
readAsDataURL() {
setTimeout(() => this.onload?.(), 0)
}
}
vi.stubGlobal('FileReader', WeirdFileReader)
await expect(blobToBase64(new Blob(['x']))).rejects.toThrow(/format data URI/)
vi.unstubAllGlobals()
})
})

36
src/shared/lib/audio.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* Helpers audio partagés Sprint 4c-3.
*/
/**
* Convertit un Blob en chaîne base64 (sans le préfixe `data:<mime>;base64,`).
*
* Utilise FileReader.readAsDataURL puis strip le préfixe avant retour. Le
* payload audio EO est ensuite envoyé tel quel dans le body JSON de
* `POST /corrections/eo` (cf. SimulationFlowProvider.submitEoAudio).
*
* Reject si le reader émet une erreur ou si le résultat n'est pas une chaîne
* data URI bien formée.
*/
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => {
reject(new Error('FileReader: lecture du Blob audio impossible.'))
}
reader.onload = () => {
const result = reader.result
if (typeof result !== 'string') {
reject(new Error('FileReader: résultat inattendu (non-string).'))
return
}
const commaIdx = result.indexOf(',')
if (commaIdx < 0 || !result.startsWith('data:')) {
reject(new Error('FileReader: résultat non conforme au format data URI.'))
return
}
resolve(result.slice(commaIdx + 1))
}
reader.readAsDataURL(blob)
})
}