diff --git a/src/app/router.tsx b/src/app/router.tsx
index a4b529f..ca1a829 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -6,7 +6,9 @@ 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'
+import { SujetsPage } from '@/features/simulations/pages/SujetsPage'
import { RapportPage } from '@/features/simulations/pages/RapportPage'
+import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
import { AppLayout } from './AppLayout'
const DesignSystemPage = import.meta.env.DEV
@@ -32,6 +34,14 @@ function PrivateLayout() {
)
}
+function SimulationFlowLayout() {
+ return (
+
+
+
+ )
+}
+
export function AppRouter() {
return (
@@ -44,9 +54,12 @@ export function AppRouter() {
} />
} />
- {/* Simulation */}
+ {/* Simulation — /simulation/ee et /sujets partagent le SimulationFlowProvider. */}
} />
- } />
+ }>
+ } />
+ } />
+
} />
{/* Rapport */}
diff --git a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
index 60362b9..95f00ee 100644
--- a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
+++ b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
@@ -3,7 +3,7 @@
*
* Transitions couvertes :
* idle → choosing-subject (selectTask success, tâche avec catalogue)
- * choosing-subject → task-selected (selectRandom / selectSujet)
+ * choosing-subject → task-selected (selectSujet)
* task-selected → correcting (submitText déclenché)
* correcting → done (correctEe success)
* correcting → task-selected (correctEe error)
@@ -13,9 +13,11 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSimulation } from '../useSimulation'
+import { SimulationFlowProvider } from '../../state/SimulationFlowProvider'
import { createSimulation } from '@/entities/production/api'
import { correctEe } from '@/entities/report/api'
import type { Production } from '@/entities/production/types'
@@ -35,6 +37,17 @@ const mockProduction: Production = {
sujet: null,
}
+const mockSujet = {
+ id: 'sujet-1',
+ consigne: 'Rédigez une lettre.',
+ role: null,
+ contexte: null,
+ doc1_titre: null,
+ doc1_texte: null,
+ doc2_titre: null,
+ doc2_texte: null,
+}
+
const mockReport: Report = {
simulation_id: 'sim-1',
score: 80,
@@ -52,7 +65,15 @@ function createWrapper() {
defaultOptions: { mutations: { retry: false } },
})
return function Wrapper({ children }: { children: React.ReactNode }) {
- return React.createElement(QueryClientProvider, { client: queryClient }, children)
+ return React.createElement(
+ MemoryRouter,
+ null,
+ React.createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ React.createElement(SimulationFlowProvider, null, children),
+ ),
+ )
}
}
@@ -125,7 +146,7 @@ describe('useSimulation — submitText', () => {
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- act(() => result.current.selectRandom([]))
+ act(() => result.current.selectSujet(mockSujet))
await waitFor(() => expect(result.current.step).toBe('task-selected'))
act(() => result.current.submitText('Mon texte de production.'))
@@ -144,7 +165,7 @@ describe('useSimulation — submitText', () => {
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- act(() => result.current.selectRandom([]))
+ act(() => result.current.selectSujet(mockSujet))
await waitFor(() => expect(result.current.step).toBe('task-selected'))
act(() => result.current.submitText('Mon texte.')
@@ -171,7 +192,7 @@ describe('useSimulation — reset', () => {
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- act(() => result.current.selectRandom([]))
+ act(() => result.current.selectSujet(mockSujet))
await waitFor(() => expect(result.current.step).toBe('task-selected'))
act(() => result.current.reset())
diff --git a/src/features/simulations/hooks/useSimulation.ts b/src/features/simulations/hooks/useSimulation.ts
index 32eb11d..27a837c 100644
--- a/src/features/simulations/hooks/useSimulation.ts
+++ b/src/features/simulations/hooks/useSimulation.ts
@@ -1,119 +1,49 @@
/**
- * Hook d'orchestration du flux simulation EE.
+ * Hook d'orchestration du flux simulation EE — consommateur de SimulationFlowProvider.
*
- * Séquence : createSimulation (POST /simulations)
- * → [choosing-subject] (sauf EO_T1 — sujet fixe)
- * → correctEe (POST /corrections/ee, timeout 30 s)
+ * Depuis la refonte /sujets (Option A), l'état vit dans le Provider pour survivre
+ * aux navigations entre /simulation/ee et /sujets. Ce hook ajoute la navigation
+ * vers /sujets après création d'une simulation pour une tâche avec catalogue.
*
- * State machine :
- * 'idle' → sélection de tâche disponible
- * 'choosing-subject' → écran SujetSelector (hors EO_T1)
- * '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é).
+ * Règle H : aucune logique métier — 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,
- SujetData,
- Tache,
-} from '@/entities/production/types'
-import type { Report } from '@/entities/report/types'
-import type { ApiError } from '@/shared/types/api'
-
-export type SimulationStep =
- | 'idle'
- | 'choosing-subject'
- | 'task-selected'
- | 'correcting'
- | 'done'
-
-/** Tâches qui ne passent pas par l'écran de choix de sujet. */
-const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
+import { useNavigate } from 'react-router-dom'
+import { useSimulationFlow } from '../state/SimulationFlowProvider'
+import type { SujetData } from '@/entities/production/types'
export function useSimulation() {
- const [step, setStep] = useState('idle')
- const [production, setProduction] = useState(null)
+ const navigate = useNavigate()
+ const flow = useSimulationFlow()
- const createMutation = useMutation({
- mutationFn: createSimulation,
- onSuccess: (data) => {
- setProduction(data)
- setStep(TACHES_SANS_CATALOGUE.includes(data.tache) ? 'task-selected' : 'choosing-subject')
- },
- })
-
- 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({ simulationId: production.id, contenu: texte, tache: production.tache })
- }
-
- /** Remplace le sujet courant sans toucher à l'étape. */
- function changeSubject(sujet: SujetData): void {
- setProduction((p) => (p ? { ...p, sujet } : p))
- }
-
- /** Choix manuel : remplace le sujet et passe à la saisie. */
+ /** Sélectionne un sujet puis passe à la saisie (utilisé depuis /sujets). */
function selectSujet(sujet: SujetData): void {
- changeSubject(sujet)
- setStep('task-selected')
+ flow.changeSubject(sujet)
+ flow.setStep('task-selected')
+ navigate('/simulation/ee')
}
- /** Choix aléatoire côté client à partir d'une liste pré-chargée. */
- function selectRandom(sujets: SujetData[]): void {
- if (sujets.length > 0) {
- const pick = sujets[Math.floor(Math.random() * sujets.length)]
- changeSubject(pick)
- }
- setStep('task-selected')
- }
-
- /** Retour à l'écran SujetSelector depuis SimulationForm. */
- function backToSubject(): void {
- setStep('choosing-subject')
- }
-
- function reset(): void {
- setStep('idle')
- setProduction(null)
- createMutation.reset()
- correctMutation.reset()
+ /** Retour à /sujets depuis SimulationForm (bouton "Changer de sujet"). */
+ function goToSubjectPicker(): void {
+ flow.setStep('choosing-subject')
+ navigate('/sujets')
}
return {
- step,
- production,
- sujet: production?.sujet ?? null,
- 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,
- changeSubject,
+ step: flow.step,
+ production: flow.production,
+ sujet: flow.sujet,
+ report: flow.report,
+ isCreating: flow.isCreating,
+ isCorrecting: flow.isCorrecting,
+ createError: flow.createError,
+ correctError: flow.correctError,
+ selectTask: flow.selectTask,
+ submitText: flow.submitText,
+ changeSubject: flow.changeSubject,
selectSujet,
- selectRandom,
- backToSubject,
- reset,
+ goToSubjectPicker,
+ reset: flow.reset,
}
}
diff --git a/src/features/simulations/pages/SimulationPage.tsx b/src/features/simulations/pages/SimulationPage.tsx
index 766fee7..c60fc9c 100644
--- a/src/features/simulations/pages/SimulationPage.tsx
+++ b/src/features/simulations/pages/SimulationPage.tsx
@@ -2,6 +2,7 @@
* Page de simulation Expression Écrite.
*
* Orchestre les 3 étapes du flux : sélection de tâche → saisie du texte → rapport.
+ * Le choix du sujet est désormais délégué à la page /sujets (refonte UX 2026-04-21).
*
* 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.
@@ -60,12 +61,18 @@ export function SimulationPage() {
reset,
} = useSimulation()
- // Catalogue des sujets pour le dropdown dans SujetDisplay.
- // EO_T1 n'a pas de catalogue (getSujets retourne [] — requête court-circuitée côté API).
- const {
- data: sujets,
- isLoading: isLoadingSujets,
- } = useSujets(production?.tache ?? 'EE_T1', !!production)
+ // Catalogue passé à SimulationForm (dropdown hérité — refacto étape 3).
+ const { data: sujets, isLoading: isLoadingSujets } = useSujets(
+ production?.tache ?? 'EE_T1',
+ !!production,
+ )
+
+ // Redirige vers /sujets dès que la création aboutit pour une tâche avec catalogue.
+ useEffect(() => {
+ if (step === 'choosing-subject' && production) {
+ navigate('/sujets')
+ }
+ }, [step, production, navigate])
useEffect(() => {
if (step === 'done' && production) {
@@ -98,7 +105,7 @@ export function SimulationPage() {
)}
{planData &&
- (step === 'choosing-subject' || step === 'task-selected' || step === 'correcting') &&
+ (step === 'task-selected' || step === 'correcting') &&
production && (