feat(simulations): choix du sujet — dropdown intégré + bouton aléatoire

This commit is contained in:
Hermann_Kitio 2026-04-21 02:06:08 +03:00
parent 477477b6a6
commit 7902eec042
5 changed files with 193 additions and 29 deletions

View file

@ -49,13 +49,26 @@ function mapCorrectError(err: ApiError | null): string | null {
interface Props { interface Props {
tache: Tache tache: Tache
sujet: SujetData | null sujet: SujetData | null
sujets: SujetData[]
isLoadingSujets: boolean
isSubmitting: boolean isSubmitting: boolean
error: ApiError | null error: ApiError | null
onSubmit: (texte: string) => void onSubmit: (texte: string) => void
onBack: () => 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<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const hasAutoSubmittedRef = useRef(false) const hasAutoSubmittedRef = useRef(false)
const [texte, setTexte] = useState('') const [texte, setTexte] = useState('')
@ -137,7 +150,13 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
<h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2> <h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
</div> </div>
<SujetDisplay sujet={sujet} /> <SujetDisplay
sujet={sujet}
sujets={sujets}
isLoadingSujets={isLoadingSujets}
onChangeSujet={onChangeSujet}
disabled={isSubmitting}
/>
{apiError && ( {apiError && (
<div <div

View file

@ -1,22 +1,33 @@
/** /**
* Affichage du sujet d'examen (consigne + documents) au-dessus de la zone de saisie. * Affichage du sujet d'examen (consigne + documents) avec sélecteur intégré.
* *
* Prop `sujet` vient du hook `useSimulation` (alimenté par `POST /simulations`). * - `sujet` : le sujet actuellement affiché (null = rien à rendre)
* Si `sujet === null` (aucun sujet actif pour la tâche, ou EO_T2_LIVE) rien n'est rendu. * - `sujets` : catalogue complet pour le dropdown + le tirage aléatoire
* - `onChangeSujet` : appelé avec le nouveau sujet choisi (dropdown ou random)
* *
* Règle H : composant purement présentationnel, aucune logique métier. * Règle H : purement présentationnel la liste et le callback viennent du parent.
* Règle L : tokens Direction H exclusivement (canvas, surface, ink-*, line, expria). * Règle L : tokens Direction H exclusivement (canvas, surface, ink-*, line, expria).
* *
* Rendu plain-text avec `whitespace-pre-wrap` pour préserver les sauts de ligne. * Le contenu des sujets est admin-curé (pas du texte IA) plain-text avec
* Les sujets étant du contenu admin-curé (pas du texte IA), pas de react-markdown. * `whitespace-pre-wrap`, pas de react-markdown.
*/ */
import { Shuffle } from 'lucide-react'
import { Badge } from '@/shared/ui/Badge' import { Badge } from '@/shared/ui/Badge'
import { Card } from '@/shared/ui/Card' import { Card } from '@/shared/ui/Card'
import type { SujetData } from '@/entities/production/types' import type { SujetData } from '@/entities/production/types'
interface Props { interface Props {
sujet: SujetData | null sujet: SujetData | null
sujets: SujetData[]
isLoadingSujets: boolean
onChangeSujet: (sujet: SujetData) => 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 }) { 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 if (!sujet) return null
const hasCatalog = sujets.length > 0
const canRandomize = hasCatalog && sujets.length > 1
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
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 ( return (
<Card variant="default" className="p-5"> <Card variant="default" className="p-5">
<div className="space-y-4"> <div className="space-y-4">
{(hasCatalog || isLoadingSujets) && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label htmlFor="sujet-select" className="text-xs font-semibold uppercase tracking-wide text-ink-4">
Sujet
</label>
<select
id="sujet-select"
value={sujet.id}
onChange={handleSelectChange}
disabled={disabled || isLoadingSujets || !hasCatalog}
className="min-w-0 flex-1 truncate rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-1 focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingSujets && !hasCatalog && (
<option value={sujet.id}>Chargement</option>
)}
{sujets.map((s) => (
<option key={s.id} value={s.id}>
{truncate(s.consigne.replace(/\s+/g, ' '), 80)}
</option>
))}
</select>
<button
type="button"
onClick={handleRandom}
disabled={disabled || isLoadingSujets || !canRandomize}
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Tirer un sujet aléatoire"
>
<Shuffle className="size-4" aria-hidden="true" />
Changer de sujet
</button>
</div>
)}
{sujet.role && ( {sujet.role && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="neutral">Rôle</Badge> <Badge variant="neutral">Rôle</Badge>

View file

@ -2,7 +2,8 @@
* Tests de la state machine useSimulation. * Tests de la state machine useSimulation.
* *
* Transitions couvertes : * 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é) * task-selected correcting (submitText déclenché)
* correcting done (correctEe success) * correcting done (correctEe success)
* correcting task-selected (correctEe error) * correcting task-selected (correctEe error)
@ -69,7 +70,7 @@ describe('useSimulation — état initial', () => {
}) })
describe('useSimulation — selectTask', () => { 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) mockCreateSimulation.mockResolvedValue(mockProduction)
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
@ -78,10 +79,24 @@ describe('useSimulation — selectTask', () => {
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }) 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) 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 () => { it('isCreating = true pendant la mutation createSimulation', async () => {
let resolveCreate!: (p: Production) => void let resolveCreate!: (p: Production) => void
mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r })) mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r }))
@ -109,6 +124,8 @@ describe('useSimulation — submitText', () => {
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
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'))
act(() => result.current.selectRandom([]))
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.'))
@ -126,6 +143,8 @@ describe('useSimulation — submitText', () => {
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
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'))
act(() => result.current.selectRandom([]))
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.')
@ -151,6 +170,8 @@ describe('useSimulation — reset', () => {
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
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'))
act(() => result.current.selectRandom([]))
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())

View file

@ -2,13 +2,15 @@
* Hook d'orchestration du flux simulation EE. * Hook d'orchestration du flux simulation EE.
* *
* Séquence : createSimulation (POST /simulations) * Séquence : createSimulation (POST /simulations)
* [choosing-subject] (sauf EO_T1 sujet fixe)
* correctEe (POST /corrections/ee, timeout 30 s) * correctEe (POST /corrections/ee, timeout 30 s)
* *
* State machine : * State machine :
* 'idle' sélection de tâche disponible * 'idle' sélection de tâche disponible
* 'task-selected' formulaire de saisie visible * 'choosing-subject' écran SujetSelector (hors EO_T1)
* 'correcting' correction en cours (30 s max) * 'task-selected' formulaire de saisie visible
* 'done' rapport disponible dans `report` * '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 * Règle H : aucune logique métier ici les gardes de quota et de plan
* sont dans TaskSelector (UX) et dans le backend (autorité). * sont dans TaskSelector (UX) et dans le backend (autorité).
@ -18,11 +20,24 @@ import { useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
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 { 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 { Report } from '@/entities/report/types'
import type { ApiError } from '@/shared/types/api' 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() { export function useSimulation() {
const [step, setStep] = useState<SimulationStep>('idle') const [step, setStep] = useState<SimulationStep>('idle')
@ -32,7 +47,7 @@ export function useSimulation() {
mutationFn: createSimulation, mutationFn: createSimulation,
onSuccess: (data) => { onSuccess: (data) => {
setProduction(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 }) 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 { function reset(): void {
setStep('idle') setStep('idle')
setProduction(null) setProduction(null)
@ -70,6 +110,10 @@ export function useSimulation() {
correctError: correctMutation.error as ApiError | null, correctError: correctMutation.error as ApiError | null,
selectTask, selectTask,
submitText, submitText,
changeSubject,
selectSujet,
selectRandom,
backToSubject,
reset, reset,
} }
} }

View file

@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query'
import { getPlanStatus } from '@/entities/user/api' import { getPlanStatus } from '@/entities/user/api'
import { Button } from '@/shared/ui/Button' import { Button } from '@/shared/ui/Button'
import { useSimulation } from '../hooks/useSimulation' import { useSimulation } from '../hooks/useSimulation'
import { useSujets } from '../hooks/useSujets'
import { TaskSelector } from '../components/TaskSelector' import { TaskSelector } from '../components/TaskSelector'
import { SimulationForm } from '../components/SimulationForm' import { SimulationForm } from '../components/SimulationForm'
@ -55,9 +56,17 @@ export function SimulationPage() {
correctError, correctError,
selectTask, selectTask,
submitText, submitText,
changeSubject,
reset, reset,
} = useSimulation() } = 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(() => { useEffect(() => {
if (step === 'done' && production) { if (step === 'done' && production) {
navigate(`/rapport/${production.id}`) navigate(`/rapport/${production.id}`)
@ -88,16 +97,21 @@ export function SimulationPage() {
/> />
)} )}
{planData && (step === 'task-selected' || step === 'correcting') && production && ( {planData &&
<SimulationForm (step === 'choosing-subject' || step === 'task-selected' || step === 'correcting') &&
tache={production.tache} production && (
sujet={sujet} <SimulationForm
isSubmitting={isCorrecting} tache={production.tache}
error={correctError} sujet={sujet}
onSubmit={submitText} sujets={sujets ?? []}
onBack={reset} isLoadingSujets={isLoadingSujets}
/> isSubmitting={isCorrecting}
)} error={correctError}
onSubmit={submitText}
onBack={reset}
onChangeSujet={changeSubject}
/>
)}
</main> </main>
) )
} }