- 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>
216 lines
6.8 KiB
TypeScript
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'])
|
|
})
|
|
})
|