feat(simulations): useSimulation hook + TaskSelector + SimulationForm + SimulationPage + route (Sprint 3 étape 14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b31e8666a5
commit
997f39bd33
7 changed files with 621 additions and 0 deletions
160
src/features/simulations/hooks/__tests__/useSimulation.test.tsx
Normal file
160
src/features/simulations/hooks/__tests__/useSimulation.test.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Tests de la state machine useSimulation.
|
||||
*
|
||||
* Transitions couvertes :
|
||||
* idle → task-selected (selectTask success)
|
||||
* task-selected → correcting (submitText déclenché)
|
||||
* correcting → done (correctEe success)
|
||||
* correcting → task-selected (correctEe error)
|
||||
* * → idle (reset)
|
||||
* guard submitText sans production (aucune mutation)
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSimulation } from '../useSimulation'
|
||||
import { createSimulation } from '@/entities/production/api'
|
||||
import { correctEe } from '@/entities/report/api'
|
||||
import type { Production } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
|
||||
vi.mock('@/entities/production/api')
|
||||
vi.mock('@/entities/report/api')
|
||||
|
||||
const mockCreateSimulation = vi.mocked(createSimulation)
|
||||
const mockCorrectEe = vi.mocked(correctEe)
|
||||
|
||||
const mockProduction: Production = {
|
||||
id: 'sim-1',
|
||||
tache: 'EE_T1',
|
||||
mode: 'entrainement',
|
||||
created_at: '2026-04-19T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockReport: Report = {
|
||||
simulation_id: 'sim-1',
|
||||
score: 80,
|
||||
nclc: 9,
|
||||
feedback_court: 'Bon travail.',
|
||||
criteres: [],
|
||||
erreurs: [],
|
||||
modele: '',
|
||||
idees: [],
|
||||
exercices: [],
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
})
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useSimulation — état initial', () => {
|
||||
it('step = idle, production null, report null', () => {
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
expect(result.current.step).toBe('idle')
|
||||
expect(result.current.production).toBeNull()
|
||||
expect(result.current.report).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSimulation — selectTask', () => {
|
||||
it('step passe à task-selected et production est hydratée après succès', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
expect(result.current.production).toEqual(mockProduction)
|
||||
})
|
||||
|
||||
it('isCreating = true pendant la mutation createSimulation', async () => {
|
||||
let resolveCreate!: (p: Production) => void
|
||||
mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r }))
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EE_T2', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isCreating).toBe(true))
|
||||
|
||||
act(() => resolveCreate(mockProduction))
|
||||
await waitFor(() => expect(result.current.isCreating).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSimulation — submitText', () => {
|
||||
it('step correcting pendant la correction, puis done après succès', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
|
||||
let resolveCorrect!: (r: Report) => void
|
||||
mockCorrectEe.mockImplementation(() => new Promise(r => { resolveCorrect = r }))
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
|
||||
act(() => result.current.submitText('Mon texte de production.'))
|
||||
await waitFor(() => expect(result.current.step).toBe('correcting'))
|
||||
|
||||
act(() => resolveCorrect(mockReport))
|
||||
await waitFor(() => expect(result.current.step).toBe('done'))
|
||||
expect(result.current.report).toEqual(mockReport)
|
||||
})
|
||||
|
||||
it('step revient à task-selected si correctEe échoue', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
mockCorrectEe.mockRejectedValue({ code: 'SIMULATION_NOT_FOUND', message: 'Not found' })
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
|
||||
act(() => result.current.submitText('Mon texte.')
|
||||
)
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
expect(result.current.report).toBeNull()
|
||||
})
|
||||
|
||||
it('submitText sans production ne déclenche aucune mutation', async () => {
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.submitText('texte quelconque'))
|
||||
|
||||
expect(mockCorrectEe).not.toHaveBeenCalled()
|
||||
expect(result.current.step).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSimulation — reset', () => {
|
||||
it('reset depuis task-selected remet step à idle et production à null', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
|
||||
act(() => result.current.reset())
|
||||
|
||||
expect(result.current.step).toBe('idle')
|
||||
expect(result.current.production).toBeNull()
|
||||
})
|
||||
})
|
||||
74
src/features/simulations/hooks/useSimulation.ts
Normal file
74
src/features/simulations/hooks/useSimulation.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Hook d'orchestration du flux simulation EE.
|
||||
*
|
||||
* Séquence : createSimulation (POST /simulations)
|
||||
* → correctEe (POST /corrections/ee, timeout 30 s)
|
||||
*
|
||||
* State machine :
|
||||
* 'idle' → sélection de tâche disponible
|
||||
* 'task-selected' → formulaire de saisie visible
|
||||
* 'correcting' → correction en cours (30 s max)
|
||||
* 'done' → rapport disponible dans `report`
|
||||
*
|
||||
* Règle H : aucune logique métier ici — les gardes de quota et de plan
|
||||
* sont dans TaskSelector (UX) et dans le backend (autorité).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { createSimulation } from '@/entities/production/api'
|
||||
import { correctEe } from '@/entities/report/api'
|
||||
import type { CreateSimulationPayload, Production } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
||||
export type SimulationStep = 'idle' | 'task-selected' | 'correcting' | 'done'
|
||||
|
||||
export function useSimulation() {
|
||||
const [step, setStep] = useState<SimulationStep>('idle')
|
||||
const [production, setProduction] = useState<Production | null>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createSimulation,
|
||||
onSuccess: (data) => {
|
||||
setProduction(data)
|
||||
setStep('task-selected')
|
||||
},
|
||||
})
|
||||
|
||||
const correctMutation = useMutation({
|
||||
mutationFn: correctEe,
|
||||
onMutate: () => setStep('correcting'),
|
||||
onSuccess: () => setStep('done'),
|
||||
onError: () => setStep('task-selected'),
|
||||
})
|
||||
|
||||
function selectTask(payload: CreateSimulationPayload): void {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
|
||||
function submitText(texte: string): void {
|
||||
if (!production) return
|
||||
correctMutation.mutate({ simulation_id: production.id, texte })
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
setStep('idle')
|
||||
setProduction(null)
|
||||
createMutation.reset()
|
||||
correctMutation.reset()
|
||||
}
|
||||
|
||||
return {
|
||||
step,
|
||||
production,
|
||||
report: (correctMutation.data ?? null) as Report | null,
|
||||
isCreating: createMutation.isPending,
|
||||
isCorrecting: correctMutation.isPending,
|
||||
createError: createMutation.error as ApiError | null,
|
||||
correctError: correctMutation.error as ApiError | null,
|
||||
selectTask,
|
||||
submitText,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue