Sprint 6c — Frontend T2 Live UI + state machine + integration
feat(t2-live): state machine pure 9 states x 8 events (resolves FTD-09) feat(t2-live): useT2LiveSession WS orchestrator + audio hooks + close codes feat(t2-live): T2SujetsPage, T2PreparationPage (2min timer + notes + ideas), T2DialoguePage (3:30 dialogue + terminal screen with WAV download) feat(t2-live): T2LiveContext provider for sujet sharing between pages fix(TaskSelector): unlock EO_T2_LIVE card via hasAccess (resolves FTD-33) chore: Tache type + labels + config extended with EO_T2_LIVE test: 21 t2-machine tests — 259/259 green (+21)
This commit is contained in:
parent
7862f7c9f3
commit
1d95166611
17 changed files with 1229 additions and 33 deletions
212
src/features/t2-live/pages/T2DialoguePage.tsx
Normal file
212
src/features/t2-live/pages/T2DialoguePage.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Page /simulation/eo/t2/dialogue — phase de dialogue live (Sprint 6c).
|
||||
*
|
||||
* Démarre la session WS au mount, pilote l'UI selon l'état machine T2, affiche
|
||||
* le timer 3:30 et l'indicateur d'état. À la fin (REPORT_READY), affiche un
|
||||
* écran terminal avec deux boutons : "Télécharger l'audio" et "Voir le rapport".
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mic, Download, FileText, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
import { useT2LiveSession } from '../hooks/useT2LiveSession'
|
||||
|
||||
const DIALOGUE_SECONDS = 210 // 3:30
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const remaining = Math.max(0, totalSeconds)
|
||||
const m = Math.floor(remaining / 60)
|
||||
const s = remaining % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function T2DialoguePage() {
|
||||
const navigate = useNavigate()
|
||||
const { sujet, reset: resetContext } = useT2LiveContext()
|
||||
const [autoStarted, setAutoStarted] = useState(false)
|
||||
|
||||
const session = useT2LiveSession({
|
||||
sujetId: sujet?.id ?? '',
|
||||
})
|
||||
|
||||
// Garde-fou : pas de sujet → retour à la sélection.
|
||||
useEffect(() => {
|
||||
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
|
||||
}, [sujet, navigate])
|
||||
|
||||
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
|
||||
useEffect(() => {
|
||||
if (!sujet) return
|
||||
if (autoStarted) return
|
||||
setAutoStarted(true)
|
||||
void session.startDialogue()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sujet, autoStarted])
|
||||
|
||||
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
|
||||
const stateLabel = (() => {
|
||||
switch (session.state) {
|
||||
case 'idle':
|
||||
case 'connecting':
|
||||
return 'Connexion à l’examinateur…'
|
||||
case 'ready':
|
||||
return 'À vous — prenez la parole.'
|
||||
case 'speaking':
|
||||
return 'L’examinateur écoute…'
|
||||
case 'listening':
|
||||
return 'L’examinateur répond…'
|
||||
case 'processing':
|
||||
return 'Évaluation en cours…'
|
||||
case 'ended':
|
||||
return 'Session terminée.'
|
||||
case 'error':
|
||||
return 'Erreur.'
|
||||
case 'preparing':
|
||||
return 'Préparation…'
|
||||
}
|
||||
})()
|
||||
|
||||
function handleDownload() {
|
||||
const blob = session.recording.exportWAV()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `expria-t2-${new Date().toISOString().slice(0, 10)}.wav`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function handleViewReport() {
|
||||
if (!session.simulationId) return
|
||||
resetContext()
|
||||
navigate(`/rapport/${session.simulationId}`)
|
||||
}
|
||||
|
||||
function handleBackToSujets() {
|
||||
resetContext()
|
||||
navigate('/simulation/eo/t2')
|
||||
}
|
||||
|
||||
if (!sujet) return null
|
||||
|
||||
// ── État terminal : rapport prêt ─────────────────────────────────────────
|
||||
if (session.state === 'ended') {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
|
||||
<Card variant="default" className="space-y-3 p-5">
|
||||
<p className="text-sm text-ink-primary">
|
||||
Votre dialogue a été évalué. Vous pouvez télécharger l'enregistrement audio avant de
|
||||
consulter le rapport.
|
||||
</p>
|
||||
<p className="text-xs text-ink-secondary">
|
||||
Durée enregistrée :{' '}
|
||||
<span className="font-semibold tabular-nums text-ink-primary">
|
||||
{session.recording.durationSeconds.toFixed(1)} s
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<Download className="size-4" aria-hidden="true" />}
|
||||
onClick={handleDownload}
|
||||
disabled={session.recording.durationSeconds === 0}
|
||||
>
|
||||
Télécharger l'audio
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<FileText className="size-4" aria-hidden="true" />}
|
||||
onClick={handleViewReport}
|
||||
disabled={!session.simulationId}
|
||||
>
|
||||
Voir le rapport
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── État erreur ──────────────────────────────────────────────────────────
|
||||
if (session.state === 'error') {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
{session.errorMessage ?? 'Une erreur est survenue.'}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={handleBackToSujets}>
|
||||
Retour aux sujets
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── État dialogue actif ──────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Dialogue en cours</h2>
|
||||
<span
|
||||
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
|
||||
aria-label="Temps restant de dialogue"
|
||||
>
|
||||
{formatMmSs(remaining)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.warning && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
|
||||
>
|
||||
⏱ 30 secondes restantes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card variant="default" className="space-y-4 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{session.state === 'processing' ? (
|
||||
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
|
||||
) : (
|
||||
<Mic
|
||||
className={
|
||||
session.state === 'speaking' ? 'size-5 text-success' : 'size-5 text-ink-secondary'
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
|
||||
</div>
|
||||
<p className="text-xs text-ink-secondary">{sujet.consigne}</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => session.endDialogue()}
|
||||
disabled={session.state === 'processing'}
|
||||
>
|
||||
Terminer le dialogue
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
src/features/t2-live/pages/T2PreparationPage.tsx
Normal file
210
src/features/t2-live/pages/T2PreparationPage.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Page /simulation/eo/t2/preparation — phase de préparation T2 Live (Sprint 6c).
|
||||
*
|
||||
* - Timer 2 min visible (countdown).
|
||||
* - 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.
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Loader2 } from 'lucide-react'
|
||||
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 { useT2LiveContext } from '../state/T2LiveContext'
|
||||
|
||||
const PREPARATION_SECONDS = 120
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60)
|
||||
const s = totalSeconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function T2PreparationPage() {
|
||||
const navigate = useNavigate()
|
||||
const { sujet } = useT2LiveContext()
|
||||
|
||||
const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [showIdees, setShowIdees] = useState(false)
|
||||
const [micWarmed, setMicWarmed] = useState<boolean | null>(null)
|
||||
const expiredRef = useRef(false)
|
||||
|
||||
const idees = useIdees()
|
||||
|
||||
// Garde-fou : pas de sujet → retour à la sélection.
|
||||
useEffect(() => {
|
||||
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
|
||||
}, [sujet, navigate])
|
||||
|
||||
// Pré-warm permission micro (le hook useAudioCapture le fera de toute façon
|
||||
// au start, mais on demande la permission ici pour éviter la latence à 0:00).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let stream: MediaStream | null = null
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
})
|
||||
.then((s) => {
|
||||
stream = s
|
||||
if (cancelled) {
|
||||
s.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
// Permission acquise — on relâche le stream pour ne pas bloquer le micro.
|
||||
s.getTracks().forEach((t) => t.stop())
|
||||
setMicWarmed(true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMicWarmed(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (stream) stream.getTracks().forEach((t) => t.stop())
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Timer countdown 2 min.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setSecondsLeft((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(id)
|
||||
if (!expiredRef.current) {
|
||||
expiredRef.current = true
|
||||
// Auto-navigation à la fin de la prépa.
|
||||
queueMicrotask(() => navigate('/simulation/eo/t2/dialogue'))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [navigate])
|
||||
|
||||
function handleReady() {
|
||||
navigate('/simulation/eo/t2/dialogue')
|
||||
}
|
||||
|
||||
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.
|
||||
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 })
|
||||
}
|
||||
|
||||
if (!sujet) return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Préparation — Tâche 2 Live</h2>
|
||||
<span
|
||||
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
|
||||
aria-label="Temps restant de préparation"
|
||||
>
|
||||
{formatMmSs(secondsLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card variant="default" className="space-y-3 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||
Consigne
|
||||
</p>
|
||||
<p className="text-sm text-ink-primary">{sujet.consigne}</p>
|
||||
{(sujet as { contexte?: string | null }).contexte && (
|
||||
<>
|
||||
<p className="pt-2 text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||
Contexte
|
||||
</p>
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{(sujet as { contexte?: string | null }).contexte}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="default" className="p-4">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
<strong className="text-ink-primary">Comment ça se passe :</strong> c'est à vous de
|
||||
prendre la parole en premier pour initier la conversation, comme à l'examen réel.
|
||||
L'examinateur IA attend que vous lui posiez vos questions.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card variant="default" className="space-y-2 p-4">
|
||||
<label
|
||||
htmlFor="t2-notes"
|
||||
className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary"
|
||||
>
|
||||
Vos notes (locales — non sauvegardées)
|
||||
</label>
|
||||
<textarea
|
||||
id="t2-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Notez vos questions, vos points à aborder…"
|
||||
className="w-full resize-y rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:outline-none focus:shadow-focus"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{micWarmed === false && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
|
||||
>
|
||||
Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
|
||||
dialogue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={
|
||||
idees.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleIdees}
|
||||
disabled={idees.isLoading}
|
||||
>
|
||||
Suggestions d'idées
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleReady}>
|
||||
Je suis prêt — démarrer le dialogue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<IdeesSuggestions
|
||||
idees={idees.idees}
|
||||
isLoading={idees.isLoading}
|
||||
error={idees.error}
|
||||
isOpen={showIdees}
|
||||
onClose={() => {
|
||||
setShowIdees(false)
|
||||
idees.reset()
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
src/features/t2-live/pages/T2SujetsPage.tsx
Normal file
109
src/features/t2-live/pages/T2SujetsPage.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Page /simulation/eo/t2 — sélection d'un sujet T2 EO Live (Sprint 6c).
|
||||
*
|
||||
* Pattern emprunté à SujetsEOPage : grille de sujets + sujet aléatoire.
|
||||
* Différence clé : le sujet est stocké dans T2LiveContext (pas SimulationFlowProvider)
|
||||
* — la production sera créée par le backend en fin de session, pas au clic.
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shuffle } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { useSujets } from '@/features/simulations/hooks/useSujets'
|
||||
import { SujetCard } from '@/features/simulations/components/SujetCard'
|
||||
import type { SujetData } from '@/entities/production/types'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
|
||||
function SujetsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-lg bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function T2SujetsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setSujet } = useT2LiveContext()
|
||||
|
||||
const { data: sujets, isLoading, isError, refetch } = useSujets('EO_T2_LIVE', true)
|
||||
|
||||
function handleSelect(sujet: SujetData) {
|
||||
setSujet(sujet)
|
||||
navigate('/simulation/eo/t2/preparation')
|
||||
}
|
||||
|
||||
function handleRandom() {
|
||||
if (!sujets || sujets.length === 0) return
|
||||
const pick = sujets[Math.floor(Math.random() * sujets.length)]
|
||||
if (pick) handleSelect(pick)
|
||||
}
|
||||
|
||||
const hasSujets = (sujets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/simulation/eo')}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
|
||||
Choisir un sujet — Tâche 2 Live
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{isLoading
|
||||
? 'Chargement des sujets…'
|
||||
: hasSujets
|
||||
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
|
||||
: 'Aucun sujet disponible pour cette tâche.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Shuffle className="size-4" aria-hidden="true" />}
|
||||
onClick={handleRandom}
|
||||
disabled={!hasSujets}
|
||||
>
|
||||
Sujet aléatoire
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
Impossible de charger les sujets.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<SujetsSkeleton />
|
||||
) : hasSujets ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sujets!.map((sujet) => (
|
||||
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue