feat(t1-live): T1 Live frontend — Sprint 7b
Some checks are pending
CI / quality (push) Waiting to run
Some checks are pending
CI / quality (push) Waiting to run
- Add T1 state machine (8 states, presenting ⇄ interrupted) - Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg) - Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator - Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live - Extract shared t1Questionnaire.ts for batch/live DRY - Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a) - Simplified flow: card → preparation → dialogue - FTD-44 frozen (cross-feature audio hooks, Sprint 7.5) - FTD-45/46 frozen (Gemini relance quality + transcription) - Tests: 301/301 green
This commit is contained in:
parent
eb8987ddb3
commit
3016d909a6
14 changed files with 1385 additions and 68 deletions
|
|
@ -29,6 +29,35 @@ Chaque entrée suit ce format :
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased] — 2026-06-30 — Sprint 7b — Frontend T1 Live (monologue + interruption non déterministe)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Machine d'état T1 (`features/t1-live/state/t1-machine.ts`) — 8 états purs (`idle`, `preparing`, `connecting`, `presenting`, `interrupted`, `processing`, `ended`, `error`). Le cœur est la transition `interrupted ⇄ presenting` (interruption examinateur puis reprise candidat). +23 tests.
|
||||||
|
- `useT1LiveSession` (`features/t1-live/hooks/useT1LiveSession.ts`) — orchestrateur du dialogue T1, calqué sur `useT2LiveSession` (discipline « Voie A »). WS `wss://${API_URL}/t1/live?token=<jwt>` (PAS de `&sujet=` — T1 n'est pas subject-based). Aucun VAD micro (T1 = monologue) ; l'uplink micro est coupé/rétabli pendant une interruption via un **ref** (`uplinkMutedRef`), jamais via `setState` (leçon Voie A). Réagit aux signaux applicatifs `{type:'interruption_start'}` / `{type:'interruption_end'}`. Timer dur 180 s. Close codes 1000/4001/4003/4005/4006.
|
||||||
|
- `T1PreparationPage` + `T1DialoguePage` (`features/t1-live/pages/`) — parcours préparation → dialogue (3:00) ; écran terminal « Télécharger l'audio » + « Voir le rapport » (`/rapport/:id`). L'UI ne suppose JAMAIS qu'une relance suit (interruption non déterministe).
|
||||||
|
- `T1SpeakingIndicator` (`features/t1-live/components/`) — indicateur de prise de parole (amplitude micro réelle en `presenting`, animation décorative en `interrupted`).
|
||||||
|
- Carte `EO_T1_LIVE` dans `TaskSelector` (discriminateur `live?: 'T1' | 'T2'`, label « Tâche 1 — Live ») gatée Premium via `hasAccess(plan, 'oral_t2_live')` (TD-24 — pas de nouvelle permission, le gate couvre T1 et T2 Live) + prop `onT1LiveSelect`. `SimulationEOPage` câble `onT1LiveSelect → /simulation/eo/t1/live/preparation`.
|
||||||
|
- `features/simulations/lib/t1Questionnaire.ts` — définition partagée du questionnaire T1 (FIELDS + schéma zod + `EMPTY_REPONSES`), réutilisée par le batch `QuestionnaireT1Page`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `useT1LiveSession` aligné sur le **Patch 7a backend** : plus d'envoi du message `{type:'context'}`, plus d'option `reponses`, la session audio démarre directement sur `ws.onopen` (WS_OPENED → presenting).
|
||||||
|
- Parcours T1 Live simplifié : carte `EO_T1_LIVE` → préparation → dialogue (plus d'étape questionnaire intermédiaire).
|
||||||
|
- `t1-machine` : commentaire et test nettoyés (mapping close **4004** retiré → 4006), cohérent avec la suppression du contexte côté backend.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `T1LiveQuestionnairePage` et `T1LiveContext` (post-Patch 7a) — le backend n'exige plus de message `context` ni de réponses pré-remplies ; ces écrans/état deviennent sans objet.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **FTD-44 gelée** (§3bis TECH_DEBT) — les trois hooks audio génériques sont empruntés à `features/t2-live/hooks/` (violation FSD inter-features assumée et tracée, sites marqués `// TODO(FTD-44)`), réactivée au Sprint 7.5 (factorisation Sprint 7).
|
||||||
|
- WebSocket / AudioContext non matérialisables en jsdom → validation manuelle ; la logique pure de transition est couverte par `t1-machine.test.ts`.
|
||||||
|
- Bugs amont observés au test manuel, hors contrôle frontend : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Unreleased] — 2026-06-29 — Sprint 6e — T2 Live « Voie A » (mix audio temps réel)
|
## [Unreleased] — 2026-06-29 — Sprint 6e — T2 Live « Voie A » (mix audio temps réel)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@
|
||||||
## Sprint 7 — T1 Live (interruption aléatoire)
|
## Sprint 7 — T1 Live (interruption aléatoire)
|
||||||
|
|
||||||
- **7a (backend) ✅** : extension du proxy WebSocket Gemini Live (`gemini-3.1-flash-live-preview`, ws brut, pas de SDK) au mode T1 — system prompt « examinateur », décision d'interruption probabiliste, génération de la question de relance sur transcription partielle (DeepSeek). Réutilise l'infra T2 Live. Scoring EO 5 critères × /4. Phonologie live = 0 (TD-08, gelé). Contraintes héritées : pas de `speechConfig`. Livré : commits `868bd09` (code) + `3722e2a` (docs) ; dettes tracées TD-23/24/25 (cf. `TECH_DEBT-backend.md`).
|
- **7a (backend) ✅** : extension du proxy WebSocket Gemini Live (`gemini-3.1-flash-live-preview`, ws brut, pas de SDK) au mode T1 — system prompt « examinateur », décision d'interruption probabiliste, génération de la question de relance sur transcription partielle (DeepSeek). Réutilise l'infra T2 Live. Scoring EO 5 critères × /4. Phonologie live = 0 (TD-08, gelé). Contraintes héritées : pas de `speechConfig`. Livré : commits `868bd09` (code) + `3722e2a` (docs) ; dettes tracées TD-23/24/25 (cf. `TECH_DEBT-backend.md`).
|
||||||
- **7b (frontend)** : UI T1 Live réutilisant ws-client + audio worklet + state machine T2 ; phase préparation ; gestion interruption / reprise du flux audio dans la state machine ; gating Premium.
|
- **7b (frontend) ✅** : UI T1 Live — machine d'état T1 (8 états, `interrupted ⇄ presenting`), `useT1LiveSession` (WS `/t1/live`, sans message `context` post-Patch 7a, uplink coupé par ref pendant interruption), `T1PreparationPage` / `T1DialoguePage` / `T1SpeakingIndicator`, carte `EO_T1_LIVE` gatée Premium (`oral_t2_live`). Parcours simplifié carte → prépa → dialogue. `T1LiveQuestionnairePage` + `T1LiveContext` retirés. Réutilise les hooks audio T2 (FTD-44 gelée). **Bugs amont observés au test manuel** (hors contrôle frontend) : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
|
||||||
|
|
||||||
## Sprint 7.5 — Clean
|
## Sprint 7.5 — Clean
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# TECH_DEBT.md — Expria Frontend
|
# TECH_DEBT.md — Expria Frontend
|
||||||
|
|
||||||
> **Document de référence — Version 1.28**
|
> **Document de référence — Version 1.30**
|
||||||
> Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées.
|
> 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.
|
> À mettre à jour après chaque session de développement.
|
||||||
>
|
>
|
||||||
|
|
@ -456,6 +456,49 @@ Frontend :
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### FTD-44 — Hooks audio génériques empruntés à `features/t2-live/` (T1 Live)
|
||||||
|
|
||||||
|
**Priorité :** 🟡 Important
|
||||||
|
**Statut :** Gelé — réactivé au Sprint 7.5 (« factorisation Sprint 7 »)
|
||||||
|
**Estimation de session :** 0,5 jour
|
||||||
|
**Description :** Le flux T1 Live (Sprint 7b) importe directement les trois hooks audio génériques de `features/t2-live/hooks/` (`useAudioCapture`, `useAudioPlayback`, `useAudioRecording`) — une violation assumée de la frontière inter-features FSD (un feature ne devrait pas importer un sibling). Décision prise pour NE PAS toucher aux fichiers T2 (pipeline audio validé à l'oreille, intouchable jusqu'à factorisation). Sites d'import marqués `// TODO(FTD-44)` dans `features/t1-live/hooks/useT1LiveSession.ts`.
|
||||||
|
|
||||||
|
**À faire :** relocaliser les trois hooks (génériques par nature : aucune logique T2 spécifique) vers `shared/lib/audio/`, puis migrer les imports T2 ET T1 vers ce chemin partagé. Validation à l'oreille obligatoire après déplacement (T2 + T1).
|
||||||
|
|
||||||
|
**Condition de résolution :** Sprint 7.5 (factorisation Sprint 7), une fois les flux T1 et T2 Live stabilisés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FTD-45 — Relances Gemini T1 Live hors-sujet (extension TD-23)
|
||||||
|
|
||||||
|
**Priorité :** 🟡 Important
|
||||||
|
**Statut :** Gelé — dépend de l'amont (Gemini / backend TD-23), hors contrôle frontend
|
||||||
|
**Estimation de session :** à évaluer (chantier backend/prompt)
|
||||||
|
**Description :** En T1 Live, l'examinateur (Gemini) formule ses relances à partir de son contexte audio interne. Au test manuel, certaines relances partent **hors-sujet** (sans rapport avec ce que le candidat vient de dire). Extension de la dette backend **TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd`, donc le modèle relance sans transcription token-par-token fiable.
|
||||||
|
|
||||||
|
**Impact actuel :** dégrade le réalisme de l'entretien T1 ; non bloquant pour la livraison 7b (le flux fonctionne, l'évaluation finale reste correcte).
|
||||||
|
|
||||||
|
**À faire :** ré-évaluer côté backend/prompt (formulation de la consigne de relance, fenêtre de contexte) une fois la transcription incrémentale repensée (Sprint 7e / TD-23).
|
||||||
|
|
||||||
|
**Condition de résolution :** après traitement de TD-23 (transcription live) — non actionnable côté frontend seul.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FTD-46 — Transcription Gemini Live hasardeuse (qualité audio→texte)
|
||||||
|
|
||||||
|
**Priorité :** 🟡 Important
|
||||||
|
**Statut :** Gelé — dépend de l'amont (qualité Gemini Live), hors contrôle frontend
|
||||||
|
**Estimation de session :** à évaluer
|
||||||
|
**Description :** La transcription produite par Gemini Live (`input/outputTranscription`) est de qualité **inégale** : mots manqués, segments approximatifs. Observé au test manuel T1 Live (et applicable au T2 Live). Affecte la fidélité du transcript utilisé pour l'évaluation et bloquera l'affichage live (Sprint 7e).
|
||||||
|
|
||||||
|
**Impact actuel :** qualité du transcript variable ; non bloquant pour 7b (l'évaluation 5 critères reste exploitable).
|
||||||
|
|
||||||
|
**À faire :** suivre l'évolution du modèle Gemini Live ; évaluer un post-traitement ou une source de transcription alternative si la qualité reste insuffisante au Sprint 7e.
|
||||||
|
|
||||||
|
**Condition de résolution :** amélioration amont (modèle) ou décision d'architecture transcription au Sprint 7e.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4. Tests à renforcer
|
## 4. Tests à renforcer
|
||||||
|
|
||||||
> FTD-09 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
|
> FTD-09 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
|
||||||
|
|
@ -555,3 +598,5 @@ Frontend :
|
||||||
| 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.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.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). |
|
| 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). |
|
||||||
|
| 1.29 | 2026-06-30 | Sprint 7b (T1 Live) — Ajout FTD-44 🟡 **gelée** (hooks audio génériques empruntés à `features/t2-live/`, réactivée au Sprint 7.5). **14 FTD actives** (inchangé — entrée gelée, ne compte pas dans le cap, même mécanique que FTD-06). |
|
||||||
|
| 1.30 | 2026-06-30 | Sprint 7b (T1 Live, finalisation) — Ajout FTD-45 🟡 **gelée** (relances Gemini hors-sujet, extension TD-23) et FTD-46 🟡 **gelée** (transcription Gemini Live hasardeuse). Bugs amont observés au test manuel, hors contrôle frontend. **14 FTD actives** (inchangé — entrées gelées, ne comptent pas dans le cap). |
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext'
|
||||||
import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
|
import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
|
||||||
import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
|
import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
|
||||||
import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
|
import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
|
||||||
|
import { T1PreparationPage } from '@/features/t1-live/pages/T1PreparationPage'
|
||||||
|
import { T1DialoguePage } from '@/features/t1-live/pages/T1DialoguePage'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
const DesignSystemPage = import.meta.env.DEV
|
const DesignSystemPage = import.meta.env.DEV
|
||||||
|
|
@ -105,6 +107,12 @@ export function AppRouter() {
|
||||||
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
|
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Sprint 7b — T1 Live (Premium) : prépa 2min → dialogue 3:00 avec
|
||||||
|
interruption non déterministe de l'examinateur. Entrée directe depuis
|
||||||
|
la carte TaskSelector (plus de questionnaire — Patch 7a backend). */}
|
||||||
|
<Route path="/simulation/eo/t1/live/preparation" element={<T1PreparationPage />} />
|
||||||
|
<Route path="/simulation/eo/t1/live/dialogue" element={<T1DialoguePage />} />
|
||||||
|
|
||||||
{/* Autres sections — Sprint 4+ */}
|
{/* Autres sections — Sprint 4+ */}
|
||||||
<Route path="/examen" element={<ComingSoon />} />
|
<Route path="/examen" element={<ComingSoon />} />
|
||||||
<Route path="/progression" element={<ProgressionPage />} />
|
<Route path="/progression" element={<ProgressionPage />} />
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,22 @@ interface Props {
|
||||||
* reste verrouillée.
|
* reste verrouillée.
|
||||||
*/
|
*/
|
||||||
onT2LiveSelect?: () => void
|
onT2LiveSelect?: () => void
|
||||||
|
/**
|
||||||
|
* Sprint 7b — handler dédié pour la carte EO_T1_LIVE. Même gating Premium
|
||||||
|
* que T2 Live (`oral_t2_live`, TD-24 — pas de permission distincte). La
|
||||||
|
* production EO_T1 est créée en backend en fin de session, pas au clic.
|
||||||
|
*/
|
||||||
|
onT1LiveSelect?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskCard {
|
interface TaskCard {
|
||||||
key: string
|
key: string
|
||||||
tache: Tache | null // null = carte verrouillée (EO_T2 Live)
|
tache: Tache | null // null = carte non sélectionnable directement (Live → handler dédié)
|
||||||
label: string
|
label: string
|
||||||
sublabel: string
|
sublabel: string
|
||||||
lockLabel?: string
|
lockLabel?: string
|
||||||
|
/** Sprint 7b — carte « Live » (T1 ou T2) : gating Premium via hasAccess. */
|
||||||
|
live?: 'T1' | 'T2'
|
||||||
}
|
}
|
||||||
|
|
||||||
const EE_CARDS: readonly TaskCard[] = [
|
const EE_CARDS: readonly TaskCard[] = [
|
||||||
|
|
@ -52,12 +60,21 @@ const EE_CARDS: readonly TaskCard[] = [
|
||||||
const EO_CARDS: readonly TaskCard[] = [
|
const EO_CARDS: readonly TaskCard[] = [
|
||||||
{ key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' },
|
{ key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' },
|
||||||
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
|
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
|
||||||
|
{
|
||||||
|
key: 'EO_T1_LIVE',
|
||||||
|
tache: null,
|
||||||
|
label: 'Expression Orale',
|
||||||
|
sublabel: 'Tâche 1 — Live',
|
||||||
|
lockLabel: 'Exclusivité Premium',
|
||||||
|
live: 'T1',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'EO_T2_LIVE',
|
key: 'EO_T2_LIVE',
|
||||||
tache: 'EO_T2_LIVE',
|
tache: 'EO_T2_LIVE',
|
||||||
label: 'Expression Orale',
|
label: 'Expression Orale',
|
||||||
sublabel: 'Tâche 2 — Live',
|
sublabel: 'Tâche 2 — Live',
|
||||||
lockLabel: 'Exclusivité Premium',
|
lockLabel: 'Exclusivité Premium',
|
||||||
|
live: 'T2',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -68,10 +85,13 @@ export function TaskSelector({
|
||||||
isLoading,
|
isLoading,
|
||||||
onSelect,
|
onSelect,
|
||||||
onT2LiveSelect,
|
onT2LiveSelect,
|
||||||
|
onT1LiveSelect,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||||
const quotaBlocked = !simulationCheck.allowed
|
const quotaBlocked = !simulationCheck.allowed
|
||||||
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
|
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
|
||||||
|
// TD-24 : T1 Live ET T2 Live partagent la même permission `oral_t2_live`.
|
||||||
|
const t1LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT1LiveSelect)
|
||||||
const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
|
const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,16 +118,21 @@ export function TaskSelector({
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const isT2Live = card.tache === 'EO_T2_LIVE'
|
const isT1Live = card.live === 'T1'
|
||||||
// T2 Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
|
const isT2Live = card.live === 'T2'
|
||||||
// Verrouillage : T2 Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
|
// Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
|
||||||
const locked = isT2Live ? !t2LiveUnlocked : card.tache === null || quotaBlocked
|
// Verrouillage : Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
|
||||||
|
const locked = isT1Live
|
||||||
|
? !t1LiveUnlocked
|
||||||
|
: isT2Live
|
||||||
|
? !t2LiveUnlocked
|
||||||
|
: card.tache === null || quotaBlocked
|
||||||
const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
|
const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
return (
|
return (
|
||||||
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
|
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
|
||||||
{(card.tache === null || isT2Live) && (
|
{(card.tache === null || isT1Live || isT2Live) && (
|
||||||
<Lock className="mb-2 size-4 text-ink-secondary" aria-hidden="true" />
|
<Lock className="mb-2 size-4 text-ink-secondary" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
|
||||||
|
|
@ -128,11 +153,15 @@ export function TaskSelector({
|
||||||
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
|
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
|
if (isT1Live && onT1LiveSelect) {
|
||||||
|
onT1LiveSelect()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isT2Live && onT2LiveSelect) {
|
if (isT2Live && onT2LiveSelect) {
|
||||||
onT2LiveSelect()
|
onT2LiveSelect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (card.tache && card.tache !== 'EO_T2_LIVE') {
|
if (card.tache && !card.live) {
|
||||||
onSelect({ tache: card.tache, mode: 'entrainement' })
|
onSelect({ tache: card.tache, mode: 'entrainement' })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
78
src/features/simulations/lib/t1Questionnaire.ts
Normal file
78
src/features/simulations/lib/t1Questionnaire.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* t1Questionnaire — définitions partagées du questionnaire Tâche 1 EO.
|
||||||
|
*
|
||||||
|
* Source UNIQUE des 5 champs + schéma Zod, réutilisée par DEUX flux distincts :
|
||||||
|
* - `QuestionnaireT1Page` (batch) : génère une présentation via DeepSeek ;
|
||||||
|
* - `features/t1-live` (live, Sprint 7b) : envoie `{type:'context', reponses}`
|
||||||
|
* au WebSocket T1 comme contexte de l'examinateur.
|
||||||
|
*
|
||||||
|
* PORTÉE STRICTE : ce module ne contient QUE la définition du formulaire (libellés,
|
||||||
|
* placeholders, schéma). AUCUNE logique de génération ni de persistance — le flux
|
||||||
|
* « présentation EO T1 » (localStorage `expria_eo_t1_presentation`, FTD-34/41)
|
||||||
|
* reste entièrement dans `QuestionnaireT1Page` / le domaine `presentation`.
|
||||||
|
*
|
||||||
|
* ADR-004 : `PresentationReponses` n'est PAS redéfini ici — il est importé de
|
||||||
|
* `@/entities/presentation/types`, déjà aligné au backend `validateReponses`
|
||||||
|
* (mêmes champs). Le backend n'exige qu'un trim non vide ; le `max(FIELD_MAX)`
|
||||||
|
* ci-dessous est une contrainte UX frontend (plus stricte, donc sûre).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { PresentationReponses } from '@/entities/presentation/types'
|
||||||
|
|
||||||
|
export const FIELD_MAX = 500
|
||||||
|
|
||||||
|
export const reponsesSchema = z.object({
|
||||||
|
prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||||
|
formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||||
|
situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||||
|
loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||||
|
motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FieldKey = keyof PresentationReponses
|
||||||
|
|
||||||
|
export interface FieldDef {
|
||||||
|
key: FieldKey
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
multiline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const T1_QUESTIONNAIRE_FIELDS: FieldDef[] = [
|
||||||
|
{
|
||||||
|
key: 'prenom_age_ville',
|
||||||
|
label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
|
||||||
|
placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'formation_metier',
|
||||||
|
label: 'Quelle est ta formation et ton métier actuel ou passé ?',
|
||||||
|
placeholder: 'Ex : Master en gestion, comptable dans une PME',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'situation_familiale',
|
||||||
|
label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
|
||||||
|
placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loisirs',
|
||||||
|
label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
|
||||||
|
placeholder: 'Ex : Lecture, cuisine, randonnée',
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'motivation_canada',
|
||||||
|
label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
|
||||||
|
placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const EMPTY_REPONSES: PresentationReponses = {
|
||||||
|
prenom_age_ville: '',
|
||||||
|
formation_metier: '',
|
||||||
|
situation_familiale: '',
|
||||||
|
loisirs: '',
|
||||||
|
motivation_canada: '',
|
||||||
|
}
|
||||||
|
|
@ -14,69 +14,18 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { z } from 'zod'
|
|
||||||
import { Button } from '@/shared/ui/Button'
|
import { Button } from '@/shared/ui/Button'
|
||||||
import type { ApiError } from '@/shared/types/api'
|
import type { ApiError } from '@/shared/types/api'
|
||||||
import { generatePresentation } from '@/entities/presentation/api'
|
import { generatePresentation } from '@/entities/presentation/api'
|
||||||
import type { PresentationReponses } from '@/entities/presentation/types'
|
import type { PresentationReponses } from '@/entities/presentation/types'
|
||||||
import { useSimulationFlow } from '../state/simulationFlow'
|
import { useSimulationFlow } from '../state/simulationFlow'
|
||||||
|
import {
|
||||||
const FIELD_MAX = 500
|
EMPTY_REPONSES,
|
||||||
|
FIELD_MAX,
|
||||||
const reponsesSchema = z.object({
|
T1_QUESTIONNAIRE_FIELDS,
|
||||||
prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
reponsesSchema,
|
||||||
formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
type FieldKey,
|
||||||
situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
} from '../lib/t1Questionnaire'
|
||||||
loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
|
||||||
motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FieldKey = keyof PresentationReponses
|
|
||||||
|
|
||||||
interface FieldDef {
|
|
||||||
key: FieldKey
|
|
||||||
label: string
|
|
||||||
placeholder: string
|
|
||||||
multiline?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIELDS: FieldDef[] = [
|
|
||||||
{
|
|
||||||
key: 'prenom_age_ville',
|
|
||||||
label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
|
|
||||||
placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'formation_metier',
|
|
||||||
label: 'Quelle est ta formation et ton métier actuel ou passé ?',
|
|
||||||
placeholder: 'Ex : Master en gestion, comptable dans une PME',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'situation_familiale',
|
|
||||||
label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
|
|
||||||
placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'loisirs',
|
|
||||||
label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
|
|
||||||
placeholder: 'Ex : Lecture, cuisine, randonnée',
|
|
||||||
multiline: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'motivation_canada',
|
|
||||||
label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
|
|
||||||
placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
|
|
||||||
multiline: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const EMPTY_REPONSES: PresentationReponses = {
|
|
||||||
prenom_age_ville: '',
|
|
||||||
formation_metier: '',
|
|
||||||
situation_familiale: '',
|
|
||||||
loisirs: '',
|
|
||||||
motivation_canada: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputBase =
|
const inputBase =
|
||||||
'w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus'
|
'w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus'
|
||||||
|
|
@ -178,7 +127,7 @@ export function QuestionnaireT1Page() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||||
{FIELDS.map((field) => {
|
{T1_QUESTIONNAIRE_FIELDS.map((field) => {
|
||||||
const value = reponses[field.key]
|
const value = reponses[field.key]
|
||||||
const showError = touched[field.key] && fieldErrors[field.key]
|
const showError = touched[field.key] && fieldErrors[field.key]
|
||||||
const id = `q-${field.key}`
|
const id = `q-${field.key}`
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export function SimulationEOPage() {
|
||||||
isLoading={isCreating}
|
isLoading={isCreating}
|
||||||
onSelect={selectTask}
|
onSelect={selectTask}
|
||||||
onT2LiveSelect={() => navigate('/simulation/eo/t2')}
|
onT2LiveSelect={() => navigate('/simulation/eo/t2')}
|
||||||
|
onT1LiveSelect={() => navigate('/simulation/eo/t1/live/preparation')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{taskUnavailableMessage && (
|
{taskUnavailableMessage && (
|
||||||
|
|
|
||||||
114
src/features/t1-live/components/T1SpeakingIndicator.tsx
Normal file
114
src/features/t1-live/components/T1SpeakingIndicator.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* T1SpeakingIndicator — Indicateur de prise de parole T1 Live (Sprint 7b).
|
||||||
|
*
|
||||||
|
* Calqué sur T2SpeakingIndicator (mêmes garde-fous Voie A : analyser dérivé du
|
||||||
|
* graphe de capture lu par ref en rAF, écriture DOM directe, aucun setState).
|
||||||
|
* Sémantique adaptée au monologue T1 :
|
||||||
|
*
|
||||||
|
* - 'presenting' → le candidat présente : barres pilotées par l'amplitude micro
|
||||||
|
* RÉELLE (analyser).
|
||||||
|
* - 'interrupted' → l'examinateur a pris la parole : barres décoratives pilotées
|
||||||
|
* par l'ÉTAT (CSS), sans sonde audio (l'uplink micro est coupé).
|
||||||
|
* - autres états → rien (la page affiche le libellé d'état).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
import { Mic, Volume2 } from 'lucide-react'
|
||||||
|
import type { T1State } from '../state/t1-machine'
|
||||||
|
|
||||||
|
const BAR_COUNT = 5
|
||||||
|
|
||||||
|
interface T1SpeakingIndicatorProps {
|
||||||
|
/** Analyser dérivé du graphe de capture (par ref, jamais en state). */
|
||||||
|
analyserRef: RefObject<AnalyserNode | null>
|
||||||
|
state: T1State
|
||||||
|
}
|
||||||
|
|
||||||
|
export function T1SpeakingIndicator({ analyserRef, state }: T1SpeakingIndicatorProps) {
|
||||||
|
const barRefs = useRef<Array<HTMLSpanElement | null>>([])
|
||||||
|
const rafRef = useRef<number | null>(null)
|
||||||
|
const dataRef = useRef<Uint8Array<ArrayBuffer> | null>(null)
|
||||||
|
|
||||||
|
// rAF actif UNIQUEMENT en 'presenting' : lit l'analyser micro par ref et écrit
|
||||||
|
// la hauteur des barres directement dans le DOM (aucun setState).
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'presenting') return
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!active) return
|
||||||
|
const analyser = analyserRef.current
|
||||||
|
if (analyser) {
|
||||||
|
if (!dataRef.current || dataRef.current.length !== analyser.fftSize) {
|
||||||
|
dataRef.current = new Uint8Array(analyser.fftSize)
|
||||||
|
}
|
||||||
|
analyser.getByteTimeDomainData(dataRef.current)
|
||||||
|
let sumSq = 0
|
||||||
|
for (let i = 0; i < dataRef.current.length; i++) {
|
||||||
|
const v = (dataRef.current[i]! - 128) / 128
|
||||||
|
sumSq += v * v
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sumSq / dataRef.current.length)
|
||||||
|
const now = performance.now()
|
||||||
|
for (let i = 0; i < barRefs.current.length; i++) {
|
||||||
|
const el = barRefs.current[i]
|
||||||
|
if (!el) continue
|
||||||
|
const wave = 0.55 + 0.45 * Math.sin(now / 110 + i * 0.9)
|
||||||
|
const h = Math.max(14, Math.min(100, 14 + rms * 260 * wave))
|
||||||
|
el.style.height = `${h}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
if (rafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafRef.current)
|
||||||
|
rafRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state, analyserRef])
|
||||||
|
|
||||||
|
if (state === 'presenting') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-3 rounded-md border border-success/40 bg-success-soft px-4 py-3">
|
||||||
|
<Mic className="size-4 text-success" aria-hidden="true" />
|
||||||
|
<div className="flex h-6 items-center gap-1" aria-label="Niveau de votre voix">
|
||||||
|
{Array.from({ length: BAR_COUNT }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
barRefs.current[i] = el
|
||||||
|
}}
|
||||||
|
className="w-1 rounded-full bg-success"
|
||||||
|
style={{ height: '14%' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-success">À vous — présentez-vous</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'interrupted') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-3 rounded-md border border-border bg-surface px-4 py-3">
|
||||||
|
<Volume2 className="size-4 text-brand-text" aria-hidden="true" />
|
||||||
|
<div className="flex h-6 items-end gap-1" aria-hidden="true">
|
||||||
|
{Array.from({ length: BAR_COUNT }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-1 animate-pulse rounded-full bg-brand-text/70 motion-reduce:animate-none"
|
||||||
|
style={{ height: `${40 + (i % 3) * 25}%`, animationDelay: `${i * 110}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-ink-secondary">L’examinateur vous interrompt…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
453
src/features/t1-live/hooks/useT1LiveSession.ts
Normal file
453
src/features/t1-live/hooks/useT1LiveSession.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
/**
|
||||||
|
* useT1LiveSession — Hook orchestrateur du dialogue T1 Live (Sprint 7b).
|
||||||
|
*
|
||||||
|
* Calqué sur useT2LiveSession (même discipline Voie A : AudioContext unique,
|
||||||
|
* jamais de MediaStream en state, flags pilotés par ref dans le chemin audio).
|
||||||
|
* MAIS la sémantique T1 diffère fondamentalement :
|
||||||
|
*
|
||||||
|
* 1. URL `wss://${API_URL}/t1/live?token=<jwt>` — PAS de `&sujet=` : la Tâche 1
|
||||||
|
* n'est PAS subject-based. L'examinateur formule ses relances à partir de ce
|
||||||
|
* qu'il ENTEND en temps réel (Patch 7a backend — plus de questionnaire).
|
||||||
|
* 2. AUCUN message de contexte. La session audio démarre dès `ws.onopen`
|
||||||
|
* (WS_OPENED → presenting) : le candidat envoie directement son audio.
|
||||||
|
* 3. AUCUN VAD micro (contrairement à T2). T1 est un MONOLOGUE : c'est le BACKEND
|
||||||
|
* (horloge probabiliste) qui décide quand l'examinateur interrompt. Le frontend
|
||||||
|
* réagit aux signaux applicatifs `{type:'interruption_start'}` /
|
||||||
|
* `{type:'interruption_end'}` → dispatch INTERRUPTION_START / INTERRUPTION_END.
|
||||||
|
* 4. Pendant une interruption, l'uplink micro est COUPÉ (l'examinateur a la
|
||||||
|
* parole) via un ref (`uplinkMutedRef`) — jamais via setState, pour ne pas
|
||||||
|
* perturber le chemin source→worklet→WS (leçon Voie A).
|
||||||
|
* 5. Timer dur 180 s côté frontend (redondant avec le backend, warning 150 s
|
||||||
|
* émis par `{type:'warning'}`).
|
||||||
|
* 6. Gère les close codes (1000, 4001, 4003, 4005, 4006).
|
||||||
|
*
|
||||||
|
* Validation : test manuel uniquement (WebSocket + AudioContext non testables en
|
||||||
|
* jsdom — la logique pure de transition est couverte par t1-machine.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { env } from '@/shared/config/env'
|
||||||
|
import { getAccessToken } from '@/shared/lib/auth-client'
|
||||||
|
// TODO(FTD-44): hooks audio génériques empruntés à features/t2-live/ (violation
|
||||||
|
// FSD inter-features assumée et tracée). À relocaliser vers shared/lib/audio/
|
||||||
|
// au Sprint 7.5 (« factorisation Sprint 7 »). Cf. TECH_DEBT.md §3bis.
|
||||||
|
import { useAudioCapture } from '@/features/t2-live/hooks/useAudioCapture'
|
||||||
|
import { useAudioPlayback } from '@/features/t2-live/hooks/useAudioPlayback'
|
||||||
|
import { useAudioRecording } from '@/features/t2-live/hooks/useAudioRecording'
|
||||||
|
import { transition, T1_INITIAL_STATE, type T1State, type T1Event } from '../state/t1-machine'
|
||||||
|
|
||||||
|
const DIALOGUE_TIMEOUT_MS = 180_000 // 3 min
|
||||||
|
const WS_PING_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
export interface UseT1LiveSessionOptions {
|
||||||
|
/** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
|
||||||
|
onReportReady?: (simulationId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseT1LiveSessionResult {
|
||||||
|
state: T1State
|
||||||
|
startDialogue: () => Promise<void>
|
||||||
|
endDialogue: () => void
|
||||||
|
/** Abandon : ferme le WS sans déclencher d'évaluation ni de persistance. */
|
||||||
|
cancelDialogue: () => 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
|
||||||
|
/**
|
||||||
|
* AnalyserNode dérivé du graphe de capture (par ref stable) — pour
|
||||||
|
* l'indicateur de prise de parole, sans re-render ni sonde sur le flux montant.
|
||||||
|
*/
|
||||||
|
analyserRef: RefObject<AnalyserNode | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
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' | 'interruption_start' | 'interruption_end'
|
||||||
|
data?: { simulation_id?: string } & Record<string, unknown>
|
||||||
|
message?: string
|
||||||
|
code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(token: string): string {
|
||||||
|
const base = env.VITE_API_URL.replace(/^http/, 'ws')
|
||||||
|
return `${base}/t1/live?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useT1LiveSession(opts: UseT1LiveSessionOptions = {}): UseT1LiveSessionResult {
|
||||||
|
const { onReportReady } = opts
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [state, setState] = useState<T1State>(T1_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)
|
||||||
|
// Uplink micro coupé pendant une interruption (l'examinateur a la parole).
|
||||||
|
// Piloté par ref — JAMAIS setState — pour ne pas perturber le chemin audio
|
||||||
|
// source→worklet→WS (leçon Voie A : un setState dans ce chemin affame l'uplink).
|
||||||
|
const uplinkMutedRef = useRef(false)
|
||||||
|
// Sprint 6d (repris) — token de cancellation pour rendre `startDialogue`
|
||||||
|
// idempotent sur les appels rapprochés (StrictMode dev double-mount, etc.).
|
||||||
|
const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
|
||||||
|
|
||||||
|
const recording = useAudioRecording()
|
||||||
|
|
||||||
|
// Déclaré avant `capture` car onChunk en dépend.
|
||||||
|
const dispatch = useCallback((event: T1Event) => {
|
||||||
|
setState((prev) => transition(prev, event))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Capture branchée à l'envoi WS. Aucun VAD (T1 = monologue) : on transmet le
|
||||||
|
// chunk uplink tel quel SAUF pendant une interruption (uplinkMutedRef).
|
||||||
|
const capture = useAudioCapture({
|
||||||
|
onChunk: (base64: string) => {
|
||||||
|
if (uplinkMutedRef.current) return
|
||||||
|
const ws = wsRef.current
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'audio', data: base64 }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Playback déclaré APRÈS capture : il consomme le contexte + le mix PARTAGÉS
|
||||||
|
// (Voie A — horloge unique, voix examinateur routée vers le mix + destination).
|
||||||
|
const playback = useAudioPlayback({
|
||||||
|
contextRef: capture.contextRef,
|
||||||
|
mixNodeRef: capture.mixNodeRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeAll = useCallback(() => {
|
||||||
|
sessionEndedRef.current = true
|
||||||
|
cleanupTimers()
|
||||||
|
if (cancelTokenRef.current) {
|
||||||
|
cancelTokenRef.current.cancelled = true
|
||||||
|
cancelTokenRef.current = null
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
}, [capture, cleanupTimers])
|
||||||
|
|
||||||
|
const handleAudioReceived = useCallback(
|
||||||
|
(base64: string) => {
|
||||||
|
// Voie A : playChunk route la voix examinateur vers destination ET vers le
|
||||||
|
// mixGain ; le tap d'enregistrement la capte sur ce mix en temps réel.
|
||||||
|
playback.playChunk(base64)
|
||||||
|
},
|
||||||
|
[playback],
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleAudioReceived],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAppMessage = useCallback(
|
||||||
|
(msg: AppMessage) => {
|
||||||
|
if (msg.type === 'warning') {
|
||||||
|
setWarning(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Interruption NON DÉTERMINISTE pilotée par le backend (jamais déduite côté
|
||||||
|
// front). L'examinateur prend / rend la parole : on coupe / rétablit l'uplink
|
||||||
|
// micro via ref et on dispatche la transition d'état.
|
||||||
|
if (msg.type === 'interruption_start') {
|
||||||
|
uplinkMutedRef.current = true
|
||||||
|
dispatch({ type: 'INTERRUPTION_START' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'interruption_end') {
|
||||||
|
uplinkMutedRef.current = false
|
||||||
|
dispatch({ type: 'INTERRUPTION_END' })
|
||||||
|
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) => {
|
||||||
|
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) {
|
||||||
|
console.warn('[T1] 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' ||
|
||||||
|
parsed.type === 'interruption_start' ||
|
||||||
|
parsed.type === 'interruption_end'
|
||||||
|
) {
|
||||||
|
handleAppMessage(parsed as AppMessage)
|
||||||
|
} else if (parsed.serverContent) {
|
||||||
|
handleGeminiMessage(parsed as GeminiMessage)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* JSON malformé — ignorer */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleAppMessage, handleGeminiMessage],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Indirection par ref : le binding ws.onmessage appelle TOUJOURS le handler
|
||||||
|
// courant (immunisé au gel de closure / HMR). Cf. INSTRUMENT REPAIR 6e (T2).
|
||||||
|
const handleWsMessageRef = useRef(handleWsMessage)
|
||||||
|
useEffect(() => {
|
||||||
|
handleWsMessageRef.current = handleWsMessage
|
||||||
|
}, [handleWsMessage])
|
||||||
|
|
||||||
|
const handleWsClose = useCallback(
|
||||||
|
(evt: CloseEvent) => {
|
||||||
|
if (sessionEndedRef.current) return
|
||||||
|
sessionEndedRef.current = true
|
||||||
|
cleanupTimers()
|
||||||
|
recording.stop()
|
||||||
|
capture.stop()
|
||||||
|
|
||||||
|
switch (evt.code) {
|
||||||
|
case 1000:
|
||||||
|
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 1 Live est réservée au plan Premium.')
|
||||||
|
dispatch({ type: 'ERROR', code: 4003 })
|
||||||
|
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, recording, state],
|
||||||
|
)
|
||||||
|
|
||||||
|
const startDialogue = useCallback(async () => {
|
||||||
|
if (wsRef.current || cancelTokenRef.current) return
|
||||||
|
const localToken = { cancelled: false }
|
||||||
|
cancelTokenRef.current = localToken
|
||||||
|
|
||||||
|
setErrorMessage(null)
|
||||||
|
setWarning(false)
|
||||||
|
sessionEndedRef.current = false
|
||||||
|
uplinkMutedRef.current = false
|
||||||
|
dispatch({ type: 'START_DIALOGUE' })
|
||||||
|
|
||||||
|
const token = await getAccessToken()
|
||||||
|
if (localToken.cancelled) return
|
||||||
|
if (!token) {
|
||||||
|
cancelTokenRef.current = null
|
||||||
|
setErrorMessage('Authentification requise.')
|
||||||
|
dispatch({ type: 'ERROR', code: 4001 })
|
||||||
|
navigate('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws: WebSocket
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(buildWsUrl(token))
|
||||||
|
ws.binaryType = 'arraybuffer'
|
||||||
|
} catch (err) {
|
||||||
|
cancelTokenRef.current = null
|
||||||
|
const message = err instanceof Error ? err.message : 'Connexion impossible'
|
||||||
|
setErrorMessage(message)
|
||||||
|
dispatch({ type: 'ERROR' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (localToken.cancelled) {
|
||||||
|
try {
|
||||||
|
ws.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Aucun message de contexte (Patch 7a backend) : la session audio démarre
|
||||||
|
// dès l'ouverture du WS, le candidat a la parole (monologue).
|
||||||
|
dispatch({ type: 'WS_OPENED' })
|
||||||
|
|
||||||
|
// Démarrer la capture micro PUIS brancher le tap d'enregistrement sur le
|
||||||
|
// contexte + mixGain (qui n'existent qu'après résolution de capture.start()).
|
||||||
|
void capture.start().then(() => {
|
||||||
|
const ctx = capture.contextRef.current
|
||||||
|
const mix = capture.mixNodeRef.current
|
||||||
|
if (ctx && mix) {
|
||||||
|
recording.reset()
|
||||||
|
void recording.start(ctx, mix)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
elapsedTimerRef.current = setInterval(() => {
|
||||||
|
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
|
||||||
|
}, 250)
|
||||||
|
|
||||||
|
// Timeout dur frontend (redondance avec le 180 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)
|
||||||
|
|
||||||
|
pingTimerRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'ping' }))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, WS_PING_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
ws.onmessage = (evt) => handleWsMessageRef.current(evt)
|
||||||
|
ws.onclose = handleWsClose
|
||||||
|
ws.onerror = () => {
|
||||||
|
// 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
|
||||||
|
}
|
||||||
|
}, [capture, dispatch, handleWsClose, navigate, recording])
|
||||||
|
|
||||||
|
const endDialogue = useCallback(() => {
|
||||||
|
if (sessionEndedRef.current) return
|
||||||
|
cleanupTimers()
|
||||||
|
recording.stop()
|
||||||
|
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, recording])
|
||||||
|
|
||||||
|
// Abandon utilisateur : ferme le WS SANS envoyer `{type:'end'}` → le backend ne
|
||||||
|
// déclenche NI correction NI persistance. Machine → 'idle' via CANCEL.
|
||||||
|
const cancelDialogue = useCallback(() => {
|
||||||
|
if (sessionEndedRef.current) return
|
||||||
|
closeAll()
|
||||||
|
playback.stop()
|
||||||
|
dispatch({ type: 'CANCEL' })
|
||||||
|
}, [closeAll, playback, dispatch])
|
||||||
|
|
||||||
|
// Cleanup au démontage UNIQUEMENT (cf. T2 : ref tenant la dernière version de
|
||||||
|
// closeAll + effet à deps vides, pour ne pas fermer le WS à chaque render).
|
||||||
|
const closeAllRef = useRef(closeAll)
|
||||||
|
useEffect(() => {
|
||||||
|
closeAllRef.current = closeAll
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
closeAllRef.current()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
startDialogue,
|
||||||
|
endDialogue,
|
||||||
|
cancelDialogue,
|
||||||
|
warning,
|
||||||
|
errorMessage,
|
||||||
|
simulationId,
|
||||||
|
recording,
|
||||||
|
elapsedSeconds,
|
||||||
|
analyserRef: capture.analyserRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/features/t1-live/pages/T1DialoguePage.tsx
Normal file
237
src/features/t1-live/pages/T1DialoguePage.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
/**
|
||||||
|
* Page /simulation/eo/t1/live/dialogue — phase de dialogue live T1 (Sprint 7b).
|
||||||
|
*
|
||||||
|
* Démarre la session WS au mount, pilote l'UI selon l'état machine T1, affiche le
|
||||||
|
* timer 3:00 et l'indicateur d'état. Spécificité T1 : l'examinateur peut
|
||||||
|
* INTERROMPRE le monologue de façon NON DÉTERMINISTE (état `interrupted`) — l'UI
|
||||||
|
* ne suppose JAMAIS qu'une relance suit. À la fin (REPORT_READY), écran terminal
|
||||||
|
* avec « Télécharger l'audio » + « Voir le rapport ».
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Mic, Volume2, Download, FileText, Loader2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
import { T1SpeakingIndicator } from '../components/T1SpeakingIndicator'
|
||||||
|
import { useT1LiveSession } from '../hooks/useT1LiveSession'
|
||||||
|
|
||||||
|
const DIALOGUE_SECONDS = 180 // 3:00
|
||||||
|
|
||||||
|
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 T1DialoguePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [autoStarted, setAutoStarted] = useState(false)
|
||||||
|
|
||||||
|
const session = useT1LiveSession()
|
||||||
|
|
||||||
|
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoStarted) return
|
||||||
|
setAutoStarted(true)
|
||||||
|
void session.startDialogue()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [autoStarted])
|
||||||
|
|
||||||
|
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
|
||||||
|
const stateLabel = (() => {
|
||||||
|
switch (session.state) {
|
||||||
|
case 'idle':
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connexion à l’examinateur…'
|
||||||
|
case 'presenting':
|
||||||
|
return 'À vous — présentez-vous.'
|
||||||
|
case 'interrupted':
|
||||||
|
return 'L’examinateur vous interrompt — répondez-lui.'
|
||||||
|
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-t1-${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
|
||||||
|
navigate(`/rapport/${session.simulationId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRestart() {
|
||||||
|
navigate('/simulation/eo/t1/live/preparation')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandon : ferme la session sans évaluation (cancelDialogue ne déclenche ni
|
||||||
|
// correction ni persistance), puis sortie du flux.
|
||||||
|
function handleCancel() {
|
||||||
|
session.cancelDialogue()
|
||||||
|
navigate('/simulation/eo')
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Annuler » / « Terminer » n'ont de sens que pendant la session active
|
||||||
|
// (connexion, présentation ou interruption), pas en évaluation.
|
||||||
|
const canCancel =
|
||||||
|
session.state === 'connecting' ||
|
||||||
|
session.state === 'presenting' ||
|
||||||
|
session.state === 'interrupted'
|
||||||
|
|
||||||
|
// ── É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 présentation a été évaluée. 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={handleRestart}>
|
||||||
|
Recommencer
|
||||||
|
</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">Présentation 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 présentation"
|
||||||
|
>
|
||||||
|
{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" />
|
||||||
|
) : session.state === 'interrupted' ? (
|
||||||
|
<Volume2 className="size-5 text-brand-text" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Mic
|
||||||
|
className={
|
||||||
|
session.state === 'presenting'
|
||||||
|
? 'size-5 text-success'
|
||||||
|
: 'size-5 text-ink-secondary'
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
|
||||||
|
</div>
|
||||||
|
{/* Indicateur : 'presenting' = amplitude micro réelle (analyser lu par
|
||||||
|
ref en rAF) ; 'interrupted' = animation décorative (uplink coupé). */}
|
||||||
|
{canCancel && (
|
||||||
|
<T1SpeakingIndicator analyserRef={session.analyserRef} state={session.state} />
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-ink-secondary">
|
||||||
|
Présentez-vous naturellement. L'examinateur peut vous interrompre à tout moment.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
{canCancel && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-danger hover:bg-danger-soft hover:text-danger"
|
||||||
|
onClick={handleCancel}
|
||||||
|
title="Abandonner sans générer de rapport"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => session.endDialogue()}
|
||||||
|
disabled={session.state === 'processing'}
|
||||||
|
>
|
||||||
|
Terminer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-xs text-ink-tertiary">
|
||||||
|
« Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/features/t1-live/pages/T1PreparationPage.tsx
Normal file
122
src/features/t1-live/pages/T1PreparationPage.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* Page /simulation/eo/t1/live/preparation — préparation T1 Live (Sprint 7b).
|
||||||
|
*
|
||||||
|
* Calquée sur T2PreparationPage MAIS sans « Suggestions d'idées » (outil de
|
||||||
|
* questions de service propre au T2, inapplicable à un monologue de présentation
|
||||||
|
* — DÉCISION 3). La prépa T1 = rappel des points de présentation (les 5 réponses
|
||||||
|
* saisies) + pré-warm micro + timer.
|
||||||
|
*
|
||||||
|
* - Timer 2 min visible (countdown), auto-navigation à 0:00.
|
||||||
|
* - Pré-warm permission micro pour éviter la latence au début du dialogue.
|
||||||
|
* - Entrée directe depuis la carte TaskSelector (plus de questionnaire — Patch 7a).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
|
||||||
|
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 T1PreparationPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
|
||||||
|
const [micWarmed, setMicWarmed] = useState<boolean | null>(null)
|
||||||
|
const expiredRef = useRef(false)
|
||||||
|
|
||||||
|
// Pré-warm permission micro (cf. T2PreparationPage — évite 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
|
||||||
|
}
|
||||||
|
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 → auto-navigation vers le dialogue.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setSecondsLeft((s) => {
|
||||||
|
if (s <= 1) {
|
||||||
|
clearInterval(id)
|
||||||
|
if (!expiredRef.current) {
|
||||||
|
expiredRef.current = true
|
||||||
|
queueMicrotask(() => navigate('/simulation/eo/t1/live/dialogue'))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
function handleReady() {
|
||||||
|
navigate('/simulation/eo/t1/live/dialogue')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 1 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="p-4">
|
||||||
|
<p className="text-sm text-ink-secondary">
|
||||||
|
<strong className="text-ink-primary">Comment ça se passe :</strong> à votre signal,
|
||||||
|
présentez-vous à l'oral en quelques phrases. L'examinateur IA pourra vous interrompre
|
||||||
|
spontanément pour vous poser des questions — répondez-lui, puis poursuivez votre
|
||||||
|
présentation.
|
||||||
|
</p>
|
||||||
|
</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="primary" onClick={handleReady}>
|
||||||
|
Je suis prêt — démarrer la présentation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/features/t1-live/state/__tests__/t1-machine.test.ts
Normal file
127
src/features/t1-live/state/__tests__/t1-machine.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { transition, T1_INITIAL_STATE } from '../t1-machine'
|
||||||
|
import type { T1State } from '../t1-machine'
|
||||||
|
|
||||||
|
describe('T1 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('idle → connecting sur START_DIALOGUE (saut de prépa autorisé)', () => {
|
||||||
|
expect(transition('idle', { type: 'START_DIALOGUE' })).toBe('connecting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connecting → presenting sur WS_OPENED (candidat a la parole)', () => {
|
||||||
|
expect(transition('connecting', { type: 'WS_OPENED' })).toBe('presenting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('processing → ended sur REPORT_READY', () => {
|
||||||
|
expect(transition('processing', { type: 'REPORT_READY' })).toBe('ended')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T1 state machine — interruption / reprise (cœur du sprint)', () => {
|
||||||
|
it('presenting → interrupted sur INTERRUPTION_START', () => {
|
||||||
|
expect(transition('presenting', { type: 'INTERRUPTION_START' })).toBe('interrupted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interrupted → presenting sur INTERRUPTION_END', () => {
|
||||||
|
expect(transition('interrupted', { type: 'INTERRUPTION_END' })).toBe('presenting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cycle complet presenting ⇄ interrupted (plusieurs relances possibles)', () => {
|
||||||
|
let s: T1State = 'presenting'
|
||||||
|
s = transition(s, { type: 'INTERRUPTION_START' })
|
||||||
|
expect(s).toBe('interrupted')
|
||||||
|
s = transition(s, { type: 'INTERRUPTION_END' })
|
||||||
|
expect(s).toBe('presenting')
|
||||||
|
s = transition(s, { type: 'INTERRUPTION_START' })
|
||||||
|
expect(s).toBe('interrupted')
|
||||||
|
s = transition(s, { type: 'INTERRUPTION_END' })
|
||||||
|
expect(s).toBe('presenting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('chemin SANS interruption est nominal (presenting → processing → ended)', () => {
|
||||||
|
// Non déterminisme P0=0.2 : une session peut n'avoir AUCUNE relance.
|
||||||
|
let s: T1State = 'presenting'
|
||||||
|
s = transition(s, { type: 'END_REQUESTED' })
|
||||||
|
expect(s).toBe('processing')
|
||||||
|
s = transition(s, { type: 'REPORT_READY' })
|
||||||
|
expect(s).toBe('ended')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INTERRUPTION_START est ignoré hors de presenting', () => {
|
||||||
|
expect(transition('interrupted', { type: 'INTERRUPTION_START' })).toBe('interrupted')
|
||||||
|
expect(transition('connecting', { type: 'INTERRUPTION_START' })).toBe('connecting')
|
||||||
|
expect(transition('processing', { type: 'INTERRUPTION_START' })).toBe('processing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INTERRUPTION_END est ignoré hors de interrupted', () => {
|
||||||
|
expect(transition('presenting', { type: 'INTERRUPTION_END' })).toBe('presenting')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T1 state machine — END_REQUESTED → processing depuis tout état actif', () => {
|
||||||
|
it.each<T1State>(['connecting', 'presenting', 'interrupted'])(
|
||||||
|
'transition %s → processing sur END_REQUESTED',
|
||||||
|
(from) => {
|
||||||
|
expect(transition(from, { type: 'END_REQUESTED' })).toBe('processing')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T1 state machine — ERROR terminal', () => {
|
||||||
|
it.each<T1State>(['idle', 'preparing', 'connecting', 'presenting', 'interrupted', 'processing'])(
|
||||||
|
'transition %s → error sur ERROR',
|
||||||
|
(from) => {
|
||||||
|
expect(transition(from, { type: 'ERROR', code: 4006 })).toBe('error')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('ended est insensible à ERROR (état terminal protégé)', () => {
|
||||||
|
expect(transition('ended', { type: 'ERROR', code: 4006 })).toBe('ended')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T1 state machine — CANCEL (abandon) → idle depuis tout état actif', () => {
|
||||||
|
it.each<T1State>(['preparing', 'connecting', 'presenting', 'interrupted', 'processing'])(
|
||||||
|
'transition %s → idle sur CANCEL',
|
||||||
|
(from) => {
|
||||||
|
expect(transition(from, { type: 'CANCEL' })).toBe('idle')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('CANCEL en idle reste idle (no-op)', () => {
|
||||||
|
expect(transition('idle', { type: 'CANCEL' })).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('états terminaux (ended, error) sont protégés contre CANCEL', () => {
|
||||||
|
expect(transition('ended', { type: 'CANCEL' })).toBe('ended')
|
||||||
|
expect(transition('error', { type: 'CANCEL' })).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T1 state machine — événements invalides ignorés', () => {
|
||||||
|
it('INTERRUPTION_START en idle est ignoré', () => {
|
||||||
|
expect(transition('idle', { type: 'INTERRUPTION_START' })).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('REPORT_READY en presenting est ignoré (doit passer par END_REQUESTED → processing)', () => {
|
||||||
|
expect(transition('presenting', { type: 'REPORT_READY' })).toBe('presenting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('états terminaux (ended, error) sont insensibles aux events nominaux', () => {
|
||||||
|
expect(transition('ended', { type: 'INTERRUPTION_START' })).toBe('ended')
|
||||||
|
expect(transition('error', { type: 'WS_OPENED' })).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('T1_INITIAL_STATE', () => {
|
||||||
|
it('vaut idle', () => {
|
||||||
|
expect(T1_INITIAL_STATE).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
125
src/features/t1-live/state/t1-machine.ts
Normal file
125
src/features/t1-live/state/t1-machine.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* t1-machine — State machine pure pour le flux T1 Live (Sprint 7b).
|
||||||
|
*
|
||||||
|
* Calquée sur t2-machine (même discipline : fonction pure, aucun side-effect,
|
||||||
|
* aucune référence DOM/React/WebSocket — l'orchestration vit dans
|
||||||
|
* useT1LiveSession). MAIS la sémantique diffère fondamentalement :
|
||||||
|
*
|
||||||
|
* - T2 : le candidat initie une interaction de service ; les états
|
||||||
|
* speaking/listening sont pilotés par le VAD micro côté frontend.
|
||||||
|
* - T1 : le candidat fait un MONOLOGUE de présentation. C'est le BACKEND
|
||||||
|
* (horloge probabiliste, Sprint 7a) qui décide QUAND l'examinateur
|
||||||
|
* interrompt. Le frontend ne fait pas de VAD : il réagit aux signaux
|
||||||
|
* {type:'interruption_start'} / {type:'interruption_end'}.
|
||||||
|
*
|
||||||
|
* CŒUR DU SPRINT : l'interruption / reprise est modélisée ICI, dans la machine,
|
||||||
|
* via l'état `interrupted`. L'interruption est NON DÉTERMINISTE (P0=0.2,
|
||||||
|
* P1=0.6, P2=0.2) — la machine ne suppose JAMAIS qu'une relance suit : le
|
||||||
|
* chemin `presenting → processing → ended` SANS aucune interruption est
|
||||||
|
* parfaitement nominal.
|
||||||
|
*
|
||||||
|
* Cycle de vie d'une session :
|
||||||
|
*
|
||||||
|
* idle
|
||||||
|
* └─ START_PREPARATION ─▶ preparing
|
||||||
|
* └─ START_DIALOGUE ─▶ connecting
|
||||||
|
* └─ WS_OPENED ─▶ presenting
|
||||||
|
* ├─ INTERRUPTION_START ─▶ interrupted
|
||||||
|
* │ └─ INTERRUPTION_END ─▶ presenting
|
||||||
|
* ├─ END_REQUESTED ─▶ processing
|
||||||
|
* │ └─ REPORT_READY ─▶ ended
|
||||||
|
* └─ ERROR ─▶ error
|
||||||
|
*
|
||||||
|
* Toute transition non listée est ignorée (état conservé).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type T1State =
|
||||||
|
| 'idle'
|
||||||
|
| 'preparing'
|
||||||
|
| 'connecting'
|
||||||
|
| 'presenting'
|
||||||
|
| 'interrupted'
|
||||||
|
| 'processing'
|
||||||
|
| 'ended'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
export type T1Event =
|
||||||
|
| { type: 'START_PREPARATION' }
|
||||||
|
| { type: 'START_DIALOGUE' }
|
||||||
|
// WS_OPENED — la socket est ouverte : le candidat a la parole (monologue).
|
||||||
|
// Pas d'état 'ready' distinct comme en T2, car aucun VAD ne pilote la prise
|
||||||
|
// de parole, et aucun message de contexte n'est envoyé (Patch 7a backend).
|
||||||
|
| { type: 'WS_OPENED' }
|
||||||
|
// INTERRUPTION_START / END — pilotés par les signaux backend (jamais déduits
|
||||||
|
// côté frontend). L'examinateur prend / rend la parole.
|
||||||
|
| { type: 'INTERRUPTION_START' }
|
||||||
|
| { type: 'INTERRUPTION_END' }
|
||||||
|
| { type: 'END_REQUESTED' }
|
||||||
|
| { type: 'REPORT_READY' }
|
||||||
|
// CANCEL — abandon utilisateur : ferme la session SANS évaluation (cf.
|
||||||
|
// useT1LiveSession.cancelDialogue, qui ferme le WS sans envoyer `{type:'end'}`).
|
||||||
|
// La machine revient à 'idle'.
|
||||||
|
| { type: 'CANCEL' }
|
||||||
|
| { 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: T1State, event: T1Event): T1State {
|
||||||
|
// 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// CANCEL (abandon) bypasse les guards depuis tout état non-terminal et
|
||||||
|
// ramène la machine à 'idle'. Les états terminaux ('ended', 'error') sont
|
||||||
|
// protégés.
|
||||||
|
if (event.type === 'CANCEL' && state !== 'ended' && state !== 'error') {
|
||||||
|
return 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'presenting'
|
||||||
|
if (event.type === 'END_REQUESTED') return 'processing'
|
||||||
|
return state
|
||||||
|
|
||||||
|
case 'presenting':
|
||||||
|
if (event.type === 'INTERRUPTION_START') return 'interrupted'
|
||||||
|
if (event.type === 'END_REQUESTED') return 'processing'
|
||||||
|
return state
|
||||||
|
|
||||||
|
case 'interrupted':
|
||||||
|
if (event.type === 'INTERRUPTION_END') return 'presenting'
|
||||||
|
// « Terminer » ou le timeout 180 s peuvent tomber pendant une relance.
|
||||||
|
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 T1 Live. */
|
||||||
|
export const T1_INITIAL_STATE: T1State = 'idle'
|
||||||
Loading…
Add table
Add a link
Reference in a new issue