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

@ -161,6 +161,21 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
### FTD-17 — Clé `['plan']` dupliquée entre features
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — accepté par design (Sprint 3)
**Estimation de session :** 30 min
**Description :** La queryKey TanStack Query `['plan']` est écrite littéralement dans deux endroits : `features/dashboard/hooks/usePlan.ts` (via `PLAN_QUERY_KEY`) et `features/simulations/pages/SimulationPage.tsx` (inline). TanStack Query partage bien le cache par clé identique, donc la déduplication fonctionne en pratique. Mais toute faute de frappe dans l'une des occurrences briserait silencieusement la mise en cache partagée.
**À faire :**
- Déplacer `PLAN_QUERY_KEY` vers `src/entities/user/api.ts` (ou un nouveau `src/entities/user/query-keys.ts`)
- Importer `PLAN_QUERY_KEY` dans `usePlan.ts` et `SimulationPage.tsx` depuis ce fichier partagé
- Supprimer la constante locale dans `usePlan.ts`
**Condition de résolution :** avant l'ajout d'une troisième feature qui consomme `['plan']` (T2 Live page, rapport page).
---
## 3. Fonctionnalités reportées
### FTD-06 — AudioWorklet au lieu de ScriptProcessorNode (T2 Live)
@ -270,3 +285,4 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 |
| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) |
| 1.4 | 2026-04-18 | FTD-16 résolu (VITE_MAINTENANCE_MODE implémenté — Sprint 1 étape 6) |
| 1.5 | 2026-04-19 | Ajout FTD-17 (clé ['plan'] dupliquée entre features — Sprint 3 étape 14) |

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