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
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue