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
376
src/features/t2-live/hooks/useT2LiveSession.ts
Normal file
376
src/features/t2-live/hooks/useT2LiveSession.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* useT2LiveSession — Hook orchestrateur du dialogue T2 Live (Sprint 6c).
|
||||
*
|
||||
* Responsabilités :
|
||||
* 1. Ouvre la WebSocket vers `wss://${API_URL}/t2/live?token=<jwt>&sujet=<uuid>`
|
||||
* au démarrage du dialogue (PAS pendant la prépa).
|
||||
* 2. Branche les hooks audio :
|
||||
* - useAudioCapture → onChunk → ws.send + addCandidateChunk(recording)
|
||||
* - useAudioPlayback → playChunk(base64 IA reçu)
|
||||
* - useAudioRecording → buffer chronologique pour exportWAV
|
||||
* 3. Parse les messages WS :
|
||||
* - Frames Gemini natifs forwardés (serverContent.modelTurn.parts.inlineData)
|
||||
* - Messages applicatifs backend (warning, report, error)
|
||||
* 4. Pilote la state machine T2 (ready → speaking → listening → processing → ended).
|
||||
* 5. Timer 210 s côté frontend (redondant avec le timer backend, ceinture+bretelles).
|
||||
* 6. Gère les close codes (1000, 4001, 4003, 4004, 4005, 4006).
|
||||
*
|
||||
* Validation : test manuel uniquement (WebSocket + AudioContext non testables en jsdom).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { env } from '@/shared/config/env'
|
||||
import { getAccessToken } from '@/shared/lib/auth-client'
|
||||
import { useAudioCapture } from './useAudioCapture'
|
||||
import { useAudioPlayback } from './useAudioPlayback'
|
||||
import { useAudioRecording } from './useAudioRecording'
|
||||
import { transition, T2_INITIAL_STATE, type T2State, type T2Event } from '../state/t2-machine'
|
||||
|
||||
const DIALOGUE_TIMEOUT_MS = 210_000 // 3 min 30
|
||||
const WS_PING_INTERVAL_MS = 30_000
|
||||
|
||||
export interface UseT2LiveSessionOptions {
|
||||
sujetId: string
|
||||
/** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
|
||||
onReportReady?: (simulationId: string) => void
|
||||
}
|
||||
|
||||
export interface UseT2LiveSessionResult {
|
||||
state: T2State
|
||||
startDialogue: () => Promise<void>
|
||||
endDialogue: () => void
|
||||
warning: boolean
|
||||
errorMessage: string | null
|
||||
simulationId: string | null
|
||||
recording: ReturnType<typeof useAudioRecording>
|
||||
/** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */
|
||||
elapsedSeconds: number
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
inlineData?: { data?: string; mimeType?: string }
|
||||
}
|
||||
interface GeminiServerContent {
|
||||
modelTurn?: { parts?: GeminiPart[] }
|
||||
inputTranscription?: { text?: string }
|
||||
outputTranscription?: { text?: string }
|
||||
interrupted?: boolean
|
||||
turnComplete?: boolean
|
||||
}
|
||||
interface GeminiMessage {
|
||||
serverContent?: GeminiServerContent
|
||||
}
|
||||
interface AppMessage {
|
||||
type: 'warning' | 'report' | 'error' | 'audio'
|
||||
data?: { simulation_id?: string } & Record<string, unknown>
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
function buildWsUrl(token: string, sujetId: string): string {
|
||||
const base = env.VITE_API_URL.replace(/^http/, 'ws')
|
||||
return `${base}/t2/live?token=${encodeURIComponent(token)}&sujet=${encodeURIComponent(sujetId)}`
|
||||
}
|
||||
|
||||
export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessionResult {
|
||||
const { sujetId, onReportReady } = opts
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [state, setState] = useState<T2State>(T2_INITIAL_STATE)
|
||||
const [warning, setWarning] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [simulationId, setSimulationId] = useState<string | null>(null)
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const sessionEndedRef = useRef(false)
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const userSpeakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const playback = useAudioPlayback()
|
||||
const recording = useAudioRecording()
|
||||
|
||||
// Capture branchée à l'envoi WS + au buffer recording.
|
||||
const capture = useAudioCapture({
|
||||
onChunk: (base64: string) => {
|
||||
const ws = wsRef.current
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'audio', data: base64 }))
|
||||
}
|
||||
// Décoder pour le buffer recording — base64 → ArrayBuffer 16k Int16 LE.
|
||||
try {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
recording.addCandidateChunk(bytes.buffer)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const dispatch = useCallback((event: T2Event) => {
|
||||
setState((prev) => transition(prev, event))
|
||||
}, [])
|
||||
|
||||
const cleanupTimers = useCallback(() => {
|
||||
if (timeoutTimerRef.current !== null) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
if (elapsedTimerRef.current !== null) {
|
||||
clearInterval(elapsedTimerRef.current)
|
||||
elapsedTimerRef.current = null
|
||||
}
|
||||
if (pingTimerRef.current !== null) {
|
||||
clearInterval(pingTimerRef.current)
|
||||
pingTimerRef.current = null
|
||||
}
|
||||
if (userSpeakingTimerRef.current !== null) {
|
||||
clearTimeout(userSpeakingTimerRef.current)
|
||||
userSpeakingTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
sessionEndedRef.current = true
|
||||
cleanupTimers()
|
||||
capture.stop()
|
||||
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
|
||||
try {
|
||||
wsRef.current.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// playback continue jusqu'à fin de file pour ne pas couper la dernière phrase IA.
|
||||
}, [capture, cleanupTimers])
|
||||
|
||||
const handleAudioReceived = useCallback(
|
||||
(base64: string) => {
|
||||
// Heuristique d'activité IA = on est en 'listening' (l'IA parle).
|
||||
// L'évènement USER_SPEAKING est inféré côté capture micro plutôt — ici on
|
||||
// signale au moins qu'on quitte 'speaking' si on y était.
|
||||
playback.playChunk(base64)
|
||||
recording.addAIChunk(base64)
|
||||
},
|
||||
[playback, recording],
|
||||
)
|
||||
|
||||
const handleGeminiMessage = useCallback(
|
||||
(msg: GeminiMessage) => {
|
||||
const sc = msg.serverContent
|
||||
if (!sc) return
|
||||
if (sc.modelTurn?.parts) {
|
||||
for (const part of sc.modelTurn.parts) {
|
||||
if (part.inlineData?.data) {
|
||||
handleAudioReceived(part.inlineData.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Les transcriptions sont accumulées côté backend pour la correction
|
||||
// finale — le frontend n'en a pas besoin pour l'UI temps réel.
|
||||
},
|
||||
[handleAudioReceived],
|
||||
)
|
||||
|
||||
const handleAppMessage = useCallback(
|
||||
(msg: AppMessage) => {
|
||||
if (msg.type === 'warning') {
|
||||
setWarning(true)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'report' && msg.data) {
|
||||
const simId = (msg.data.simulation_id as string | undefined) ?? null
|
||||
if (simId) setSimulationId(simId)
|
||||
dispatch({ type: 'REPORT_READY' })
|
||||
if (simId && onReportReady) onReportReady(simId)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
setErrorMessage(msg.message ?? 'Une erreur est survenue.')
|
||||
dispatch({ type: 'ERROR', message: msg.message })
|
||||
}
|
||||
},
|
||||
[dispatch, onReportReady],
|
||||
)
|
||||
|
||||
const handleWsMessage = useCallback(
|
||||
(evt: MessageEvent) => {
|
||||
// Les chunks audio Gemini peuvent arriver en binary ou en JSON
|
||||
// (selon proxy). Le backend Sprint 6a forward les frames Gemini telles
|
||||
// quelles — donc majoritairement string JSON.
|
||||
let text: string
|
||||
if (typeof evt.data === 'string') {
|
||||
text = evt.data
|
||||
} else if (evt.data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder().decode(evt.data)
|
||||
} else if (evt.data instanceof Blob) {
|
||||
// Fallback async — ignorer ce frame, log seulement.
|
||||
console.warn('[T2] Frame Blob reçu, attendu string/ArrayBuffer')
|
||||
return
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as GeminiMessage & AppMessage
|
||||
if (parsed.type === 'warning' || parsed.type === 'report' || parsed.type === 'error') {
|
||||
handleAppMessage(parsed as AppMessage)
|
||||
} else if (parsed.serverContent) {
|
||||
handleGeminiMessage(parsed as GeminiMessage)
|
||||
}
|
||||
} catch {
|
||||
/* JSON malformé — ignorer */
|
||||
}
|
||||
},
|
||||
[handleAppMessage, handleGeminiMessage],
|
||||
)
|
||||
|
||||
const handleWsClose = useCallback(
|
||||
(evt: CloseEvent) => {
|
||||
if (sessionEndedRef.current) return
|
||||
sessionEndedRef.current = true
|
||||
cleanupTimers()
|
||||
capture.stop()
|
||||
|
||||
switch (evt.code) {
|
||||
case 1000:
|
||||
// Fermeture normale — si on n'a pas reçu le rapport, c'est anormal.
|
||||
if (state !== 'ended') {
|
||||
dispatch({ type: 'ERROR', message: 'Session interrompue' })
|
||||
}
|
||||
break
|
||||
case 4001:
|
||||
setErrorMessage('Authentification expirée.')
|
||||
dispatch({ type: 'ERROR', code: 4001 })
|
||||
navigate('/login')
|
||||
break
|
||||
case 4003:
|
||||
setErrorMessage('La Tâche 2 Live est réservée au plan Premium.')
|
||||
dispatch({ type: 'ERROR', code: 4003 })
|
||||
break
|
||||
case 4004:
|
||||
setErrorMessage('Sujet introuvable.')
|
||||
dispatch({ type: 'ERROR', code: 4004 })
|
||||
break
|
||||
case 4005:
|
||||
setErrorMessage('Service temporairement indisponible.')
|
||||
dispatch({ type: 'ERROR', code: 4005 })
|
||||
break
|
||||
case 4006:
|
||||
setErrorMessage('Connexion à l’examinateur perdue.')
|
||||
dispatch({ type: 'ERROR', code: 4006 })
|
||||
break
|
||||
default:
|
||||
setErrorMessage('Une erreur est survenue. Réessayez dans quelques instants.')
|
||||
dispatch({ type: 'ERROR', code: evt.code })
|
||||
}
|
||||
},
|
||||
[capture, cleanupTimers, dispatch, navigate, state],
|
||||
)
|
||||
|
||||
const startDialogue = useCallback(async () => {
|
||||
if (wsRef.current) return
|
||||
setErrorMessage(null)
|
||||
setWarning(false)
|
||||
sessionEndedRef.current = false
|
||||
dispatch({ type: 'START_DIALOGUE' })
|
||||
|
||||
const token = await getAccessToken()
|
||||
if (!token) {
|
||||
setErrorMessage('Authentification requise.')
|
||||
dispatch({ type: 'ERROR', code: 4001 })
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl(token, sujetId))
|
||||
ws.binaryType = 'arraybuffer'
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Connexion impossible'
|
||||
setErrorMessage(message)
|
||||
dispatch({ type: 'ERROR' })
|
||||
return
|
||||
}
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'WS_OPENED' })
|
||||
// Démarrer la capture micro UNE FOIS le WS ouvert.
|
||||
void capture.start()
|
||||
|
||||
// Timer écoulé pour l'UI.
|
||||
const startTime = Date.now()
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
|
||||
}, 250)
|
||||
|
||||
// Timeout dur côté frontend (redondance avec le 210 s backend).
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
if (sessionEndedRef.current) return
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'end' }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'END_REQUESTED' })
|
||||
}, DIALOGUE_TIMEOUT_MS)
|
||||
|
||||
// Keep-alive ping (certains proxies coupent à 30s d'inactivité).
|
||||
pingTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS)
|
||||
}
|
||||
ws.onmessage = handleWsMessage
|
||||
ws.onclose = handleWsClose
|
||||
ws.onerror = () => {
|
||||
// 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
|
||||
}
|
||||
}, [capture, dispatch, handleWsClose, handleWsMessage, navigate, sujetId])
|
||||
|
||||
const endDialogue = useCallback(() => {
|
||||
if (sessionEndedRef.current) return
|
||||
cleanupTimers()
|
||||
capture.stop()
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: 'end' }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'END_REQUESTED' })
|
||||
}, [capture, cleanupTimers, dispatch])
|
||||
|
||||
// Cleanup au démontage.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeAll()
|
||||
}
|
||||
}, [closeAll])
|
||||
|
||||
return {
|
||||
state,
|
||||
startDialogue,
|
||||
endDialogue,
|
||||
warning,
|
||||
errorMessage,
|
||||
simulationId,
|
||||
recording,
|
||||
elapsedSeconds,
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
38
src/features/t2-live/state/T2LiveContext.tsx
Normal file
38
src/features/t2-live/state/T2LiveContext.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* T2LiveContext — partage le sujet sélectionné entre les pages T2 Live.
|
||||
*
|
||||
* Les pages /simulation/eo/t2/preparation et /simulation/eo/t2/dialogue
|
||||
* ont besoin du sujet choisi sur /simulation/eo/t2 (id, role, contexte,
|
||||
* consigne). Le sujet n'est pas persisté en backend tant que le rapport
|
||||
* n'est pas généré — donc un Provider React simple suffit.
|
||||
*
|
||||
* Si un utilisateur arrive directement sur /preparation ou /dialogue
|
||||
* sans sujet (ex: refresh), les pages redirigent vers /simulation/eo/t2.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import type { SujetData } from '@/entities/production/types'
|
||||
|
||||
interface T2LiveContextValue {
|
||||
sujet: SujetData | null
|
||||
setSujet: (sujet: SujetData | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const T2LiveContext = createContext<T2LiveContextValue | null>(null)
|
||||
|
||||
export function T2LiveProvider({ children }: { children: ReactNode }) {
|
||||
const [sujet, setSujet] = useState<SujetData | null>(null)
|
||||
const reset = () => setSujet(null)
|
||||
return (
|
||||
<T2LiveContext.Provider value={{ sujet, setSujet, reset }}>{children}</T2LiveContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useT2LiveContext(): T2LiveContextValue {
|
||||
const ctx = useContext(T2LiveContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useT2LiveContext must be used within T2LiveProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
75
src/features/t2-live/state/__tests__/t2-machine.test.ts
Normal file
75
src/features/t2-live/state/__tests__/t2-machine.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { transition, T2_INITIAL_STATE } from '../t2-machine'
|
||||
import type { T2State } from '../t2-machine'
|
||||
|
||||
describe('T2 state machine — transitions nominales', () => {
|
||||
it('idle → preparing sur START_PREPARATION', () => {
|
||||
expect(transition('idle', { type: 'START_PREPARATION' })).toBe('preparing')
|
||||
})
|
||||
|
||||
it('preparing → connecting sur START_DIALOGUE', () => {
|
||||
expect(transition('preparing', { type: 'START_DIALOGUE' })).toBe('connecting')
|
||||
})
|
||||
|
||||
it('connecting → ready sur WS_OPENED', () => {
|
||||
expect(transition('connecting', { type: 'WS_OPENED' })).toBe('ready')
|
||||
})
|
||||
|
||||
it('ready ↔ speaking ↔ listening (cycle de dialogue)', () => {
|
||||
expect(transition('ready', { type: 'USER_SPEAKING' })).toBe('speaking')
|
||||
expect(transition('speaking', { type: 'USER_SILENT' })).toBe('listening')
|
||||
expect(transition('listening', { type: 'USER_SPEAKING' })).toBe('speaking')
|
||||
})
|
||||
|
||||
it('processing → ended sur REPORT_READY', () => {
|
||||
expect(transition('processing', { type: 'REPORT_READY' })).toBe('ended')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2 state machine — END_REQUESTED → processing depuis tout état actif', () => {
|
||||
it.each<T2State>(['connecting', 'ready', 'speaking', 'listening'])(
|
||||
'transition %s → processing sur END_REQUESTED',
|
||||
(from) => {
|
||||
expect(transition(from, { type: 'END_REQUESTED' })).toBe('processing')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('T2 state machine — ERROR terminal', () => {
|
||||
it.each<T2State>([
|
||||
'idle',
|
||||
'preparing',
|
||||
'connecting',
|
||||
'ready',
|
||||
'speaking',
|
||||
'listening',
|
||||
'processing',
|
||||
])('transition %s → error sur ERROR', (from) => {
|
||||
expect(transition(from, { type: 'ERROR', code: 4001 })).toBe('error')
|
||||
})
|
||||
|
||||
it('ended est insensible à ERROR (état terminal protégé)', () => {
|
||||
expect(transition('ended', { type: 'ERROR', code: 4001 })).toBe('ended')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2 state machine — événements invalides ignorés', () => {
|
||||
it('USER_SPEAKING en idle est ignoré', () => {
|
||||
expect(transition('idle', { type: 'USER_SPEAKING' })).toBe('idle')
|
||||
})
|
||||
|
||||
it('REPORT_READY en ready est ignoré (doit passer par END_REQUESTED → processing)', () => {
|
||||
expect(transition('ready', { type: 'REPORT_READY' })).toBe('ready')
|
||||
})
|
||||
|
||||
it('états terminaux (ended, error) sont insensibles aux events nominaux', () => {
|
||||
expect(transition('ended', { type: 'USER_SPEAKING' })).toBe('ended')
|
||||
expect(transition('error', { type: 'WS_OPENED' })).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2_INITIAL_STATE', () => {
|
||||
it('vaut idle', () => {
|
||||
expect(T2_INITIAL_STATE).toBe('idle')
|
||||
})
|
||||
})
|
||||
102
src/features/t2-live/state/t2-machine.ts
Normal file
102
src/features/t2-live/state/t2-machine.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* t2-machine — State machine pure pour le flux T2 Live (Sprint 6c).
|
||||
*
|
||||
* Résout FTD-09 (planifiée Sprint 2.5, déclenchée Sprint 6).
|
||||
*
|
||||
* Cycle de vie d'une session :
|
||||
*
|
||||
* idle
|
||||
* └─ START_PREPARATION ─▶ preparing
|
||||
* └─ START_DIALOGUE ─▶ connecting
|
||||
* └─ WS_OPENED ─▶ ready
|
||||
* ├─ USER_SPEAKING ─▶ speaking
|
||||
* │ └─ USER_SILENT ─▶ listening
|
||||
* │ └─ USER_SPEAKING ─▶ speaking
|
||||
* ├─ END_REQUESTED ─▶ processing
|
||||
* │ └─ REPORT_READY ─▶ ended
|
||||
* └─ ERROR ─▶ error
|
||||
*
|
||||
* Toute transition non listée est ignorée (état conservé). La machine est
|
||||
* une fonction pure — aucun side-effect, aucune référence à du DOM, à React
|
||||
* ou à un WebSocket. Les side-effects sont orchestrés par useT2LiveSession.
|
||||
*/
|
||||
|
||||
export type T2State =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'connecting'
|
||||
| 'ready'
|
||||
| 'speaking'
|
||||
| 'listening'
|
||||
| 'processing'
|
||||
| 'ended'
|
||||
| 'error'
|
||||
|
||||
export type T2Event =
|
||||
| { type: 'START_PREPARATION' }
|
||||
| { type: 'START_DIALOGUE' }
|
||||
| { type: 'WS_OPENED' }
|
||||
| { type: 'USER_SPEAKING' }
|
||||
| { type: 'USER_SILENT' }
|
||||
| { type: 'END_REQUESTED' }
|
||||
| { type: 'REPORT_READY' }
|
||||
| { type: 'ERROR'; code?: number; message?: string }
|
||||
|
||||
/**
|
||||
* Transition pure : (state, event) → newState.
|
||||
*
|
||||
* Les événements `WARNING` (timer 30 s restantes) ne sont pas modélisés ici
|
||||
* car ils n'affectent pas l'état — ils déclenchent un side-effect d'affichage
|
||||
* géré directement par le hook orchestrateur.
|
||||
*/
|
||||
export function transition(state: T2State, event: T2Event): T2State {
|
||||
// ERROR est terminal et bypasse tous les guards : peut être émis depuis
|
||||
// n'importe quel état non-terminal.
|
||||
if (event.type === 'ERROR' && state !== 'ended') {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
if (event.type === 'START_PREPARATION') return 'preparing'
|
||||
// Permet de sauter la prépa si l'appelant le souhaite.
|
||||
if (event.type === 'START_DIALOGUE') return 'connecting'
|
||||
return state
|
||||
|
||||
case 'preparing':
|
||||
if (event.type === 'START_DIALOGUE') return 'connecting'
|
||||
return state
|
||||
|
||||
case 'connecting':
|
||||
if (event.type === 'WS_OPENED') return 'ready'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'ready':
|
||||
if (event.type === 'USER_SPEAKING') return 'speaking'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'speaking':
|
||||
if (event.type === 'USER_SILENT') return 'listening'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'listening':
|
||||
if (event.type === 'USER_SPEAKING') return 'speaking'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'processing':
|
||||
if (event.type === 'REPORT_READY') return 'ended'
|
||||
return state
|
||||
|
||||
case 'ended':
|
||||
case 'error':
|
||||
// États terminaux — aucune transition.
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/** État initial de toute session T2 Live. */
|
||||
export const T2_INITIAL_STATE: T2State = 'idle'
|
||||
Loading…
Add table
Add a link
Reference in a new issue