feat(simulations): router /sujets + SimulationFlowProvider wiring + useSimulation refacto
This commit is contained in:
parent
782439b309
commit
a6f95c2093
4 changed files with 87 additions and 116 deletions
|
|
@ -6,7 +6,9 @@ import { RegisterPage } from '@/features/auth/pages/RegisterPage'
|
||||||
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
||||||
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
||||||
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
||||||
|
import { SujetsPage } from '@/features/simulations/pages/SujetsPage'
|
||||||
import { RapportPage } from '@/features/simulations/pages/RapportPage'
|
import { RapportPage } from '@/features/simulations/pages/RapportPage'
|
||||||
|
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
const DesignSystemPage = import.meta.env.DEV
|
const DesignSystemPage = import.meta.env.DEV
|
||||||
|
|
@ -32,6 +34,14 @@ function PrivateLayout() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SimulationFlowLayout() {
|
||||||
|
return (
|
||||||
|
<SimulationFlowProvider>
|
||||||
|
<Outlet />
|
||||||
|
</SimulationFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppRouter() {
|
export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -44,9 +54,12 @@ export function AppRouter() {
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
{/* Simulation */}
|
{/* Simulation — /simulation/ee et /sujets partagent le SimulationFlowProvider. */}
|
||||||
<Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
|
<Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
|
||||||
<Route path="/simulation/ee" element={<SimulationPage />} />
|
<Route element={<SimulationFlowLayout />}>
|
||||||
|
<Route path="/simulation/ee" element={<SimulationPage />} />
|
||||||
|
<Route path="/sujets" element={<SujetsPage />} />
|
||||||
|
</Route>
|
||||||
<Route path="/simulation/eo" element={<ComingSoon />} />
|
<Route path="/simulation/eo" element={<ComingSoon />} />
|
||||||
|
|
||||||
{/* Rapport */}
|
{/* Rapport */}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* Transitions couvertes :
|
* Transitions couvertes :
|
||||||
* idle → choosing-subject (selectTask success, tâche avec catalogue)
|
* 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é)
|
* task-selected → correcting (submitText déclenché)
|
||||||
* correcting → done (correctEe success)
|
* correcting → done (correctEe success)
|
||||||
* correcting → task-selected (correctEe error)
|
* correcting → task-selected (correctEe error)
|
||||||
|
|
@ -13,9 +13,11 @@
|
||||||
|
|
||||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { useSimulation } from '../useSimulation'
|
import { useSimulation } from '../useSimulation'
|
||||||
|
import { SimulationFlowProvider } from '../../state/SimulationFlowProvider'
|
||||||
import { createSimulation } from '@/entities/production/api'
|
import { createSimulation } from '@/entities/production/api'
|
||||||
import { correctEe } from '@/entities/report/api'
|
import { correctEe } from '@/entities/report/api'
|
||||||
import type { Production } from '@/entities/production/types'
|
import type { Production } from '@/entities/production/types'
|
||||||
|
|
@ -35,6 +37,17 @@ const mockProduction: Production = {
|
||||||
sujet: null,
|
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 = {
|
const mockReport: Report = {
|
||||||
simulation_id: 'sim-1',
|
simulation_id: 'sim-1',
|
||||||
score: 80,
|
score: 80,
|
||||||
|
|
@ -52,7 +65,15 @@ function createWrapper() {
|
||||||
defaultOptions: { mutations: { retry: false } },
|
defaultOptions: { mutations: { retry: false } },
|
||||||
})
|
})
|
||||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
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' }))
|
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
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'))
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
|
||||||
act(() => result.current.submitText('Mon texte de production.'))
|
act(() => result.current.submitText('Mon texte de production.'))
|
||||||
|
|
@ -144,7 +165,7 @@ describe('useSimulation — submitText', () => {
|
||||||
|
|
||||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
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'))
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
|
||||||
act(() => result.current.submitText('Mon texte.')
|
act(() => result.current.submitText('Mon texte.')
|
||||||
|
|
@ -171,7 +192,7 @@ describe('useSimulation — reset', () => {
|
||||||
|
|
||||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
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'))
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
|
||||||
act(() => result.current.reset())
|
act(() => result.current.reset())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
* Depuis la refonte /sujets (Option A), l'état vit dans le Provider pour survivre
|
||||||
* → [choosing-subject] (sauf EO_T1 — sujet fixe)
|
* aux navigations entre /simulation/ee et /sujets. Ce hook ajoute la navigation
|
||||||
* → correctEe (POST /corrections/ee, timeout 30 s)
|
* vers /sujets après création d'une simulation pour une tâche avec catalogue.
|
||||||
*
|
*
|
||||||
* State machine :
|
* Règle H : aucune logique métier — les gardes de quota et de plan sont dans
|
||||||
* 'idle' → sélection de tâche disponible
|
* TaskSelector (UX) et dans le backend (autorité).
|
||||||
* '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é).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useSimulationFlow } from '../state/SimulationFlowProvider'
|
||||||
import { createSimulation } from '@/entities/production/api'
|
import type { SujetData } from '@/entities/production/types'
|
||||||
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']
|
|
||||||
|
|
||||||
export function useSimulation() {
|
export function useSimulation() {
|
||||||
const [step, setStep] = useState<SimulationStep>('idle')
|
const navigate = useNavigate()
|
||||||
const [production, setProduction] = useState<Production | null>(null)
|
const flow = useSimulationFlow()
|
||||||
|
|
||||||
const createMutation = useMutation({
|
/** Sélectionne un sujet puis passe à la saisie (utilisé depuis /sujets). */
|
||||||
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. */
|
|
||||||
function selectSujet(sujet: SujetData): void {
|
function selectSujet(sujet: SujetData): void {
|
||||||
changeSubject(sujet)
|
flow.changeSubject(sujet)
|
||||||
setStep('task-selected')
|
flow.setStep('task-selected')
|
||||||
|
navigate('/simulation/ee')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choix aléatoire côté client à partir d'une liste pré-chargée. */
|
/** Retour à /sujets depuis SimulationForm (bouton "Changer de sujet"). */
|
||||||
function selectRandom(sujets: SujetData[]): void {
|
function goToSubjectPicker(): void {
|
||||||
if (sujets.length > 0) {
|
flow.setStep('choosing-subject')
|
||||||
const pick = sujets[Math.floor(Math.random() * sujets.length)]
|
navigate('/sujets')
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step,
|
step: flow.step,
|
||||||
production,
|
production: flow.production,
|
||||||
sujet: production?.sujet ?? null,
|
sujet: flow.sujet,
|
||||||
report: (correctMutation.data ?? null) as Report | null,
|
report: flow.report,
|
||||||
isCreating: createMutation.isPending,
|
isCreating: flow.isCreating,
|
||||||
isCorrecting: correctMutation.isPending,
|
isCorrecting: flow.isCorrecting,
|
||||||
createError: createMutation.error as ApiError | null,
|
createError: flow.createError,
|
||||||
correctError: correctMutation.error as ApiError | null,
|
correctError: flow.correctError,
|
||||||
selectTask,
|
selectTask: flow.selectTask,
|
||||||
submitText,
|
submitText: flow.submitText,
|
||||||
changeSubject,
|
changeSubject: flow.changeSubject,
|
||||||
selectSujet,
|
selectSujet,
|
||||||
selectRandom,
|
goToSubjectPicker,
|
||||||
backToSubject,
|
reset: flow.reset,
|
||||||
reset,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Page de simulation Expression Écrite.
|
* Page de simulation Expression Écrite.
|
||||||
*
|
*
|
||||||
* Orchestre les 3 étapes du flux : sélection de tâche → saisie du texte → rapport.
|
* 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 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.
|
* Règle H : aucune logique métier — tout est dans useSimulation() et les entités.
|
||||||
|
|
@ -60,12 +61,18 @@ export function SimulationPage() {
|
||||||
reset,
|
reset,
|
||||||
} = useSimulation()
|
} = useSimulation()
|
||||||
|
|
||||||
// Catalogue des sujets pour le dropdown dans SujetDisplay.
|
// Catalogue passé à SimulationForm (dropdown hérité — refacto étape 3).
|
||||||
// EO_T1 n'a pas de catalogue (getSujets retourne [] — requête court-circuitée côté API).
|
const { data: sujets, isLoading: isLoadingSujets } = useSujets(
|
||||||
const {
|
production?.tache ?? 'EE_T1',
|
||||||
data: sujets,
|
!!production,
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (step === 'done' && production) {
|
if (step === 'done' && production) {
|
||||||
|
|
@ -98,7 +105,7 @@ export function SimulationPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{planData &&
|
{planData &&
|
||||||
(step === 'choosing-subject' || step === 'task-selected' || step === 'correcting') &&
|
(step === 'task-selected' || step === 'correcting') &&
|
||||||
production && (
|
production && (
|
||||||
<SimulationForm
|
<SimulationForm
|
||||||
tache={production.tache}
|
tache={production.tache}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue