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)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@
|
|||
## 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`).
|
||||
- **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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
> À 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
|
||||
|
||||
> 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.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.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 { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
|
||||
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'
|
||||
|
||||
const DesignSystemPage = import.meta.env.DEV
|
||||
|
|
@ -105,6 +107,12 @@ export function AppRouter() {
|
|||
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
|
||||
</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+ */}
|
||||
<Route path="/examen" element={<ComingSoon />} />
|
||||
<Route path="/progression" element={<ProgressionPage />} />
|
||||
|
|
|
|||
|
|
@ -33,14 +33,22 @@ interface Props {
|
|||
* reste verrouillée.
|
||||
*/
|
||||
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 {
|
||||
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
|
||||
sublabel: string
|
||||
lockLabel?: string
|
||||
/** Sprint 7b — carte « Live » (T1 ou T2) : gating Premium via hasAccess. */
|
||||
live?: 'T1' | 'T2'
|
||||
}
|
||||
|
||||
const EE_CARDS: readonly TaskCard[] = [
|
||||
|
|
@ -52,12 +60,21 @@ const EE_CARDS: readonly TaskCard[] = [
|
|||
const EO_CARDS: readonly TaskCard[] = [
|
||||
{ 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_T1_LIVE',
|
||||
tache: null,
|
||||
label: 'Expression Orale',
|
||||
sublabel: 'Tâche 1 — Live',
|
||||
lockLabel: 'Exclusivité Premium',
|
||||
live: 'T1',
|
||||
},
|
||||
{
|
||||
key: 'EO_T2_LIVE',
|
||||
tache: 'EO_T2_LIVE',
|
||||
label: 'Expression Orale',
|
||||
sublabel: 'Tâche 2 — Live',
|
||||
lockLabel: 'Exclusivité Premium',
|
||||
live: 'T2',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -68,10 +85,13 @@ export function TaskSelector({
|
|||
isLoading,
|
||||
onSelect,
|
||||
onT2LiveSelect,
|
||||
onT1LiveSelect,
|
||||
}: Props) {
|
||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||
const quotaBlocked = !simulationCheck.allowed
|
||||
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)
|
||||
|
||||
return (
|
||||
|
|
@ -98,16 +118,21 @@ export function TaskSelector({
|
|||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{cards.map((card) => {
|
||||
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 isT1Live = card.live === 'T1'
|
||||
const isT2Live = card.live === 'T2'
|
||||
// Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
|
||||
// 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'
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<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" />
|
||||
)}
|
||||
<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')}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
if (isT1Live && onT1LiveSelect) {
|
||||
onT1LiveSelect()
|
||||
return
|
||||
}
|
||||
if (isT2Live && onT2LiveSelect) {
|
||||
onT2LiveSelect()
|
||||
return
|
||||
}
|
||||
if (card.tache && card.tache !== 'EO_T2_LIVE') {
|
||||
if (card.tache && !card.live) {
|
||||
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 { useMutation } from '@tanstack/react-query'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
import { generatePresentation } from '@/entities/presentation/api'
|
||||
import type { PresentationReponses } from '@/entities/presentation/types'
|
||||
import { useSimulationFlow } from '../state/simulationFlow'
|
||||
|
||||
const FIELD_MAX = 500
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
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: '',
|
||||
}
|
||||
import {
|
||||
EMPTY_REPONSES,
|
||||
FIELD_MAX,
|
||||
T1_QUESTIONNAIRE_FIELDS,
|
||||
reponsesSchema,
|
||||
type FieldKey,
|
||||
} from '../lib/t1Questionnaire'
|
||||
|
||||
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'
|
||||
|
|
@ -178,7 +127,7 @@ export function QuestionnaireT1Page() {
|
|||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||
{FIELDS.map((field) => {
|
||||
{T1_QUESTIONNAIRE_FIELDS.map((field) => {
|
||||
const value = reponses[field.key]
|
||||
const showError = touched[field.key] && fieldErrors[field.key]
|
||||
const id = `q-${field.key}`
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export function SimulationEOPage() {
|
|||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
onT2LiveSelect={() => navigate('/simulation/eo/t2')}
|
||||
onT1LiveSelect={() => navigate('/simulation/eo/t1/live/preparation')}
|
||||
/>
|
||||
|
||||
{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