feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription
- Gemini batch transcription (no Deepgram live)
- blobToBase64 helper (shared/lib/audio.ts)
- AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit
- Timer stops at maxSeconds and triggers auto-submission
- EnregistrementEOPage: audioBase64 to backend, fix race condition step=done
- SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible)
- MIME normalization (strip codec params)
- Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s)
- PresentationGenereeT1Page: localStorage persistence
Typecheck: OK · Tests: 159/159 ✅
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
71c1ad3018
commit
d1c8b548bb
34 changed files with 3255 additions and 70 deletions
187
src/features/simulations/components/AudioRecorder.tsx
Normal file
187
src/features/simulations/components/AudioRecorder.tsx
Normal 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 été 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>
|
||||
)
|
||||
}
|
||||
60
src/features/simulations/components/TranscriptionDisplay.tsx
Normal file
60
src/features/simulations/components/TranscriptionDisplay.tsx
Normal 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
|
||||
* été 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 l’enregistrement', 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()
|
||||
})
|
||||
})
|
||||
216
src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
Normal file
216
src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
271
src/features/simulations/hooks/useAudioRecorder.ts
Normal file
271
src/features/simulations/hooks/useAudioRecorder.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
226
src/features/simulations/hooks/useDeepgramLive.ts
Normal file
226
src/features/simulations/hooks/useDeepgramLive.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
171
src/features/simulations/pages/EnregistrementEOPage.tsx
Normal file
171
src/features/simulations/pages/EnregistrementEOPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/features/simulations/pages/ModeChoixT1Page.tsx
Normal file
78
src/features/simulations/pages/ModeChoixT1Page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
src/features/simulations/pages/PreEnregistrementEOPage.tsx
Normal file
105
src/features/simulations/pages/PreEnregistrementEOPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
src/features/simulations/pages/PresentationGenereeT1Page.tsx
Normal file
198
src/features/simulations/pages/PresentationGenereeT1Page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
src/features/simulations/pages/QuestionnaireT1Page.tsx
Normal file
243
src/features/simulations/pages/QuestionnaireT1Page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/features/simulations/pages/SimulationEOPage.tsx
Normal file
77
src/features/simulations/pages/SimulationEOPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
src/features/simulations/pages/SujetsEOPage.tsx
Normal file
126
src/features/simulations/pages/SujetsEOPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
getSimulationState,
|
||||
updateSujet as updateSujetApi,
|
||||
} from '@/entities/production/api'
|
||||
import { correctEe } from '@/entities/report/api'
|
||||
import { correctEe, correctEo } from '@/entities/report/api'
|
||||
import type { CreateSimulationPayload, Production, Tache } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
|
@ -25,10 +25,29 @@ import { SimulationFlowContext, type FlowValue, type SimulationStep } from './si
|
|||
|
||||
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
||||
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
||||
const LS_EO_T1_PRESENTATION_KEY = 'expria_eo_t1_presentation'
|
||||
|
||||
function isEoTache(tache: Tache): boolean {
|
||||
return tache.startsWith('EO_')
|
||||
}
|
||||
|
||||
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||
const [step, setStep] = useState<SimulationStep>('idle')
|
||||
const [production, setProduction] = useState<Production | null>(null)
|
||||
const [taskUnavailableMessage, setTaskUnavailableMessage] = useState<string | null>(null)
|
||||
// Sprint 4c-2 — état initialisé depuis localStorage pour survivre au refresh
|
||||
// tout au long du flux T1 (questionnaire → présentation → enregistrement).
|
||||
const [presentationT1, setPresentationT1State] = useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
|
||||
})
|
||||
|
||||
function setPresentationT1(text: string | null): void {
|
||||
setPresentationT1State(text)
|
||||
if (typeof window === 'undefined') return
|
||||
if (text === null) window.localStorage.removeItem(LS_EO_T1_PRESENTATION_KEY)
|
||||
else window.localStorage.setItem(LS_EO_T1_PRESENTATION_KEY, text)
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hydratedRef = useRef(false)
|
||||
|
|
@ -59,8 +78,25 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
sujet_id: state.sujet?.id,
|
||||
})
|
||||
setStep('task-selected')
|
||||
if (!location.pathname.startsWith('/simulation/ee')) {
|
||||
navigate('/simulation/ee')
|
||||
// Sprint 4c-2 — restauration EO :
|
||||
// - EO_T1 + présentation déjà générée → /t1/presentation
|
||||
// - EO_T1 sans présentation → /t1/mode (choix mode)
|
||||
// - EO_T3 → /pre-enregistrement
|
||||
// - EE → /simulation/ee
|
||||
let targetBase: string
|
||||
if (state.tache === 'EO_T1') {
|
||||
const stored =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
|
||||
: null
|
||||
targetBase = stored ? '/simulation/eo/t1/presentation' : '/simulation/eo/t1/mode'
|
||||
} else if (isEoTache(state.tache)) {
|
||||
targetBase = '/simulation/eo/pre-enregistrement'
|
||||
} else {
|
||||
targetBase = '/simulation/ee'
|
||||
}
|
||||
if (!location.pathname.startsWith(targetBase)) {
|
||||
navigate(targetBase)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -75,11 +111,15 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
setProduction(data)
|
||||
const hasCatalogue = !TACHES_SANS_CATALOGUE.includes(data.tache)
|
||||
setStep(hasCatalogue ? 'choosing-subject' : 'task-selected')
|
||||
// Navigation initiale vers /sujets pour les tâches avec catalogue —
|
||||
// gérée ici (et non dans un useEffect sticky côté SimulationPage) pour
|
||||
// éviter la boucle infinie quand l'utilisateur revient depuis /sujets.
|
||||
if (hasCatalogue) {
|
||||
navigate('/sujets')
|
||||
// Sprint 4c-2 — routage post-création :
|
||||
// - EE_T1/T2/T3 (avec catalogue) → /sujets (legacy)
|
||||
// - EO_T3 (avec catalogue) → /simulation/eo/sujets
|
||||
// - EO_T1 (sans catalogue) → /simulation/eo/t1/mode (choix génération
|
||||
// vs enregistrement direct).
|
||||
if (data.tache === 'EO_T1') {
|
||||
navigate('/simulation/eo/t1/mode')
|
||||
} else if (hasCatalogue) {
|
||||
navigate(isEoTache(data.tache) ? '/simulation/eo/sujets' : '/sujets')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -98,7 +138,24 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
onError: () => setStep('task-selected'),
|
||||
})
|
||||
|
||||
// Sprint 4c-1 — mutation EO. Sépare le pipeline pour éviter de devoir
|
||||
// discriminer dynamiquement le payload (EE vs EO) côté mutationFn.
|
||||
const correctEoMutation = useMutation({
|
||||
mutationFn: correctEo,
|
||||
onMutate: () => setStep('correcting'),
|
||||
onSuccess: (_data, variables) => {
|
||||
setStep('done')
|
||||
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
||||
navigate(`/rapport/${variables.simulationId}`)
|
||||
},
|
||||
onError: () => setStep('recording'),
|
||||
})
|
||||
|
||||
function selectTask(payload: CreateSimulationPayload): void {
|
||||
// Sprint 4c-2 — l'interception EO_T1 introduite en 4c-1 est levée :
|
||||
// le flux T1 est désormais wired (cf. createMutation.onSuccess).
|
||||
// `taskUnavailableMessage` reste exposé pour de futurs cas (ex. T2 Live).
|
||||
setTaskUnavailableMessage(null)
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +169,21 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
})
|
||||
}
|
||||
|
||||
// Sprint 4c-3 — bascule transcription live → audio batch backend.
|
||||
// Le frontend envoie l'audio brut en base64 + mimeType ; le backend appelle
|
||||
// Gemini batch pour la transcription puis poursuit le pipeline correction
|
||||
// (cf. POST /corrections/eo en mode audio).
|
||||
function submitEoAudio(audioBase64: string, mimeType: string, nclcCible: 9 | 10 = 9): void {
|
||||
if (!production) return
|
||||
correctEoMutation.mutate({
|
||||
simulationId: production.id,
|
||||
tache: production.tache,
|
||||
audioBase64,
|
||||
mimeType,
|
||||
nclc_cible: nclcCible,
|
||||
})
|
||||
}
|
||||
|
||||
function changeSubject(sujet: SujetData): void {
|
||||
// FTD-21 — persiste le changement côté backend (best-effort : l'UI ne bloque pas).
|
||||
if (production) {
|
||||
|
|
@ -125,22 +197,29 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
function reset(): void {
|
||||
setStep('idle')
|
||||
setProduction(null)
|
||||
setTaskUnavailableMessage(null)
|
||||
setPresentationT1(null)
|
||||
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
||||
createMutation.reset()
|
||||
correctMutation.reset()
|
||||
correctEoMutation.reset()
|
||||
}
|
||||
|
||||
const value: FlowValue = {
|
||||
step,
|
||||
production,
|
||||
sujet: production?.sujet ?? null,
|
||||
report: (correctMutation.data ?? null) as Report | null,
|
||||
report: (correctMutation.data ?? correctEoMutation.data ?? null) as Report | null,
|
||||
isCreating: createMutation.isPending,
|
||||
isCorrecting: correctMutation.isPending,
|
||||
isCorrecting: correctMutation.isPending || correctEoMutation.isPending,
|
||||
createError: createMutation.error as ApiError | null,
|
||||
correctError: correctMutation.error as ApiError | null,
|
||||
correctError: (correctMutation.error ?? correctEoMutation.error) as ApiError | null,
|
||||
taskUnavailableMessage,
|
||||
presentationT1,
|
||||
setPresentationT1,
|
||||
selectTask,
|
||||
submitText,
|
||||
submitEoAudio,
|
||||
changeSubject,
|
||||
setStep,
|
||||
reset,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Tests du flow EO ajoutés en Sprint 4c-1.
|
||||
*
|
||||
* Couvre :
|
||||
* - selectTask EO_T1 → message inline, pas de création
|
||||
* - selectTask EO_T3 → création + step='choosing-subject' (navigation testée
|
||||
* via le mock de useNavigate)
|
||||
* - submitEoAudio appelle correctEo avec audioBase64 + mimeType (Sprint 4c-3)
|
||||
* - non-régression EE : selectTask EE_T1 fonctionne toujours
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/entities/production/api')
|
||||
vi.mock('@/entities/report/api')
|
||||
|
||||
import { createSimulation, getSimulationState } from '@/entities/production/api'
|
||||
import { correctEo } from '@/entities/report/api'
|
||||
import { SimulationFlowProvider } from '../SimulationFlowProvider'
|
||||
import { useSimulationFlow } from '../simulationFlow'
|
||||
import type { Production } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
|
||||
const mockCreate = vi.mocked(createSimulation)
|
||||
const mockCorrectEo = vi.mocked(correctEo)
|
||||
const mockGetState = vi.mocked(getSimulationState)
|
||||
|
||||
const eoT3Production: Production = {
|
||||
id: 'sim-eo-1',
|
||||
tache: 'EO_T3',
|
||||
mode: 'entrainement',
|
||||
created_at: '2026-04-25T00:00:00Z',
|
||||
sujet: null,
|
||||
}
|
||||
|
||||
const mockEoReport: Report = {
|
||||
simulation_id: 'sim-eo-1',
|
||||
score: 14,
|
||||
nclc: 9,
|
||||
nclc_cible: 9,
|
||||
revelation: { croyance: '', realite: '', consequence: '' },
|
||||
diagnostic: '',
|
||||
criteres: [],
|
||||
conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
|
||||
erreurs_codes: [],
|
||||
exercices: null,
|
||||
exercices_status: 'pending',
|
||||
modele: null,
|
||||
modele_status: 'pending',
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(SimulationFlowProvider, null, children),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockGetState.mockRejectedValue(new Error('no resume'))
|
||||
})
|
||||
|
||||
describe('SimulationFlowProvider EO — Sprint 4c-1', () => {
|
||||
it('Sprint 4c-2 — EO_T1 crée la simulation (interception 4c-1 levée)', async () => {
|
||||
const eoT1: Production = { ...eoT3Production, tache: 'EO_T1' }
|
||||
mockCreate.mockResolvedValue(eoT1)
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.taskUnavailableMessage).toBeNull()
|
||||
})
|
||||
|
||||
it('EO_T3 : selectTask crée la simulation et passe en choosing-subject', async () => {
|
||||
mockCreate.mockResolvedValue(eoT3Production)
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||
expect(result.current.production).toEqual(eoT3Production)
|
||||
expect(mockCreate.mock.calls[0]?.[0]).toEqual({ tache: 'EO_T3', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
it('submitEoAudio appelle correctEo avec audioBase64 + mimeType', async () => {
|
||||
mockCreate.mockResolvedValue(eoT3Production)
|
||||
mockCorrectEo.mockResolvedValue(mockEoReport)
|
||||
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
|
||||
})
|
||||
await waitFor(() => expect(result.current.production).toEqual(eoT3Production))
|
||||
|
||||
act(() => {
|
||||
result.current.submitEoAudio('AAAAAA==', 'audio/webm', 9)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCorrectEo).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockCorrectEo.mock.calls[0]?.[0]).toEqual({
|
||||
simulationId: 'sim-eo-1',
|
||||
tache: 'EO_T3',
|
||||
audioBase64: 'AAAAAA==',
|
||||
mimeType: 'audio/webm',
|
||||
nclc_cible: 9,
|
||||
})
|
||||
})
|
||||
|
||||
it('non-régression EE : selectTask EE_T1 reste fonctionnel', async () => {
|
||||
const eeProduction: Production = { ...eoT3Production, id: 'sim-ee', tache: 'EE_T1' }
|
||||
mockCreate.mockResolvedValue(eeProduction)
|
||||
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||
expect(result.current.production).toEqual(eeProduction)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Tests du flow T1 — Sprint 4c-2.
|
||||
*
|
||||
* Couvre :
|
||||
* - setPresentationT1 expose la valeur via le hook + persiste en localStorage
|
||||
* - reset() remet presentationT1 à null + nettoie localStorage
|
||||
* - hydratation au mount lit la valeur depuis localStorage
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/entities/production/api')
|
||||
vi.mock('@/entities/report/api')
|
||||
|
||||
import { getSimulationState } from '@/entities/production/api'
|
||||
import { SimulationFlowProvider } from '../SimulationFlowProvider'
|
||||
import { useSimulationFlow } from '../simulationFlow'
|
||||
|
||||
const mockGetState = vi.mocked(getSimulationState)
|
||||
|
||||
const LS_KEY = 'expria_eo_t1_presentation'
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(SimulationFlowProvider, null, children),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mockGetState.mockRejectedValue(new Error('no resume'))
|
||||
})
|
||||
|
||||
describe('SimulationFlowProvider T1 — Sprint 4c-2', () => {
|
||||
it('setPresentationT1 expose la valeur et la persiste en localStorage', () => {
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.presentationT1).toBeNull()
|
||||
|
||||
act(() => {
|
||||
result.current.setPresentationT1('Bonjour je m appelle Marie...')
|
||||
})
|
||||
|
||||
expect(result.current.presentationT1).toBe('Bonjour je m appelle Marie...')
|
||||
expect(localStorage.getItem(LS_KEY)).toBe('Bonjour je m appelle Marie...')
|
||||
})
|
||||
|
||||
it('setPresentationT1(null) supprime la clé localStorage', () => {
|
||||
localStorage.setItem(LS_KEY, 'old')
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.setPresentationT1(null)
|
||||
})
|
||||
|
||||
expect(result.current.presentationT1).toBeNull()
|
||||
expect(localStorage.getItem(LS_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('hydrate presentationT1 depuis localStorage au mount', () => {
|
||||
localStorage.setItem(LS_KEY, 'présentation persistée')
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.presentationT1).toBe('présentation persistée')
|
||||
})
|
||||
|
||||
it('reset() remet presentationT1 à null et nettoie localStorage', () => {
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.setPresentationT1('texte')
|
||||
})
|
||||
expect(result.current.presentationT1).toBe('texte')
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.presentationT1).toBeNull()
|
||||
expect(localStorage.getItem(LS_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -11,7 +11,13 @@ import type { CreateSimulationPayload, Production, SujetData } from '@/entities/
|
|||
import type { Report } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
||||
export type SimulationStep = 'idle' | 'choosing-subject' | 'task-selected' | 'correcting' | 'done'
|
||||
export type SimulationStep =
|
||||
| 'idle'
|
||||
| 'choosing-subject'
|
||||
| 'task-selected'
|
||||
| 'recording'
|
||||
| 'correcting'
|
||||
| 'done'
|
||||
|
||||
export interface FlowValue {
|
||||
step: SimulationStep
|
||||
|
|
@ -22,8 +28,32 @@ export interface FlowValue {
|
|||
isCorrecting: boolean
|
||||
createError: ApiError | null
|
||||
correctError: ApiError | null
|
||||
/**
|
||||
* Sprint 4c-1 — message d'info non bloquant remonté par `selectTask` quand
|
||||
* l'utilisateur clique sur une tâche temporairement indisponible (EO_T1
|
||||
* dans 4c-1). La tâche n'est pas créée et l'UI affiche le message.
|
||||
* Réinitialisé à null à chaque nouvelle action.
|
||||
*/
|
||||
taskUnavailableMessage: string | null
|
||||
/**
|
||||
* Sprint 4c-2 — texte de présentation T1 généré par DeepSeek (ou édité
|
||||
* manuellement par l'utilisateur). Utilisé comme texte de référence
|
||||
* affiché pendant l'enregistrement EO_T1. Mirroré aussi dans
|
||||
* `localStorage.expria_eo_t1_presentation` pour survivre aux refresh.
|
||||
* `null` quand aucune présentation n'a encore été générée pour la session
|
||||
* en cours, ou quand l'utilisateur a choisi le mode « enregistrer
|
||||
* directement » (sans questionnaire).
|
||||
*/
|
||||
presentationT1: string | null
|
||||
setPresentationT1: (text: string | null) => void
|
||||
selectTask: (payload: CreateSimulationPayload) => void
|
||||
submitText: (texte: string, nclcCible?: 9 | 10) => void
|
||||
/**
|
||||
* Sprint 4c-1 (transcript live Deepgram) → 4c-3 (audio batch Gemini backend) :
|
||||
* envoie l'audio brut en base64 au backend qui transcrit puis corrige. Le
|
||||
* paramètre `mimeType` indique le format produit par MediaRecorder.
|
||||
*/
|
||||
submitEoAudio: (audioBase64: string, mimeType: string, nclcCible?: 9 | 10) => void
|
||||
changeSubject: (sujet: SujetData) => void
|
||||
setStep: (step: SimulationStep) => void
|
||||
reset: () => void
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue