diff --git a/src/features/t2-live/pages/T2PreparationPage.tsx b/src/features/t2-live/pages/T2PreparationPage.tsx index 2626767..ccd736a 100644 --- a/src/features/t2-live/pages/T2PreparationPage.tsx +++ b/src/features/t2-live/pages/T2PreparationPage.tsx @@ -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 diff --git a/src/features/t2-live/pages/__tests__/T2PreparationPage.test.tsx b/src/features/t2-live/pages/__tests__/T2PreparationPage.test.tsx new file mode 100644 index 0000000..1fe2566 --- /dev/null +++ b/src/features/t2-live/pages/__tests__/T2PreparationPage.test.tsx @@ -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('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( + + + , + ) +} + +// 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, + }) + }) +})