fix(t2-live): exiger 30 mots de notes avant suggestions d'idées (parité EE)
handleIdees envoyait la consigne du sujet comme contenu_partiel, déclenchant l'anti-blanc sans effort du candidat et provoquant un 400 sur les sujets à consigne courte (< 30 mots). Envoie désormais les notes réelles du candidat et désactive le bouton sous 30 mots (tooltip), aligné sur le comportement EE. Le modal IdeesSuggestions reste le filet VALIDATION_ERROR. Test: T2PreparationPage.test.tsx (bouton désactivé < 30 mots ; fetchIdees reçoit les notes ≥ 30 mots). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b8eed80708
commit
9bf95f5c05
2 changed files with 110 additions and 7 deletions
|
|
@ -5,7 +5,9 @@
|
|||
* - Consigne + contexte du sujet affichés.
|
||||
* - Zone de notes locale (état interne, non sauvegardée).
|
||||
* - Bouton "Suggestions d'idées" → réutilise useIdees (POST /sujets/idees).
|
||||
* Pas de seuil MIN_WORDS : on passe la consigne du sujet comme contenu_partiel.
|
||||
* Parité EE (SimulationForm) : on passe les NOTES du candidat comme
|
||||
* contenu_partiel et le bouton reste désactivé tant que < 30 mots — l'aide
|
||||
* récompense l'effort (anti-blanc), elle ne se déclenche pas à vide.
|
||||
* - Bouton "Je suis prêt" → navigation vers /dialogue avant la fin du timer.
|
||||
* - Auto-navigation à 0:00.
|
||||
* - Pré-warm de la permission micro pour éviter le délai au début du dialogue.
|
||||
|
|
@ -18,9 +20,11 @@ import { Button } from '@/shared/ui/Button'
|
|||
import { Card } from '@/shared/ui/Card'
|
||||
import { useIdees } from '@/features/simulations/hooks/useIdees'
|
||||
import { IdeesSuggestions } from '@/features/simulations/components/IdeesSuggestions'
|
||||
import { countWords } from '@/features/simulations/lib/simulationConfig'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
|
||||
const PREPARATION_SECONDS = 120
|
||||
const MIN_WORDS_IDEES = 30
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60)
|
||||
|
|
@ -96,15 +100,20 @@ export function T2PreparationPage() {
|
|||
navigate('/simulation/eo/t2/dialogue')
|
||||
}
|
||||
|
||||
const wordCount = countWords(notes)
|
||||
const ideesDisabled = idees.isLoading || wordCount < MIN_WORDS_IDEES
|
||||
const ideesTitle =
|
||||
wordCount < MIN_WORDS_IDEES ? `Écrivez au moins ${MIN_WORDS_IDEES} mots` : undefined
|
||||
|
||||
function handleIdees() {
|
||||
if (!sujet) return
|
||||
// Pas de seuil MIN_WORDS pour T2 prép : on passe la consigne + contexte
|
||||
// comme contenu_partiel pour respecter la validation backend (≥ 30 mots).
|
||||
// Le mécanisme DeepSeek génère des idées de questions à poser.
|
||||
// Parité EE : contenu_partiel = NOTES du candidat (pas la consigne). Le seuil
|
||||
// ≥ 30 mots s'applique aux notes et gatekeepe le bouton ci-dessous, donc aucun
|
||||
// 400 ne part : le candidat doit avoir produit un minimum avant de demander
|
||||
// l'aide. Le mécanisme DeepSeek génère des idées de questions à poser.
|
||||
const consigne = sujet.consigne ?? 'Tâche 2 — interaction de service'
|
||||
const contexte = (sujet as { contexte?: string | null }).contexte ?? consigne
|
||||
setShowIdees(true)
|
||||
idees.fetchIdees({ consigne, contenu: contexte })
|
||||
idees.fetchIdees({ consigne, contenu: notes })
|
||||
}
|
||||
|
||||
if (!sujet) return null
|
||||
|
|
@ -185,7 +194,8 @@ export function T2PreparationPage() {
|
|||
)
|
||||
}
|
||||
onClick={handleIdees}
|
||||
disabled={idees.isLoading}
|
||||
disabled={ideesDisabled}
|
||||
title={ideesTitle}
|
||||
>
|
||||
Suggestions d'idées
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Tests — T2PreparationPage (Sprint 6e, fix idees Option C / parité EE).
|
||||
*
|
||||
* Couvre le gatekeeping « Suggestions d'idées » aligné sur SimulationForm :
|
||||
* - notes < 30 mots → bouton désactivé (aucun appel /sujets/idees → aucun 400)
|
||||
* - notes ≥ 30 mots → bouton actif ET fetchIdees appelé avec contenu = notes
|
||||
* (les notes RÉELLES du candidat, pas la consigne du sujet).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
const { navigateMock, fetchIdeesMock, resetMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
fetchIdeesMock: vi.fn(),
|
||||
resetMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return { ...actual, useNavigate: () => navigateMock }
|
||||
})
|
||||
|
||||
vi.mock('../../state/T2LiveContext', () => ({
|
||||
useT2LiveContext: () => ({
|
||||
sujet: {
|
||||
id: 'sujet-1',
|
||||
consigne: 'Réservez une table au restaurant.',
|
||||
contexte: 'Vous appelez un restaurant.',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/simulations/hooks/useIdees', () => ({
|
||||
useIdees: () => ({
|
||||
fetchIdees: fetchIdeesMock,
|
||||
reset: resetMock,
|
||||
idees: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { T2PreparationPage } from '../T2PreparationPage'
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<T2PreparationPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
// 30 mots exactement → countWords renvoie 30 (≥ seuil).
|
||||
const NOTES_30 = Array.from({ length: 30 }, (_, i) => `mot${i + 1}`).join(' ')
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
navigateMock.mockReset()
|
||||
fetchIdeesMock.mockReset()
|
||||
resetMock.mockReset()
|
||||
// Le pré-warm micro appelle getUserMedia dans un effect — stub jsdom.
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [] }) },
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2PreparationPage — gatekeeping Suggestions d’idées (parité EE)', () => {
|
||||
it('bouton désactivé tant que les notes font < 30 mots', () => {
|
||||
renderPage()
|
||||
const btn = screen.getByRole('button', { name: /Suggestions/i })
|
||||
expect(btn).toBeDisabled()
|
||||
expect(btn).toHaveAttribute('title', 'Écrivez au moins 30 mots')
|
||||
})
|
||||
|
||||
it('bouton actif à ≥ 30 mots et fetchIdees appelé avec contenu = notes', () => {
|
||||
renderPage()
|
||||
const textarea = screen.getByLabelText(/Vos notes/i)
|
||||
fireEvent.change(textarea, { target: { value: NOTES_30 } })
|
||||
|
||||
const btn = screen.getByRole('button', { name: /Suggestions/i })
|
||||
expect(btn).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(btn)
|
||||
expect(fetchIdeesMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchIdeesMock).toHaveBeenCalledWith({
|
||||
consigne: 'Réservez une table au restaurant.',
|
||||
contenu: NOTES_30,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue