expria-frontend/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
Hermann_Kitio d1c8b548bb 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>
2026-04-25 08:28:51 +03:00

216 lines
6.8 KiB
TypeScript

/**
* 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'])
})
})