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

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

Typecheck: OK · Tests: 159/159 

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

View file

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

View file

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

View file

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