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 && (