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:
Hermann_Kitio 2026-04-20 00:08:34 +03:00
parent b31e8666a5
commit 997f39bd33
7 changed files with 621 additions and 0 deletions

View file

@ -5,6 +5,7 @@ import { LoginPage } from '@/features/auth/pages/LoginPage'
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
const DesignSystemPage = import.meta.env.DEV
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
@ -24,6 +25,14 @@ export function AppRouter() {
</ProtectedRoute>
}
/>
<Route
path="/simulation"
element={
<ProtectedRoute>
<SimulationPage />
</ProtectedRoute>
}
/>
{import.meta.env.DEV && (
<Route
path="/design-system"

View file

@ -0,0 +1,139 @@
/**
* Formulaire de saisie pour une simulation Expression Écrite.
*
* SEC-04 : validation Zod avant envoi (texte non vide, max 5 000 caractères).
* SEC-05 : aucun dangerouslySetInnerHTML le texte utilisateur est rendu comme texte.
* Règle H : aucune logique métier le composant reçoit tache, handlers et états par props.
*/
import { useState, type FormEvent } from 'react'
import { Loader2 } from 'lucide-react'
import { z } from 'zod'
import { Button } from '@/shared/components/ui/button'
import { formatTache } from '@/entities/production/lib'
import type { Tache } from '@/entities/production/types'
import type { ApiError } from '@/shared/types/api'
const textSchema = z.object({
texte: z
.string()
.min(1, 'Le texte ne peut pas être vide.')
.max(5000, 'Le texte ne doit pas dépasser 5 000 caractères.'),
})
function mapCorrectError(err: ApiError | null): string | null {
if (!err) return null
switch (err.code) {
case 'SIMULATION_NOT_FOUND':
return 'Simulation introuvable. Revenez en arrière et recommencez.'
case 'AUTH_REQUIRED':
return 'Votre session a expiré. Reconnectez-vous.'
case 'VALIDATION_ERROR':
case 'INVALID_BODY':
return 'Le texte soumis est invalide. Vérifiez votre saisie.'
default:
return 'Correction impossible. Réessayez dans quelques instants.'
}
}
interface Props {
tache: Tache
isSubmitting: boolean
error: ApiError | null
onSubmit: (texte: string) => void
onBack: () => void
}
export function SimulationForm({ tache, isSubmitting, error, onSubmit, onBack }: Props) {
const [texte, setTexte] = useState('')
const [fieldError, setFieldError] = useState<string | null>(null)
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setFieldError(null)
const parsed = textSchema.safeParse({ texte })
if (!parsed.success) {
setFieldError(parsed.error.flatten().fieldErrors.texte?.[0] ?? null)
return
}
onSubmit(parsed.data.texte)
}
const apiError = mapCorrectError(error)
const charCount = texte.length
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
disabled={isSubmitting}
className="text-sm text-ink-4 underline-offset-4 hover:text-ink-2 hover:underline disabled:pointer-events-none"
>
Retour
</button>
<h2 className="text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
</div>
{apiError && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
>
{apiError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
<div className="space-y-1.5">
<label htmlFor="texte" className="text-sm font-medium text-ink-2">
Votre production
</label>
<textarea
id="texte"
rows={12}
value={texte}
onChange={(e) => setTexte(e.target.value)}
disabled={isSubmitting}
placeholder="Rédigez votre texte ici…"
aria-invalid={!!fieldError}
aria-describedby={fieldError ? 'texte-error' : undefined}
className="w-full resize-none rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="flex items-start justify-between gap-2">
{fieldError ? (
<p id="texte-error" className="text-sm text-danger">
{fieldError}
</p>
) : (
<span />
)}
<span className={`text-xs tabular-nums ${charCount > 5000 ? 'text-danger' : 'text-ink-5'}`}>
{charCount.toLocaleString('fr-FR')}&thinsp;/&thinsp;5 000
</span>
</div>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Correction en cours
</>
) : (
'Envoyer pour correction'
)}
</Button>
{isSubmitting && (
<p className="text-center text-xs text-ink-4">
La correction peut prendre jusqu'à 30 secondes.
</p>
)}
</form>
</div>
)
}

View file

@ -0,0 +1,113 @@
/**
* Sélecteur de tâche pour lancer une simulation.
*
* Affiche les 6 tâches TCF :
* - EE T1/T2/T3 sélectionnables si quota OK
* - EO T1/T3 verrouillées (audio Sprint 4)
* - EO T2 Live verrouillée (Exclusivité Premium Sprint 6)
*
* Règle D : le quota est vérifié via canSimulate(), jamais if (plan === 'free').
* Règle H : aucune logique métier uniquement appel de canSimulate() et affichage.
*/
import { Lock, Loader2 } from 'lucide-react'
import { canSimulate } from '@/entities/user/lib'
import { cn } from '@/shared/lib/utils'
import type { Plan } from '@/entities/user/lib'
import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
interface Props {
plan: Plan
simulationsUsed: number
isLoading: boolean
onSelect: (payload: CreateSimulationPayload) => void
}
interface TaskCard {
tache: Tache | null // null = EO T2 Live (hors Tache type)
label: string
sublabel: string
sprintLocked?: boolean // audio ou premium non encore implémenté
lockLabel?: string
}
const TASK_CARDS: readonly TaskCard[] = [
{ tache: 'EE_T1', label: 'Expression Écrite', sublabel: 'Tâche 1' },
{ tache: 'EE_T2', label: 'Expression Écrite', sublabel: 'Tâche 2' },
{ tache: 'EE_T3', label: 'Expression Écrite', sublabel: 'Tâche 3' },
{ tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Tâche 1', sprintLocked: true, lockLabel: 'Bientôt disponible' },
{ tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Tâche 3', sprintLocked: true, lockLabel: 'Bientôt disponible' },
{ tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', sprintLocked: true, lockLabel: 'Exclusivité Premium' },
]
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
const simulationCheck = canSimulate(plan, simulationsUsed)
const quotaBlocked = !simulationCheck.allowed
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2>
<p className="mt-1 text-sm text-ink-3">
Sélectionnez la tâche que vous souhaitez simuler.
</p>
</div>
{quotaBlocked && (
<div
role="alert"
className="rounded-lg border border-danger/30 bg-danger-bg px-4 py-3 text-sm text-danger"
>
Vous avez utilisé vos 5 simulations gratuites.{' '}
<a href="/pricing" className="underline underline-offset-4">
Passer en Standard
</a>{' '}
pour continuer votre préparation.
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{TASK_CARDS.map((card) => {
const locked = card.sprintLocked || quotaBlocked
const disabled = locked || isLoading || card.tache === null
return (
<button
key={`${card.tache ?? 'eo-t2'}`}
type="button"
disabled={disabled}
onClick={() => {
if (card.tache && !locked) {
onSelect({ tache: card.tache, mode: 'entrainement' })
}
}}
className={cn(
'group relative flex flex-col rounded-lg border p-4 text-left transition-colors',
locked || card.tache === null
? 'cursor-not-allowed border-line bg-canvas-2 opacity-60'
: 'cursor-pointer border-line bg-surface hover:border-expria hover:bg-expria-50',
isLoading && !locked && 'cursor-wait',
)}
>
{(card.sprintLocked || card.tache === null) && (
<Lock
className="mb-2 size-4 text-ink-4"
aria-hidden="true"
/>
)}
<span className="text-xs font-medium text-ink-4">{card.label}</span>
<span className="mt-0.5 text-sm font-semibold text-ink-1">{card.sublabel}</span>
{card.lockLabel && (
<span className="mt-1.5 text-xs text-ink-4">{card.lockLabel}</span>
)}
{isLoading && !locked && card.tache && (
<Loader2 className="absolute right-3 top-3 size-3.5 animate-spin text-expria" aria-hidden="true" />
)}
</button>
)
})}
</div>
</div>
)
}

View 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()
})
})

View 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,
}
}

View file

@ -0,0 +1,110 @@
/**
* Page de simulation Expression Écrite.
*
* Orchestre les 3 étapes du flux : sélection de tâche saisie du texte rapport.
*
* Règle D : quotas et permissions passent par canSimulate() jamais de plan === '...'
* Règle H : aucune logique métier tout est dans useSimulation() et les entités.
*
* Nota : queryKey ['plan'] est dupliqué depuis features/dashboard/hooks/usePlan.
* TanStack Query partage le cache par clé FTD-17.
*/
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getPlanStatus } from '@/entities/user/api'
import { Logo } from '@/shared/components/Logo'
import { ThemeToggle } from '@/shared/components/ThemeToggle'
import { Button } from '@/shared/components/ui/button'
import { useSimulation } from '../hooks/useSimulation'
import { TaskSelector } from '../components/TaskSelector'
import { SimulationForm } from '../components/SimulationForm'
function SimulationSkeleton() {
return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement…">
<div className="h-6 w-40 animate-pulse rounded bg-canvas-2" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-canvas-2" />
))}
</div>
</div>
)
}
export function SimulationPage() {
const navigate = useNavigate()
const {
data: planData,
isLoading: isPlanLoading,
isError: isPlanError,
refetch: refetchPlan,
} = useQuery({
queryKey: ['plan'],
queryFn: getPlanStatus,
staleTime: 5 * 60 * 1000,
})
const {
step,
production,
isCreating,
isCorrecting,
correctError,
selectTask,
submitText,
reset,
} = useSimulation()
useEffect(() => {
if (step === 'done' && production) {
navigate(`/rapport/${production.id}`)
}
}, [step, production, navigate])
return (
<div className="min-h-screen bg-canvas">
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
<Logo size="sm" />
<ThemeToggle />
</header>
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
{isPlanError && (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants.
</p>
<Button variant="outline" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{planData && step === 'idle' && (
<TaskSelector
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
)}
{planData && (step === 'task-selected' || step === 'correcting') && production && (
<SimulationForm
tache={production.tache}
isSubmitting={isCorrecting}
error={correctError}
onSubmit={submitText}
onBack={reset}
/>
)}
</main>
</div>
)
}