From 7902eec042d92796117c4fc71f1e3e6921a77c69 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Tue, 21 Apr 2026 02:06:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(simulations):=20choix=20du=20sujet=20?= =?UTF-8?q?=E2=80=94=20dropdown=20int=C3=A9gr=C3=A9=20+=20bouton=20al?= =?UTF-8?q?=C3=A9atoire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../simulations/components/SimulationForm.tsx | 23 +++++- .../simulations/components/SujetDisplay.tsx | 80 +++++++++++++++++-- .../hooks/__tests__/useSimulation.test.tsx | 27 ++++++- .../simulations/hooks/useSimulation.ts | 58 ++++++++++++-- .../simulations/pages/SimulationPage.tsx | 34 +++++--- 5 files changed, 193 insertions(+), 29 deletions(-) diff --git a/src/features/simulations/components/SimulationForm.tsx b/src/features/simulations/components/SimulationForm.tsx index 1be8b3c..b2d0c07 100644 --- a/src/features/simulations/components/SimulationForm.tsx +++ b/src/features/simulations/components/SimulationForm.tsx @@ -49,13 +49,26 @@ function mapCorrectError(err: ApiError | null): string | null { interface Props { tache: Tache sujet: SujetData | null + sujets: SujetData[] + isLoadingSujets: boolean isSubmitting: boolean error: ApiError | null onSubmit: (texte: string) => void onBack: () => void + onChangeSujet: (sujet: SujetData) => void } -export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, onBack }: Props) { +export function SimulationForm({ + tache, + sujet, + sujets, + isLoadingSujets, + isSubmitting, + error, + onSubmit, + onBack, + onChangeSujet, +}: Props) { const textareaRef = useRef(null) const hasAutoSubmittedRef = useRef(false) const [texte, setTexte] = useState('') @@ -137,7 +150,13 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on

{formatTache(tache)}

- + {apiError && (
void + disabled?: boolean +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s + return `${s.slice(0, max).trimEnd()}…` } function DocumentBlock({ titre, texte }: { titre: string | null; texte: string | null }) { @@ -31,12 +42,67 @@ function DocumentBlock({ titre, texte }: { titre: string | null; texte: string | ) } -export function SujetDisplay({ sujet }: Props) { +export function SujetDisplay({ + sujet, + sujets, + isLoadingSujets, + onChangeSujet, + disabled = false, +}: Props) { if (!sujet) return null + const hasCatalog = sujets.length > 0 + const canRandomize = hasCatalog && sujets.length > 1 + + function handleSelectChange(e: React.ChangeEvent) { + const next = sujets.find((s) => s.id === e.target.value) + if (next && next.id !== sujet?.id) onChangeSujet(next) + } + + function handleRandom() { + if (sujets.length === 0) return + const others = sujets.length > 1 ? sujets.filter((s) => s.id !== sujet?.id) : sujets + const pick = others[Math.floor(Math.random() * others.length)] + if (pick) onChangeSujet(pick) + } + return (
+ {(hasCatalog || isLoadingSujets) && ( +
+ + + +
+ )} + {sujet.role && (
Rôle diff --git a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx index a30bf43..60362b9 100644 --- a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx +++ b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx @@ -2,7 +2,8 @@ * Tests de la state machine useSimulation. * * Transitions couvertes : - * idle → task-selected (selectTask success) + * idle → choosing-subject (selectTask success, tâche avec catalogue) + * choosing-subject → task-selected (selectRandom / selectSujet) * task-selected → correcting (submitText déclenché) * correcting → done (correctEe success) * correcting → task-selected (correctEe error) @@ -69,7 +70,7 @@ describe('useSimulation — état initial', () => { }) describe('useSimulation — selectTask', () => { - it('step passe à task-selected et production est hydratée après succès', async () => { + it('step passe à choosing-subject et production est hydratée pour une tâche avec catalogue', async () => { mockCreateSimulation.mockResolvedValue(mockProduction) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) @@ -78,10 +79,24 @@ describe('useSimulation — selectTask', () => { result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }) }) - await waitFor(() => expect(result.current.step).toBe('task-selected')) + await waitFor(() => expect(result.current.step).toBe('choosing-subject')) expect(result.current.production).toEqual(mockProduction) }) + it('step passe directement à task-selected pour EO_T1 (sans catalogue)', async () => { + const eoProduction: Production = { ...mockProduction, tache: 'EO_T1' } + mockCreateSimulation.mockResolvedValue(eoProduction) + + const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) + + act(() => { + result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' }) + }) + + await waitFor(() => expect(result.current.step).toBe('task-selected')) + expect(result.current.production).toEqual(eoProduction) + }) + it('isCreating = true pendant la mutation createSimulation', async () => { let resolveCreate!: (p: Production) => void mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r })) @@ -109,6 +124,8 @@ describe('useSimulation — submitText', () => { const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })) + await waitFor(() => expect(result.current.step).toBe('choosing-subject')) + act(() => result.current.selectRandom([])) await waitFor(() => expect(result.current.step).toBe('task-selected')) act(() => result.current.submitText('Mon texte de production.')) @@ -126,6 +143,8 @@ describe('useSimulation — submitText', () => { const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })) + await waitFor(() => expect(result.current.step).toBe('choosing-subject')) + act(() => result.current.selectRandom([])) await waitFor(() => expect(result.current.step).toBe('task-selected')) act(() => result.current.submitText('Mon texte.') @@ -151,6 +170,8 @@ describe('useSimulation — reset', () => { const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })) + await waitFor(() => expect(result.current.step).toBe('choosing-subject')) + act(() => result.current.selectRandom([])) 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 9903990..32eb11d 100644 --- a/src/features/simulations/hooks/useSimulation.ts +++ b/src/features/simulations/hooks/useSimulation.ts @@ -2,13 +2,15 @@ * Hook d'orchestration du flux simulation EE. * * Séquence : createSimulation (POST /simulations) + * → [choosing-subject] (sauf EO_T1 — sujet fixe) * → 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` + * '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é). @@ -18,11 +20,24 @@ 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 { + 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' | 'task-selected' | 'correcting' | 'done' +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() { const [step, setStep] = useState('idle') @@ -32,7 +47,7 @@ export function useSimulation() { mutationFn: createSimulation, onSuccess: (data) => { setProduction(data) - setStep('task-selected') + setStep(TACHES_SANS_CATALOGUE.includes(data.tache) ? 'task-selected' : 'choosing-subject') }, }) @@ -52,6 +67,31 @@ export function useSimulation() { 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 { + changeSubject(sujet) + setStep('task-selected') + } + + /** 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) @@ -70,6 +110,10 @@ export function useSimulation() { correctError: correctMutation.error as ApiError | null, selectTask, submitText, + changeSubject, + selectSujet, + selectRandom, + backToSubject, reset, } } diff --git a/src/features/simulations/pages/SimulationPage.tsx b/src/features/simulations/pages/SimulationPage.tsx index 40a2b76..766fee7 100644 --- a/src/features/simulations/pages/SimulationPage.tsx +++ b/src/features/simulations/pages/SimulationPage.tsx @@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query' import { getPlanStatus } from '@/entities/user/api' import { Button } from '@/shared/ui/Button' import { useSimulation } from '../hooks/useSimulation' +import { useSujets } from '../hooks/useSujets' import { TaskSelector } from '../components/TaskSelector' import { SimulationForm } from '../components/SimulationForm' @@ -55,9 +56,17 @@ export function SimulationPage() { correctError, selectTask, submitText, + changeSubject, 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) + useEffect(() => { if (step === 'done' && production) { navigate(`/rapport/${production.id}`) @@ -88,16 +97,21 @@ export function SimulationPage() { /> )} - {planData && (step === 'task-selected' || step === 'correcting') && production && ( - - )} + {planData && + (step === 'choosing-subject' || step === 'task-selected' || step === 'correcting') && + production && ( + + )} ) }