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:
Hermann_Kitio 2026-04-26 20:28:35 +03:00
parent 7862f7c9f3
commit 1d95166611
17 changed files with 1229 additions and 33 deletions

View file

@ -29,6 +29,34 @@ Chaque entrée suit ce format :
---
## [Unreleased] — 2026-04-26 — Sprint 6c — Frontend T2 Live UI
### Added
- `t2-machine.ts` — state machine pure T2 Live : 9 états (`idle``preparing``connecting``ready``speaking``listening``processing``ended` / `error`), 8 events. 21 tests. Résout FTD-09.
- `useT2LiveSession.ts` — hook orchestrateur : WebSocket + state machine + hooks audio (capture/playback/recording). Parse format Gemini natif (`serverContent.modelTurn`) + messages applicatifs backend (`warning`/`report`/`error`). Close codes 1000/4001/4003/4004. Timer dialogue 210 s. Ping 30 s keep-alive.
- `T2LiveContext.tsx` — Provider léger pour partager le sujet sélectionné entre les pages T2.
- `T2SujetsPage.tsx` — grille de sélection des sujets T2 (`GET /sujets?mode=EO&tache=2`).
- `T2PreparationPage.tsx` — timer 2 min, consigne affichée, zone de notes locale, bouton « Suggestions d'idées » (DeepSeek, actif immédiatement), bouton « Je suis prêt », pré-warm micro via `getUserMedia`. Transition auto vers dialogue à 0:00.
- `T2DialoguePage.tsx` — timer 3:30, indicateur d'état IA, waveform, bouton « Terminer ». Écran terminal (state `ended`) : bouton « Télécharger l'audio » (WAV mono 24 kHz) + bouton « Voir le rapport » (→ `/rapport/:id`).
- 3 routes : `/simulation/eo/t2`, `/simulation/eo/t2/preparation`, `/simulation/eo/t2/dialogue` sous `T2LiveLayout`.
### Changed
- `TaskSelector.tsx` — carte EO T2 Live déverrouillée via `hasAccess(plan, 'oral_t2_live')` + prop `onT2LiveSelect`. Résout FTD-33.
- `SimulationEOPage.tsx` — branche `onT2LiveSelect` vers `/simulation/eo/t2`.
- `entities/production/``Tache` type, labels, `mapTacheToSujetParams`, config étendus avec `EO_T2_LIVE`.
- `features/historique/``TACHE_NUMBER` étendu.
### Notes
- Tests frontend : 238 → 259 verts (+21 — tous sur t2-machine).
- FTD-09 résolue (state machine testée).
- FTD-33 résolue (carte déverrouillée via hasAccess).
- `useT2LiveSession` non testé en unit (WebSocket non supporté jsdom) — validation manuelle prévue.
---
## [Unreleased] — 2026-04-26 — Sprint 6b — Frontend audio (T2 Live)
### Added

View file

@ -1,6 +1,6 @@
# TECH_DEBT.md — Expria Frontend
> **Document de référence — Version 1.27**
> **Document de référence — Version 1.28**
> Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées.
> À mettre à jour après chaque session de développement.
>
@ -421,29 +421,7 @@ Frontend :
---
### FTD-09 — Tests de la state machine T2 Live non implémentés
**Priorité :** 🟡 Important
**Statut :** Gelé — Sprint 5.5 (2026-04-26)
**Estimation de session :** 3h
**Description :** La state machine T2 Live (`src/features/t2-live/state/t2-machine.ts`) n'existe pas encore. Quand elle sera créée, elle devra être testée de manière exhaustive (6+ tests couvrant les transitions d'états et les cas d'erreur).
**Motif de gel :** Gelé — le code n'existe pas encore, sera créé et testé au Sprint 6.
**Condition de résolution :** fin Sprint 6 (T2 Live).
---
### FTD-33 — Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`)
**Priorité :** 🟢 Mineur
**Statut :** Gelé — Sprint 5.5 (2026-04-26)
**Estimation de session :** 0,5 jour
**Description :** Dans `TaskSelector`, la carte EO_T2_LIVE a `tache: null` ce qui la rend inactive pour tous les plans, indépendamment de `hasAccess(plan, 'oral_t2_live')`. C'est volontaire tant que T2 Live n'est pas livré (Sprint 6) — un utilisateur Premium ne doit pas accéder à une feature non implémentée. À nettoyer dès que T2 Live est wired pour respecter strictement la Règle D.
**Motif de gel :** Gelé — condition de résolution = Sprint 6 T2 Live.
**Condition de résolution :** lancement de T2 Live (Sprint 6).
> FTD-09 et FTD-33 résolues au Sprint 6c (2026-04-26) — voir §5 Historique.
---
@ -534,6 +512,8 @@ Frontend :
| FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. |
| FTD-25 | Mise à jour ARCHITECTURE.md §3 (arborescence réelle) | 2026-04-25 | §3 réécrite : `app/` documenté avec entry points + layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage) ; ajout `entities/{patterns,presentation,transcription}` ; ajout `features/{historique,progression,design-system}` ; extension `simulations/` (pages EO, components/rapport/, lib/, state/) ; mise à jour `shared/`. `t2-live/` et `billing/` retirés (non implémentés — voir ROADMAP). Note explicative ajoutée sous `app/`. Bump doc v1.1. |
| FTD-26 | Clarifier cohabitation `shared/ui/` vs `shared/components/ui/` | 2026-04-25 | Section dédiée ajoutée dans ARCHITECTURE.md §3 : tableau de distinction (PascalCase wrappers Expria vs kebab-case primitives shadcn) + règle d'évolution (toute nouvelle primitive Expria va dans `shared/ui/`, `shared/components/ui/` réservé à la CLI shadcn). Aucun fichier déplacé — documentation uniquement. |
| FTD-09 | Tests de la state machine T2 Live non implémentés | 2026-04-26 | Sprint 6c — State machine pure créée (`src/features/t2-live/state/t2-machine.ts`, 9 états × 8 events) + 21 tests Vitest couvrant transitions nominales, END_REQUESTED depuis tout état actif, ERROR terminal, événements invalides ignorés. Dégelée et fermée. |
| FTD-33 | Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) | 2026-04-26 | Sprint 6c — Carte EO_T2_LIVE déverrouillée via `hasAccess(plan, 'oral_t2_live')` + nouvelle prop `onT2LiveSelect` dans `TaskSelector`. Si plan donne accès, clic navigue vers `/simulation/eo/t2` (la production est créée par le backend en fin de session, pas au clic). Sinon, carte reste verrouillée avec lockLabel « Exclusivité Premium ». Dégelée et fermée. |
| FTD-14 | Anti-FOUC thème : script inline manquant dans `<head>` | 2026-04-26 | Sprint 5.5 — Script `.light` déjà en place dans `index.html` (lignes 14-20), conforme DESIGN_SYSTEM v2.0. L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0 (obsolète). Aucune action code requise — FTD fermée comme déjà résolue. |
| FTD-35 | `PresentationGenereeT1Page` : refresh sans simulation active | 2026-04-26 | Sprint 5.5 — Subsumée par FTD-41 : la résolution de FTD-41 (persistance T1 en BDD) élimine le problème de FTD-35 (localStorage instable). Aucune action propre. |
| FTD-38 | `useAudioRecorder` : mise à jour de ref pendant le render | 2026-04-26 | Sprint 5.5 — Refactor `optionsRef.current = options` (assignation pendant render + eslint-disable) en `useEffect(() => { optionsRef.current = options })`. Sémantique préservée : effet sans deps run après chaque commit, donc avant le prochain render qui lit la ref. eslint-disable retiré. 195 lignes de tests `useAudioRecorder.test.ts` toujours vertes (219/219). |
@ -574,3 +554,4 @@ Frontend :
| 1.25 | 2026-04-25 | Sprint 4.5 — Ajout FTD-40 🟡 (conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible — patch frontend en place dans `ConseilNclcCallout`) et FTD-41 🔴 (persistance présentation EO T1 en BDD — résout FTD-35). **19 FTD actives — cap 15 dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.** |
| 1.26 | 2026-04-26 | Sprint 5e (clean Sprint 5 Billing) — Ajout FTD-42 🟡 (modal prorata Standard→Premium avec montant exact — divergence PARCOURS_UTILISATEURS §3, actuellement Customer Portal natif sans preview in-app) et FTD-43 🟢 (race condition webhook post-redirect Stripe — `usePlan()` peut retourner ancien plan brièvement). **21 FTD actives — cap 15 dépassé de 6. Résorption FTD critique au Sprint 5.5 avant Sprint 6.** |
| 1.27 | 2026-04-26 | Sprint 5.5 Clean — FTD-09, FTD-33, FTD-42 gelées. FTD-35 fermée (subsumée par FTD-41). FTD-14, FTD-38, FTD-39 résolues. **14 FTD actives** (cap 15 respecté). |
| 1.28 | 2026-04-26 | Sprint 6c — FTD-09 et FTD-33 résolues (dégelées → fermées). **14 FTD actives** (inchangé — les gelées ne comptaient pas dans le cap). |

View file

@ -20,6 +20,10 @@ import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
import { PricingPage } from '@/features/billing/pages/PricingPage'
import { ParametresPage } from '@/features/account/pages/ParametresPage'
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext'
import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
import { AppLayout } from './AppLayout'
const DesignSystemPage = import.meta.env.DEV
@ -53,6 +57,14 @@ function SimulationFlowLayout() {
)
}
function T2LiveLayout() {
return (
<T2LiveProvider>
<Outlet />
</T2LiveProvider>
)
}
export function AppRouter() {
return (
<Routes>
@ -86,6 +98,13 @@ export function AppRouter() {
<Route path="/rapport/:id" element={<RapportPage />} />
</Route>
{/* Sprint 6c — T2 Live (Premium) : sélection sujet → prépa 2min → dialogue 3:30 */}
<Route element={<T2LiveLayout />}>
<Route path="/simulation/eo/t2" element={<T2SujetsPage />} />
<Route path="/simulation/eo/t2/preparation" element={<T2PreparationPage />} />
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
</Route>
{/* Autres sections — Sprint 4+ */}
<Route path="/examen" element={<ComingSoon />} />
<Route path="/progression" element={<ProgressionPage />} />

View file

@ -78,7 +78,10 @@ export async function updateSujet(id: string, sujetId: string): Promise<void> {
/**
* Mappe une Tache vers les paramètres de la route `GET /sujets`.
* Retourne `null` pour les tâches sans catalogue de sujets côté base
* (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet).
* (EO_T1 : sujet fixe connu).
*
* Sprint 6c : `EO_T2_LIVE` mappe vers (mode='EO', tache=2) pour récupérer
* la grille de sujets T2 (rôle + contexte alimentés en BDD).
*/
function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
switch (tache) {
@ -88,6 +91,8 @@ function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber:
return { mode: 'EE', tacheNumber: 2 }
case 'EE_T3':
return { mode: 'EE', tacheNumber: 3 }
case 'EO_T2_LIVE':
return { mode: 'EO', tacheNumber: 2 }
case 'EO_T3':
return { mode: 'EO', tacheNumber: 3 }
case 'EO_T1':

View file

@ -10,6 +10,7 @@ const TACHE_LABELS: Record<Tache, string> = {
EE_T2: 'Expression Écrite — Tâche 2',
EE_T3: 'Expression Écrite — Tâche 3',
EO_T1: 'Expression Orale — Tâche 1',
EO_T2_LIVE: 'Expression Orale — Tâche 2 Live',
EO_T3: 'Expression Orale — Tâche 3',
}

View file

@ -11,8 +11,11 @@
* Ne jamais les injecter comme HTML passer par react-markdown dans les composants.
*/
/** Identifiants des tâches disponibles en mode simulation (hors T2 Live). */
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3'
/**
* Identifiants des tâches disponibles en mode simulation.
* `EO_T2_LIVE` désigne la T2 EO en dialogue live (Sprint 6).
*/
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T2_LIVE' | 'EO_T3'
/** Mode de la simulation — examen uniquement accessible au plan Premium. */
export type Mode = 'entrainement' | 'examen'

View file

@ -127,6 +127,7 @@ const TACHE_NUMBER: Record<Tache, string> = {
EE_T2: 'EE · Tâche 2',
EE_T3: 'EE · Tâche 3',
EO_T1: 'EO · Tâche 1',
EO_T2_LIVE: 'EO · Tâche 2 Live',
EO_T3: 'EO · Tâche 3',
}

View file

@ -10,7 +10,7 @@
*/
import { Lock, Loader2 } from 'lucide-react'
import { canSimulate } from '@/entities/user/lib'
import { canSimulate, hasAccess } from '@/entities/user/lib'
import { cn } from '@/shared/lib/utils'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
@ -25,6 +25,14 @@ interface Props {
simulationsUsed: number
isLoading: boolean
onSelect: (payload: CreateSimulationPayload) => void
/**
* Sprint 6c handler dédié pour la carte EO_T2_LIVE. Si fourni ET que
* l'utilisateur a accès (`oral_t2_live`), un clic appelle ce handler au
* lieu d'`onSelect` (la production T2 Live est créée en backend en fin
* de session, pas au clic). Si absent OU plan insuffisant, la carte
* reste verrouillée.
*/
onT2LiveSelect?: () => void
}
interface TaskCard {
@ -46,17 +54,25 @@ const EO_CARDS: readonly TaskCard[] = [
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
{
key: 'EO_T2_LIVE',
tache: null,
tache: 'EO_T2_LIVE',
label: 'Expression Orale',
sublabel: 'Tâche 2 — Live',
lockLabel: 'Exclusivité Premium',
},
]
export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) {
export function TaskSelector({
type,
plan,
simulationsUsed,
isLoading,
onSelect,
onT2LiveSelect,
}: Props) {
const simulationCheck = canSimulate(plan, simulationsUsed)
const quotaBlocked = !simulationCheck.allowed
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
return (
<div className="space-y-4">
@ -82,13 +98,16 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{cards.map((card) => {
const locked = card.tache === null || quotaBlocked
const isT2Live = card.tache === 'EO_T2_LIVE'
// T2 Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
// Verrouillage : T2 Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
const locked = isT2Live ? !t2LiveUnlocked : card.tache === null || quotaBlocked
const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
if (locked) {
return (
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
{card.tache === null && (
{(card.tache === null || isT2Live) && (
<Lock className="mb-2 size-4 text-ink-secondary" aria-hidden="true" />
)}
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
@ -108,7 +127,12 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
variant="interactive"
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
onClick={() => {
if (!isLoading && card.tache) {
if (isLoading) return
if (isT2Live && onT2LiveSelect) {
onT2LiveSelect()
return
}
if (card.tache && card.tache !== 'EO_T2_LIVE') {
onSelect({ tache: card.tache, mode: 'entrainement' })
}
}}

View file

@ -45,6 +45,15 @@ const SIMULATION_CONFIG: Record<Tache, SimulationConfig> = {
dureeRecommandeeSecondes: 120,
enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
},
EO_T2_LIVE: {
dureeMinutes: 6,
motsMin: 0,
motsCibleMin: 0,
motsCibleMax: 0,
// 2 min prépa + 3 min 30 dialogue (Sprint 6c).
dureeRecommandeeSecondes: 330,
enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
},
EO_T3: {
dureeMinutes: 5,
motsMin: 0,

View file

@ -9,6 +9,7 @@
* Règle H : aucune logique métier délègue au provider.
*/
import { useNavigate } from 'react-router-dom'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { Button } from '@/shared/ui/Button'
import { TaskSelector } from '../components/TaskSelector'
@ -36,6 +37,7 @@ export function SimulationEOPage() {
} = usePlan()
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
const navigate = useNavigate()
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
@ -61,6 +63,7 @@ export function SimulationEOPage() {
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
onT2LiveSelect={() => navigate('/simulation/eo/t2')}
/>
{taskUnavailableMessage && (

View 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 à lexaminateur 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,
}
}

View 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 à lexaminateur…'
case 'ready':
return 'À vous — prenez la parole.'
case 'speaking':
return 'Lexaminateur écoute…'
case 'listening':
return 'Lexaminateur 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 é é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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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')
})
})

View 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'