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