feat(rapport/eo): support 5 critères × /4 — Phonologie (Sprint 4.8 frontend)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 03:30:31 +03:00
parent 3ce91aaa7b
commit 04019f8348
11 changed files with 273 additions and 10 deletions

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { getMaxScorePerCritere, critereCodeFromNom } from '../lib'
import type { Critere } from '../types'
function critere(nom: string, score: number): Critere {
return { nom, score, commentaire: '', exemple: '', suggestion: '', astuce: '' }
}
describe('getMaxScorePerCritere — Sprint 4.8', () => {
it('5 critères (EO Sprint 4.8) → maxScore = 4', () => {
const rapport = {
criteres: [
critere('Adéquation à la tâche', 3),
critere('Cohérence et cohésion', 3),
critere('Étendue et maîtrise du lexique', 2),
critere('Maîtrise morphosyntaxique', 3),
critere('Phonologie', 3),
],
}
expect(getMaxScorePerCritere(rapport)).toBe(4)
})
it('4 critères (EE / EO legacy) → maxScore = 5', () => {
const rapport = {
criteres: [
critere('Adéquation à la tâche et au registre', 4),
critere('Cohérence et cohésion du discours', 3),
critere('Compétence lexicale', 3),
critere('Compétence grammaticale', 4),
],
}
expect(getMaxScorePerCritere(rapport)).toBe(5)
})
it('0 critère (cas limite) → maxScore = 5 (défaut sécurité)', () => {
expect(getMaxScorePerCritere({ criteres: [] })).toBe(5)
})
it('6+ critères (cas hypothétique) → maxScore = 5 (défaut sécurité)', () => {
const rapport = {
criteres: Array.from({ length: 6 }, (_, i) => critere(`c${i}`, 0)),
}
expect(getMaxScorePerCritere(rapport)).toBe(5)
})
})
describe('critereCodeFromNom — extension Sprint 4.8 EO', () => {
it('mappe les libellés EO Sprint 4.8 vers les codes taxonomie', () => {
expect(critereCodeFromNom('Adéquation à la tâche')).toBe('adequation_tache')
expect(critereCodeFromNom('Cohérence et cohésion')).toBe('coherence_cohesion')
expect(critereCodeFromNom('Étendue et maîtrise du lexique')).toBe('competence_lexicale')
expect(critereCodeFromNom('Maîtrise morphosyntaxique')).toBe('competence_grammaticale')
})
it("Phonologie n'a pas de code taxonomie → null", () => {
expect(critereCodeFromNom('Phonologie')).toBeNull()
})
it('libellés EE legacy toujours mappés (rétrocompat)', () => {
expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
})
})

View file

@ -16,7 +16,7 @@
import { hasAccess } from '@/entities/user/lib'
import type { Plan } from '@/entities/user/lib'
import type { BlurableSection, Critere, ErreurCode, CritereCode } from './types'
import type { BlurableSection, Critere, ErreurCode, CritereCode, Report } from './types'
const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
criteres: 'detailed_report',
@ -61,16 +61,40 @@ export function groupErreursByCritere(
* pour rattacher `erreurs_codes` à la bonne carte critère côté UI.
*/
const CRITERE_NOM_TO_CODE: Record<string, CritereCode> = {
// Libellés EE (CRITERE_LABELS backend)
'Adéquation à la tâche et au registre': 'adequation_tache',
'Cohérence et cohésion du discours': 'coherence_cohesion',
'Compétence lexicale': 'competence_lexicale',
'Compétence grammaticale': 'competence_grammaticale',
// Libellés EO Sprint 4.8 (CRITERE_LABELS_EO backend) — mappés vers les
// mêmes codes taxonomie pour rattacher les `erreurs_codes`. La 5e dimension
// « Phonologie » n'a pas de CritereCode (aucune erreur taxonomie associée
// côté backend) : `critereCodeFromNom('Phonologie')` retourne donc `null`.
'Adéquation à la tâche': 'adequation_tache',
'Cohérence et cohésion': 'coherence_cohesion',
'Étendue et maîtrise du lexique': 'competence_lexicale',
'Maîtrise morphosyntaxique': 'competence_grammaticale',
}
export function critereCodeFromNom(nom: string): CritereCode | null {
return CRITERE_NOM_TO_CODE[nom] ?? null
}
/**
* Sprint 4.8 Détecte le score maximum par critère selon le format du rapport.
*
* - Rapports EO Sprint 4.8 : 5 critères × /4 (4 textuels DeepSeek + Phonologie Gemini).
* - Rapports EE et EO legacy : 4 critères × /5.
*
* Détection sur la donnée elle-même (pas sur la tâche) pour rester rétrocompatible
* avec les rapports EO en base d'avant Sprint 4.8.
*
* Défaut sécurité : tout autre nombre de critères 5.
*/
export function getMaxScorePerCritere(rapport: Pick<Report, 'criteres'>): 4 | 5 {
return rapport.criteres.length === 5 ? 4 : 5
}
/**
* Calcule l'écart en points /20 entre le score obtenu et l'objectif NCLC cible.
* Barème TCF Canada (cf. Prompt_maître.md §Barème) : NCLC 9 14/20, NCLC 10 16/20.

View file

@ -28,7 +28,7 @@ export interface ErreurCode {
export interface Critere {
nom: string
score: number // 0-5
score: number // 0-4 (EO Sprint 4.8 — 5 critères) | 0-5 (EE, EO legacy 4 critères)
commentaire: string
exemple: string
suggestion: string

View file

@ -19,15 +19,17 @@ import type { Critere, ErreurCode } from '@/entities/report/types'
interface Props {
critere: Critere
erreursCodes: ErreurCode[]
/** Sprint 4.8 — échelle par critère : 4 (EO 5 critères) ou 5 (EE / EO legacy 4 critères). */
maxScore?: 4 | 5
}
export function CritereCard({ critere, erreursCodes }: Props) {
export function CritereCard({ critere, erreursCodes, maxScore = 5 }: 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-primary">{critere.nom}</h3>
<Badge variant="nclc" className="shrink-0 tabular-nums">
{critere.score}/5
{critere.score}/{maxScore}
</Badge>
</div>

View file

@ -75,10 +75,12 @@ class FakeMediaRecorder {
}
static isTypeSupported(_t: string): boolean {
void _t
return true
}
start(_timeslice?: number) {
void _timeslice
this.state = 'recording'
}

View file

@ -149,7 +149,6 @@ export function useDeepgramLive(): UseDeepgramLiveResult {
// Conserver l'ancienne connexion ; nouvelle tentative à la prochaine échéance.
// FTD-XX : durcir avec une retry policy plus fine en Sprint 4c-2.
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openConnection])
const connect = useCallback(async () => {

View file

@ -20,7 +20,12 @@ import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Lock } from 'lucide-react'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { isSectionVisible, groupErreursByCritere, critereCodeFromNom } from '@/entities/report/lib'
import {
isSectionVisible,
groupErreursByCritere,
critereCodeFromNom,
getMaxScorePerCritere,
} from '@/entities/report/lib'
import type { Report } from '@/entities/report/types'
import { useRapport } from '../hooks/useRapport'
import { useSimulation } from '../hooks/useSimulation'
@ -93,6 +98,7 @@ function RapportSkeleton() {
function CriteresSection({ rapport }: { rapport: Report }) {
const grouped = groupErreursByCritere(rapport.erreurs_codes)
const maxScore = getMaxScorePerCritere(rapport)
return (
<section aria-label="Détail par critère">
@ -101,7 +107,7 @@ function CriteresSection({ rapport }: { rapport: Report }) {
{rapport.criteres.map((c) => {
const code = critereCodeFromNom(c.nom)
const codes = code ? grouped[code] : []
return <CritereCard key={c.nom} critere={c} erreursCodes={codes} />
return <CritereCard key={c.nom} critere={c} erreursCodes={codes} maxScore={maxScore} />
})}
</div>
</section>

View file

@ -7,7 +7,6 @@
* - onSuccess : setPresentationT1 + navigate vers /simulation/eo/t1/presentation
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

View file

@ -1,5 +1,5 @@
@import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@import 'tailwindcss';
/* Dark = défaut. `.light` sur <html> active le mode clair (override sur --color-*). */
@custom-variant light (&:where(.light, .light *));