feat(rapport): Sprint 3.6b — RapportPage enrichie, exercices dynamiques, production modèle, sélecteur NCLC

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 20:14:38 +03:00
parent 8390e8b873
commit f51caa1b75
22 changed files with 1357 additions and 297 deletions

View file

@ -0,0 +1,69 @@
/**
* Sélecteur de niveau NCLC cible Sprint 3.6b.
*
* Segmented control à 2 valeurs : NCLC 9 ou NCLC 10.
* La valeur est envoyée dans le payload `POST /corrections/ee` (champ `nclc_cible`).
* Backend par défaut : 9 (cf. expria-backend `corrections.ts`).
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel l'état vit chez le parent (SimulationForm).
*/
import { cn } from '@/shared/lib/utils'
import type { NclcCible } from '@/entities/report/types'
interface Props {
value: NclcCible
onChange: (next: NclcCible) => void
disabled?: boolean
}
const OPTIONS: { value: NclcCible; label: string; hint: string }[] = [
{ value: 9, label: 'NCLC 9', hint: 'Visa — 14/20 minimum' },
{ value: 10, label: 'NCLC 10', hint: 'Excellence — 16/20 minimum' },
]
export function NclcCibleSelector({ value, onChange, disabled = false }: Props) {
return (
<fieldset
className="space-y-2"
aria-label="Niveau NCLC cible pour la correction"
disabled={disabled}
>
<legend className="text-sm font-medium text-ink-2">Objectif de correction</legend>
<div
role="radiogroup"
aria-label="Niveau NCLC cible"
className="inline-flex overflow-hidden rounded-md border border-line bg-surface"
>
{OPTIONS.map((opt) => {
const active = opt.value === value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
disabled={disabled}
onClick={() => onChange(opt.value)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
'focus-visible:outline-none focus-visible:shadow-focus',
'disabled:cursor-not-allowed disabled:opacity-50',
active
? 'bg-expria text-white'
: 'bg-surface text-ink-2 hover:bg-canvas-2 hover:text-ink-1',
)}
title={opt.hint}
>
{opt.label}
</button>
)
})}
</div>
<p className="text-xs text-ink-4">
{OPTIONS.find((o) => o.value === value)?.hint}
</p>
</fieldset>
)
}

View file

@ -17,6 +17,7 @@ import { Button } from '@/shared/ui/Button'
import { formatTache } from '@/entities/production/lib'
import { hasAccess, type Plan } from '@/entities/user/lib'
import type { SujetData, Tache } from '@/entities/production/types'
import type { NclcCible } from '@/entities/report/types'
import type { ApiError } from '@/shared/types/api'
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
import { useTimer } from '../hooks/useTimer'
@ -28,6 +29,7 @@ import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
import { TimerDisplay } from './TimerDisplay'
import { WordCountBar } from './WordCountBar'
import { IdeesSuggestions } from './IdeesSuggestions'
import { NclcCibleSelector } from './NclcCibleSelector'
const MIN_WORDS_IDEES = 30
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
@ -66,7 +68,7 @@ interface Props {
step: SimulationStep
isSubmitting: boolean
error: ApiError | null
onSubmit: (texte: string) => void
onSubmit: (texte: string, nclcCible: NclcCible) => void
onBack: () => void
onChangeSujet: () => void
}
@ -89,6 +91,7 @@ export function SimulationForm({
const [texte, setTexte] = useState(() => initialContenu ?? '')
const [fieldError, setFieldError] = useState<string | null>(null)
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
const [nclcCible, setNclcCible] = useState<NclcCible>(9)
const config = getSimulationConfig(tache)
const wordCount = countWords(texte)
@ -150,8 +153,8 @@ export function SimulationForm({
if (wordCount < config.motsMin) return
hasAutoSubmittedRef.current = true
onSubmit(texte)
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, onSubmit])
onSubmit(texte, nclcCible)
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit])
function handleInsert(char: string) {
const el = textareaRef.current
@ -185,7 +188,7 @@ export function SimulationForm({
return
}
onSubmit(parsed.data.texte)
onSubmit(parsed.data.texte, nclcCible)
}
const apiError = mapCorrectError(error)
@ -308,6 +311,12 @@ export function SimulationForm({
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
/>
<WordCountBar count={wordCount} config={config} />
<NclcCibleSelector
value={nclcCible}
onChange={setNclcCible}
disabled={isSubmitting}
/>
{autosave.savedAt && !fieldError && (
<p className="text-xs text-ink-4" aria-live="polite">
Sauvegardé à{' '}

View file

@ -0,0 +1,46 @@
/**
* ConseilNclcCallout Sprint 3.6b.
*
* Section "Plan d'action NCLC" : écart au NCLC cible + action prioritaire.
* Visible pour tous les plans.
*
* Règle L : tokens Direction H exclusivement.
*/
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import type { ConseilNclc } from '@/entities/report/types'
interface Props {
conseil: ConseilNclc
}
export function ConseilNclcCallout({ conseil }: Props) {
return (
<section aria-label="Plan d'action NCLC">
<h2 className="mb-3 text-base font-semibold text-ink-1">Plan d'action NCLC</h2>
<Card variant="raised" className="space-y-3 p-4">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Objectif
</p>
<p className="text-sm font-semibold text-ink-1">{conseil.nclc_cible}</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Écart
</p>
<p className="text-sm text-ink-2">{conseil.ecart}</p>
</div>
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-expria">
Action prioritaire
</p>
<div className="text-sm leading-relaxed text-ink-1">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{conseil.action_prioritaire}
</ReactMarkdown>
</div>
</div>
</Card>
</section>
)
}

View file

@ -0,0 +1,80 @@
/**
* CritereCard Sprint 3.6b.
*
* Carte critère enrichie : nom, score /5, commentaire, exemple, suggestion,
* astuce + badges des codes d'erreurs taxonomie correspondants.
*
* Visible pour Standard et Premium (gate `detailed_report`). Le floutage est
* géré par le parent via BlurredSection CritereCard ne connaît pas le plan.
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel aucune logique plan ici.
*/
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import type { Critere, ErreurCode } from '@/entities/report/types'
interface Props {
critere: Critere
erreursCodes: ErreurCode[]
}
export function CritereCard({ critere, erreursCodes }: Props) {
return (
<Card variant="default" className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-semibold text-ink-1">{critere.nom}</h3>
<Badge variant="nclc" className="shrink-0 tabular-nums">
{critere.score}/5
</Badge>
</div>
{critere.commentaire && (
<div className="text-sm leading-relaxed text-ink-2">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{critere.commentaire}
</ReactMarkdown>
</div>
)}
{critere.exemple && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Exemple tiré de votre texte
</p>
<p className="italic text-sm leading-relaxed text-ink-2">
« {critere.exemple} »
</p>
</div>
)}
{critere.suggestion && (
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-expria">
Reformulation suggérée
</p>
<p className="text-sm leading-relaxed text-ink-1">{critere.suggestion}</p>
</div>
)}
{critere.astuce && (
<div className="flex gap-2 text-sm text-ink-3">
<span className="shrink-0 text-expria" aria-hidden="true">💡</span>
<span>{critere.astuce}</span>
</div>
)}
{erreursCodes.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-line pt-3">
{erreursCodes.map((e) => (
<Badge key={`${e.code}-${e.description ?? ''}`} variant="neutral">
{e.description ?? e.code.replace(/_/g, ' ')}
</Badge>
))}
</div>
)}
</Card>
)
}

View file

@ -0,0 +1,32 @@
/**
* DiagnosticCallout Sprint 3.6b.
*
* Section "Ce qui freine votre progression" phrase courte identifiant
* le frein principal. Visible pour tous les plans.
*
* Règle L : tokens Direction H exclusivement.
*/
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
interface Props {
diagnostic: string
}
export function DiagnosticCallout({ diagnostic }: Props) {
return (
<section aria-label="Frein principal">
<h2 className="mb-3 text-base font-semibold text-ink-1">
Ce qui freine votre progression
</h2>
<Card variant="default" className="border-l-4 border-l-expria p-4">
<div className="text-sm leading-relaxed text-ink-1">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{diagnostic}
</ReactMarkdown>
</div>
</Card>
</section>
)
}

View file

@ -0,0 +1,143 @@
/**
* ExerciceInteractive Sprint 3.6b.
*
* Carte d'exercice avec interactions :
* - Badge de difficulté + thème + diagnostic
* - Consigne + extrait candidat
* - Zone de texte libre (tentative du candidat)
* - Bouton "Indice" révèle une piste (fond jaune), une seule fois
* - Bouton "Voir la correction" activé dès qu'une saisie est présente
* révèle correction (fond vert) + explication
* - Message "Comparez avec votre réponse" une fois la correction révélée
*
* Règle H : aucune logique métier la correction ne calcule rien, elle
* révèle seulement ce que DeepSeek a produit.
* Règle L : tokens Direction H exclusivement.
*/
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { Button } from '@/shared/ui/Button'
import { DIFFICULTE_LABEL, type Exercice } from '@/entities/report/types'
interface Props {
exercice: Exercice
}
export function ExerciceInteractive({ exercice }: Props) {
const [tentative, setTentative] = useState('')
const [indiceRevealed, setIndiceRevealed] = useState(false)
const [correctionRevealed, setCorrectionRevealed] = useState(false)
const canRevealCorrection = tentative.trim().length > 0
return (
<Card variant="default" className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="nclc">{DIFFICULTE_LABEL[exercice.difficulte]}</Badge>
{exercice.theme && (
<span className="text-xs font-medium text-ink-4">
{exercice.theme.replace(/_/g, ' ')}
</span>
)}
</div>
</div>
{exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-3">{exercice.diagnostic}</p>
)}
{exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Consigne
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.consigne}</p>
</div>
)}
{exercice.extrait && (
<div className="space-y-1.5 rounded-md border border-line bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Extrait à retravailler
</p>
<p className="italic text-sm leading-relaxed text-ink-2">« {exercice.extrait} »</p>
</div>
)}
<label className="block space-y-1.5">
<span className="text-sm font-medium text-ink-2">Votre réponse</span>
<textarea
rows={3}
value={tentative}
onChange={(e) => setTentative(e.target.value)}
placeholder="Écrivez votre tentative ici…"
className="w-full resize-none rounded-md border border-line bg-surface p-2 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus"
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={indiceRevealed || !exercice.indice}
onClick={() => setIndiceRevealed(true)}
>
{indiceRevealed ? 'Indice révélé' : 'Indice'}
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!canRevealCorrection || correctionRevealed}
onClick={() => setCorrectionRevealed(true)}
title={!canRevealCorrection ? 'Écrivez d\'abord votre tentative' : undefined}
>
Voir la correction
</Button>
</div>
{indiceRevealed && exercice.indice && (
<div
className="space-y-1 rounded-md border border-warning/30 bg-warning-bg p-3"
aria-live="polite"
>
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
Indice
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.indice}</p>
</div>
)}
{correctionRevealed && (
<div className="space-y-3" aria-live="polite">
<div className="space-y-1 rounded-md border border-success/30 bg-success-bg p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correction attendue
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.correction}</p>
</div>
{exercice.explication && (
<div className="space-y-1 rounded-md border border-line bg-canvas-2 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Explication
</p>
<div className="text-sm leading-relaxed text-ink-2">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{exercice.explication}
</ReactMarkdown>
</div>
</div>
)}
<p className="text-xs text-ink-4">
Comparez avec votre réponse ci-dessus pour repérer les différences.
</p>
</div>
)}
</Card>
)
}

View file

@ -0,0 +1,48 @@
/**
* JobStatusFallback Sprint 3.6b.
*
* Affiche un fallback visuel pour les sections générées en asynchrone par le
* backend (exercices, production modèle) :
* - 'pending' "Génération en cours…" avec spinner (refresh manuel côté user)
* - 'error' "Indisponible pour le moment"
*
* FTD-24 tracera le polling automatique (laissé pour une session ultérieure).
*
* Règle L : tokens Direction H exclusivement.
*/
import { Loader2 } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import type { JobStatus } from '@/entities/report/types'
interface Props {
status: JobStatus
pendingLabel?: string
errorLabel?: string
}
export function JobStatusFallback({
status,
pendingLabel = 'Génération en cours…',
errorLabel = 'Indisponible pour le moment.',
}: Props) {
if (status === 'pending') {
return (
<Card variant="default" className="flex items-center gap-3 p-4">
<Loader2 className="size-4 animate-spin text-ink-4" aria-hidden="true" />
<p className="text-sm text-ink-3" aria-live="polite">
{pendingLabel}{' '}
<span className="text-ink-4">Rafraîchissez la page dans quelques instants.</span>
</p>
</Card>
)
}
return (
<Card variant="default" className="border-l-4 border-l-warning p-4">
<p className="text-sm text-warning" role="alert">
{errorLabel}
</p>
</Card>
)
}

View file

@ -0,0 +1,103 @@
/**
* ProductionModeleSection Sprint 3.6b.
*
* Affiche la production modèle NCLC 9 générée par DeepSeek :
* - Texte final prêt pour l'examen
* - 3 passages commentés (notes_pedagogiques)
* - Transformations : original amélioré explication
* - Bandeau message encourageant
*
* Gate `tips` (Standard+). Le floutage est géré par le parent via BlurredSection.
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel.
*/
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import type { ProductionModele } from '@/entities/report/types'
interface Props {
modele: ProductionModele
}
export function ProductionModeleSection({ modele }: Props) {
return (
<div className="space-y-4">
<Card variant="raised" className="space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Version restructurée NCLC 9+
</p>
<Badge variant="nclc">
{modele.tcf_word_count ?? ''} mots
</Badge>
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-1">
{modele.production_modele_propre}
</p>
{modele.tcf_truncated && (
<p className="text-xs text-warning">
Texte tronqué au maximum autorisé pour la tâche ({modele.tcf_word_max} mots).
</p>
)}
</Card>
{modele.notes_pedagogiques.length > 0 && (
<Card variant="default" className="space-y-3 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Passages clés
</p>
<ul className="space-y-3">
{modele.notes_pedagogiques.map((n, i) => (
<li key={i} className="space-y-1.5 border-l-2 border-expria pl-3">
<p className="italic text-sm leading-relaxed text-ink-2">« {n.passage} »</p>
<p className="text-xs text-ink-3">{n.explication}</p>
</li>
))}
</ul>
</Card>
)}
{modele.transformations.length > 0 && (
<Card variant="default" className="space-y-3 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Transformations appliquées
</p>
<ul className="space-y-4">
{modele.transformations.map((t, i) => (
<li key={i} className="space-y-2">
<div className="rounded-md border border-line bg-canvas-2 p-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-ink-5">
Original
</span>
<p className="text-sm text-ink-3 line-through decoration-danger decoration-1">
{t.original}
</p>
</div>
<div className="rounded-md border border-success/30 bg-success-bg p-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-success">
Amélioré
</span>
<p className="text-sm text-ink-1">{t.ameliore}</p>
</div>
<p className="text-xs text-ink-4">{t.explication}</p>
</li>
))}
</ul>
</Card>
)}
{modele.message && (
<Card variant="default" className="border-l-4 border-l-expria p-4">
<div className="text-sm leading-relaxed text-ink-1">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{modele.message}
</ReactMarkdown>
</div>
</Card>
)}
</div>
)
}

View file

@ -0,0 +1,51 @@
/**
* RevelationCards Sprint 3.6b.
*
* Section "Lecture du correcteur" 3 colonnes : ce que le candidat croit faire,
* ce que le correcteur observe, et l'impact sur la note.
*
* Visible pour tous les plans (cf. PLANS_TARIFAIRES.md).
* Règle L : tokens Direction H exclusivement.
*/
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import type { Revelation } from '@/entities/report/types'
interface Props {
revelation: Revelation
}
const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' | 'danger' }[] = [
{ key: 'croyance', titre: 'Ce que vous croyez', ton: 'ink' },
{ key: 'realite', titre: 'Ce qu\'observe le correcteur', ton: 'warning' },
{ key: 'consequence', titre: 'Conséquence sur la note', ton: 'danger' },
]
const TON_CLASS: Record<'ink' | 'warning' | 'danger', string> = {
ink: 'text-ink-2',
warning: 'text-warning',
danger: 'text-danger',
}
export function RevelationCards({ revelation }: Props) {
return (
<section aria-label="Lecture du correcteur">
<h2 className="mb-3 text-base font-semibold text-ink-1">Lecture du correcteur</h2>
<div className="grid gap-3 sm:grid-cols-3">
{SECTIONS.map(({ key, titre, ton }) => (
<Card key={key} variant="default" className="p-4">
<p className={`mb-2 text-[11px] font-semibold uppercase tracking-widest ${TON_CLASS[ton]}`}>
{titre}
</p>
<div className="text-sm leading-relaxed text-ink-2">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{revelation[key]}
</ReactMarkdown>
</div>
</Card>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,105 @@
/**
* ScoreHero Sprint 3.6b.
*
* Affiche score /20, jauge avec seuil NCLC cible marqué, badge NCLC atteint,
* et un encart d'écart "X points avant NCLC 9+" si objectif non atteint.
*
* Règle L : tokens Direction H exclusivement.
*/
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { ecartVsCible } from '@/entities/report/lib'
import type { NclcCible } from '@/entities/report/types'
interface Props {
score: number // /20
nclc: number // NCLC atteint
nclcCible: NclcCible
}
const NCLC_MIN_SCORE: Record<number, number> = { 9: 14, 10: 16 }
export function ScoreHero({ score, nclc, nclcCible }: Props) {
const { points, atteint } = ecartVsCible(score, nclcCible)
const seuilCible = NCLC_MIN_SCORE[nclcCible] ?? 14
const percent = Math.max(0, Math.min(100, (score / 20) * 100))
const seuilPercent = (seuilCible / 20) * 100
return (
<Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-end gap-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Score
</p>
<p className="mt-1 tabular-nums text-ink-1">
<span className="text-5xl font-bold">{score}</span>
<span className="text-2xl font-medium text-ink-4">/20</span>
</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Niveau atteint
</p>
<Badge variant="nclc" className="mt-2">
NCLC {nclc}
</Badge>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Objectif
</p>
<Badge variant="neutral" className="mt-2">
NCLC {nclcCible}
</Badge>
</div>
</div>
{/* Jauge avec marqueur NCLC cible */}
<div className="space-y-1.5">
<div
className="relative h-2 overflow-hidden rounded-full bg-canvas-2"
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={20}
aria-label={`Score ${score} sur 20`}
>
<div
className={`h-full transition-all duration-300 ${
atteint ? 'bg-success' : 'bg-expria'
}`}
style={{ width: `${percent}%` }}
/>
{/* Marqueur du seuil NCLC cible */}
<div
className="absolute top-0 h-full w-0.5 bg-ink-2"
style={{ left: `${seuilPercent}%` }}
aria-hidden="true"
title={`Seuil NCLC ${nclcCible} : ${seuilCible}/20`}
/>
</div>
<div className="flex justify-between text-xs text-ink-4 tabular-nums">
<span>0</span>
<span className="font-medium">Seuil NCLC {nclcCible} : {seuilCible}/20</span>
<span>20</span>
</div>
</div>
{/* Encart d'écart */}
{atteint ? (
<p className="rounded-md border border-success/30 bg-success-bg px-3 py-2 text-sm text-success">
Objectif NCLC {nclcCible} atteint.
</p>
) : (
<p className="rounded-md border border-warning/30 bg-warning-bg px-3 py-2 text-sm text-warning">
{points === 1
? '1 point avant NCLC '
: `${points} points avant NCLC `}
{nclcCible}+
</p>
)}
</Card>
)
}

View file

@ -0,0 +1,79 @@
/**
* Tests ExerciceInteractive (Sprint 3.6b).
*
* Couvre l'état interne : indice révélé une seule fois, bouton correction
* désactivé tant qu'aucune saisie, activé dès qu'une tentative existe.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
afterEach(cleanup)
import { ExerciceInteractive } from '../ExerciceInteractive'
import type { Exercice } from '@/entities/report/types'
const EXERCICE: Exercice = {
difficulte: 'facile',
theme: 'accord_sujet_verbe',
diagnostic: 'Les accords sujet-verbe sont fragiles.',
consigne: 'Corrigez les accords.',
extrait: 'les enfants joue',
indice: 'Pluriel du sujet ?',
correction: 'les enfants jouent',
explication: 'Le verbe s\'accorde en nombre avec le sujet.',
}
describe('ExerciceInteractive', () => {
it('affiche le badge de difficulté avec le libellé mappé', () => {
render(<ExerciceInteractive exercice={EXERCICE} />)
expect(screen.getByText('Facile')).toBeInTheDocument()
})
it('bouton "Voir la correction" désactivé tant que la zone de saisie est vide', () => {
render(<ExerciceInteractive exercice={EXERCICE} />)
const btn = screen.getByRole('button', { name: /voir la correction/i })
expect(btn).toBeDisabled()
})
it('bouton "Voir la correction" activé dès qu\'une saisie est présente', async () => {
const user = userEvent.setup()
render(<ExerciceInteractive exercice={EXERCICE} />)
await user.type(screen.getByPlaceholderText(/écrivez votre tentative/i), 'ma réponse')
expect(screen.getByRole('button', { name: /voir la correction/i })).toBeEnabled()
})
it('clic sur "Indice" révèle la piste une seule fois (bouton se désactive)', async () => {
const user = userEvent.setup()
render(<ExerciceInteractive exercice={EXERCICE} />)
const btn = screen.getByRole('button', { name: /^indice$/i })
expect(btn).toBeEnabled()
expect(screen.queryByText(EXERCICE.indice)).not.toBeInTheDocument()
await user.click(btn)
expect(screen.getByText(EXERCICE.indice)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /indice révélé/i })).toBeDisabled()
})
it('clic sur "Voir la correction" révèle correction + explication + message final', async () => {
const user = userEvent.setup()
render(<ExerciceInteractive exercice={EXERCICE} />)
await user.type(screen.getByPlaceholderText(/écrivez votre tentative/i), 'les enfants joue')
await user.click(screen.getByRole('button', { name: /voir la correction/i }))
expect(screen.getByText(EXERCICE.correction)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.explication)).toBeInTheDocument()
expect(screen.getByText(/comparez avec votre réponse/i)).toBeInTheDocument()
})
it('le bouton "Indice" reste disponible si aucun indice fourni par le backend', () => {
const sansIndice: Exercice = { ...EXERCICE, indice: '' }
render(<ExerciceInteractive exercice={sansIndice} />)
// Pas d'indice → bouton désactivé d'office (evite de révéler un vide)
expect(screen.getByRole('button', { name: /^indice$/i })).toBeDisabled()
})
})