From d1c8b548bb47c90f6c2f66a0c75f49767d608946 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sat, 25 Apr 2026 08:28:51 +0300 Subject: [PATCH] feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gemini batch transcription (no Deepgram live) - blobToBase64 helper (shared/lib/audio.ts) - AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit - Timer stops at maxSeconds and triggers auto-submission - EnregistrementEOPage: audioBase64 to backend, fix race condition step=done - SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible) - MIME normalization (strip codec params) - Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s) - PresentationGenereeT1Page: localStorage persistence Typecheck: OK · Tests: 159/159 ✅ Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/TECH_DEBT.md | 216 +++++++++++--- src/app/router.tsx | 17 +- .../presentation/__tests__/api.test.ts | 59 ++++ src/entities/presentation/api.ts | 23 ++ src/entities/presentation/types.ts | 23 ++ src/entities/report/api.ts | 13 +- src/entities/report/types.ts | 15 +- .../transcription/__tests__/api.test.ts | 49 ++++ src/entities/transcription/api.ts | 21 ++ src/entities/transcription/types.ts | 15 + .../simulations/components/AudioRecorder.tsx | 187 ++++++++++++ .../components/TranscriptionDisplay.tsx | 60 ++++ .../__tests__/TranscriptionDisplay.test.tsx | 36 +++ .../hooks/__tests__/useAudioRecorder.test.ts | 195 +++++++++++++ .../hooks/__tests__/useDeepgramLive.test.ts | 216 ++++++++++++++ .../hooks/__tests__/useSimulation.test.tsx | 11 +- .../simulations/hooks/useAudioRecorder.ts | 271 ++++++++++++++++++ .../simulations/hooks/useDeepgramLive.ts | 226 +++++++++++++++ .../lib/__tests__/simulationConfig.test.ts | 44 +++ .../simulations/lib/simulationConfig.ts | 43 ++- .../pages/EnregistrementEOPage.tsx | 171 +++++++++++ .../simulations/pages/ModeChoixT1Page.tsx | 78 +++++ .../pages/PreEnregistrementEOPage.tsx | 105 +++++++ .../pages/PresentationGenereeT1Page.tsx | 198 +++++++++++++ .../simulations/pages/QuestionnaireT1Page.tsx | 243 ++++++++++++++++ .../simulations/pages/SimulationEOPage.tsx | 77 +++++ .../simulations/pages/SujetsEOPage.tsx | 126 ++++++++ .../__tests__/QuestionnaireT1Page.test.tsx | 123 ++++++++ .../state/SimulationFlowProvider.tsx | 101 ++++++- .../state/__tests__/simulationFlowEO.test.tsx | 145 ++++++++++ .../state/__tests__/simulationFlowT1.test.tsx | 96 +++++++ .../simulations/state/simulationFlow.ts | 32 ++- src/shared/lib/__tests__/audio.test.ts | 54 ++++ src/shared/lib/audio.ts | 36 +++ 34 files changed, 3255 insertions(+), 70 deletions(-) create mode 100644 src/entities/presentation/__tests__/api.test.ts create mode 100644 src/entities/presentation/api.ts create mode 100644 src/entities/presentation/types.ts create mode 100644 src/entities/transcription/__tests__/api.test.ts create mode 100644 src/entities/transcription/api.ts create mode 100644 src/entities/transcription/types.ts create mode 100644 src/features/simulations/components/AudioRecorder.tsx create mode 100644 src/features/simulations/components/TranscriptionDisplay.tsx create mode 100644 src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx create mode 100644 src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts create mode 100644 src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts create mode 100644 src/features/simulations/hooks/useAudioRecorder.ts create mode 100644 src/features/simulations/hooks/useDeepgramLive.ts create mode 100644 src/features/simulations/lib/__tests__/simulationConfig.test.ts create mode 100644 src/features/simulations/pages/EnregistrementEOPage.tsx create mode 100644 src/features/simulations/pages/ModeChoixT1Page.tsx create mode 100644 src/features/simulations/pages/PreEnregistrementEOPage.tsx create mode 100644 src/features/simulations/pages/PresentationGenereeT1Page.tsx create mode 100644 src/features/simulations/pages/QuestionnaireT1Page.tsx create mode 100644 src/features/simulations/pages/SimulationEOPage.tsx create mode 100644 src/features/simulations/pages/SujetsEOPage.tsx create mode 100644 src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx create mode 100644 src/features/simulations/state/__tests__/simulationFlowEO.test.tsx create mode 100644 src/features/simulations/state/__tests__/simulationFlowT1.test.tsx create mode 100644 src/shared/lib/__tests__/audio.test.ts create mode 100644 src/shared/lib/audio.ts diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 2e50230..0ba0de6 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -24,10 +24,12 @@ Pour éviter que ce document devienne un cimetière de dette ignorée (le piège ## 1. Dettes héritées de l'audit backend (2026-04-17) ### FTD-01 — Inconsistance des codes de validation côté backend + **Priorité :** 🟡 Important **Statut :** Ouvert — dépend du backend **Estimation de session :** 2h (backend uniquement) **Description :** Le backend utilise deux codes d'erreur pour la même classe (corps de requête invalide) : + - `VALIDATION_ERROR` dans `routes/simulations.ts` et `routes/corrections.ts` - `INVALID_BODY` dans `routes/plans.ts` et `routes/stripe.ts` @@ -41,6 +43,7 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma --- ### FTD-02 — Header `X-API-Version` envoyé mais non vérifié + **Priorité :** 🟡 Important **Statut :** Ouvert **Estimation de session :** 1h (backend) + 30min (frontend) @@ -49,6 +52,7 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma **Impact :** si le backend évolue de façon breaking (ex : format de réponse de `/plans/status` modifié), le frontend peut recevoir un payload incompatible sans message d'erreur clair. Symptôme : bugs silencieux en production après un déploiement backend. **À faire :** + - Backend : ajouter un middleware qui lit `X-API-Version`, le log, et retourne `HTTP 426 Upgrade Required` avec code `API_VERSION_MISMATCH` si breaking change - Frontend : gérer `API_VERSION_MISMATCH` dans `api-client.ts` → afficher un message "Une nouvelle version est disponible, veuillez rafraîchir la page" @@ -57,13 +61,16 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma --- ### FTD-03 — Quirk `status` dans le body des erreurs de simulations/corrections + **Priorité :** 🟢 Mineur **Statut :** Ouvert — dépend du backend **Estimation de session :** 1h (backend uniquement) **Description :** Les routes `POST /simulations` et `POST /corrections/ee,eo` renvoient un champ `status` dans le body JSON d'erreur, qui duplique le code HTTP : + ```json { "error": true, "code": "QUOTA_REACHED", "message": "...", "status": 403 } ``` + Vient du pattern `c.json(result, result.status)` où `result` contient déjà `status`. C'est ignorable côté frontend (on ne lit pas ce champ), mais c'est du bruit. **À faire côté backend :** nettoyer les objets d'erreur retournés par `simulationController` et `correctionController` pour ne pas contenir de champ `status`. Tracé dans **TD-16** à créer dans `expria-backend/docs/TECH_DEBT.md`. @@ -75,6 +82,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s ## 2. Dettes frontend propres ### FTD-10 — Semgrep non intégré en CI + **Priorité :** 🟢 Mineur **Statut :** Reporté — après MVP **Estimation de session :** 2h @@ -83,6 +91,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s **Impact actuel :** `npm audit` couvre les vulnérabilités des dépendances npm, mais aucune analyse statique de sécurité (SAST) n'est faite sur le code custom du projet. Des patterns dangereux (`eval`, `innerHTML` sans DOMPurify, secrets en dur, etc.) passeraient inaperçus en CI. **À faire :** + - Ajouter un step Semgrep au workflow `.github/workflows/ci.yml` - Utiliser les rulesets `auto` + `r2c-security-audit` + `r2c-ci` - Configurer la sortie pour bloquer sur sévérité ERROR uniquement @@ -93,6 +102,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s --- ### FTD-14 — Anti-FOUC thème : script inline manquant dans `` + **Priorité :** 🟡 Important **Statut :** Ouvert — à faire avant déploiement production **Estimation de session :** 30 min @@ -102,9 +112,11 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s ```html ``` @@ -118,7 +130,97 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s --- +### FTD-30 — Rotation token Deepgram sans grace period + +**Priorité :** 🟢 Mineur +**Statut :** Gelé — Sprint 4c-3 (Deepgram live mis en pause au profit de Gemini batch backend) +**Estimation de session :** 0,5 jour +**Description :** `useDeepgramLive` redemande un token à T-60 s avant expiration et hot-swap la WebSocket. Si la nouvelle échoue à s'ouvrir avant l'expiration, des chunks peuvent être perdus. **Code dormant depuis le Sprint 4c-3** — à ré-évaluer si Deepgram live est réactivé (cf. FTD-37). +**À faire :** retry policy explicite + maintien de l'ancienne connexion tant que la nouvelle n'a pas reçu son premier message. Hors scope tant que le hook reste dormant. + +--- + +### FTD-31 — Page `EnregistrementEOPage` non resumable au refresh + +**Priorité :** 🟢 Mineur +**Statut :** Ouvert — introduit au Sprint 4c-1 +**Estimation de session :** 0,5 jour +**Description :** Si l'utilisateur ferme l'onglet ou recharge la page pendant l'enregistrement, le transcript live et l'audio sont perdus. La simulation côté backend reste avec `rapport=null` mais sans contenu textuel : au resume, le provider redirige vers `/simulation/eo/pre-enregistrement` et l'utilisateur doit recommencer. +**À faire :** persister un buffer du transcript final dans `localStorage` à chaque `is_final=true`, restaurer au resume comme point de départ. Décider si on autorise la reprise « par-dessus » ou si on impose un nouveau départ. +**Condition de résolution :** session dédiée autosave EO post-MVP. + +--- + +### FTD-32 — `useAudioRecorder` non testé sur Safari iOS + +**Priorité :** 🟢 Mineur +**Statut :** Ouvert — introduit au Sprint 4c-1 +**Estimation de session :** 0,5 jour +**Description :** `pickMimeType()` propose un fallback `audio/mp4` pour Safari, mais aucun test manuel n'a été réalisé. Le bouton « Télécharger l'audio » nomme toujours le fichier `.webm` même quand le mime réel est `audio/mp4`. +**À faire :** validation manuelle iOS, adapter l'extension du fichier téléchargé au mime réel via `audioMimeType`. +**Condition de résolution :** une fois la version iPhone validée par un testeur réel. + +--- + +### FTD-34 — Présentation T1 stockée en clair dans `localStorage` + +**Priorité :** 🟢 Mineur +**Statut :** Ouvert — introduit au Sprint 4c-2 +**Estimation de session :** 0,5 jour +**Description :** `expria_eo_t1_presentation` contient le texte de la présentation personnelle de l'utilisateur (prénom, âge, ville, parcours, situation familiale, projet d'immigration). Stocké en clair, accessible à tout script tiers exécuté dans le contexte du domaine. Acceptable au MVP : aucune donnée sensible au sens RGPD strict (pas de mot de passe ni numéro fiscal), mais le contenu reste personnel. +**À faire :** chiffrement AES-GCM avec clé dérivée du JWT Supabase, ou bascule vers IndexedDB chiffré (libs : `idb-keyval` + `Web Crypto API`). Étendre à toute persistance sensible si on en ajoute (transcripts, audio, etc.). +**Condition de résolution :** quand on stocke un jour des contenus plus sensibles via le même mécanisme. + +--- + +### FTD-35 — `PresentationGenereeT1Page` : refresh sans simulation active + +**Priorité :** 🟡 Important +**Statut :** Ouvert — introduit au Sprint 4c-2 +**Estimation de session :** 0,5 jour +**Description :** Le hydrate du provider lit `expria_simulation_id` et `expria_eo_t1_presentation` indépendamment. Si l'ID a expiré côté backend (`getSimulationState` rejette en 404) mais que la présentation reste en localStorage, l'utilisateur est redirigé vers `/simulation/eo` au mount du provider, puis la page `PresentationGenereeT1Page` peut être atteinte via reload direct sans `production` valide → la garde `shouldRedirect` envoie vers `/simulation/eo/t1/mode`, qui à son tour redirige vers `/simulation/eo`. Le résultat est correct mais l'utilisateur n'a pas de feedback explicite (« votre simulation a expiré »). +**À faire :** afficher un toast / bandeau dédié quand la résolution `getSimulationState` échoue avec une présentation localStorage présente, et proposer un bouton « Recommencer » qui efface la présentation également. +**Condition de résolution :** Sprint 4c-3 ou clean post-MVP. + +--- + +### FTD-36 — Upload audio base64 in-memory sans indicateur de progression + +**Priorité :** 🟡 Important +**Statut :** Ouvert — introduit au Sprint 4c-3 +**Estimation de session :** 1 jour +**Description :** `EnregistrementEOPage` encode le Blob audio en base64 via `FileReader.readAsDataURL` puis envoie le résultat dans le body JSON de `POST /corrections/eo`. Pour 6 minutes d'audio webm/Opus à 32 kbps ≈ 1,5 Mo binaire ≈ 2 Mo base64. Reste sous le cap 14 Mo backend, mais : (a) tout est chargé en mémoire navigateur, (b) aucun indicateur de progression d'upload (le banner « Transcription et correction en cours » couvre les ~30-60 s totales sans distinguer upload/Gemini/DeepSeek), (c) retry impossible côté navigateur si la connexion mobile coupe en cours d'upload. +**À faire :** passer à `multipart/form-data` avec `XMLHttpRequest.upload.onprogress` ou `fetch` + `ReadableStream` ; afficher une barre de progression upload distincte de l'état serveur. +**Condition de résolution :** observer un cas réel de plantage mobile/edge OU avant ouverture publique. + +--- + +### FTD-37 — Code Deepgram live dormant à trancher + +**Priorité :** 🟢 Mineur +**Statut :** Ouvert — introduit au Sprint 4c-3 +**Estimation de session :** 1 jour (réactivation) ou 0,5 jour (suppression) +**Description :** Sprint 4c-3 a basculé la transcription EO sur Gemini batch côté backend. Les artefacts Deepgram live restent en place mais sans consommateur : + +- Frontend : `useDeepgramLive`, `TranscriptionDisplay`, `entities/transcription/api.ts` + tests associés +- Backend : route `POST /transcriptions/token`, `lib/deepgram.ts` + tests associés + **Décision de garde :** conservés 30 jours après la mise en prod du Sprint 4c-3 puis on tranche. Soit (a) réactivation pour réduire la latence perçue (transcription live pendant l'enregistrement vs attente serveur après stop), soit (b) suppression définitive si le retour utilisateur sur la latence Gemini est acceptable. + **À faire :** trancher au plus tard 30 jours après la première mise en prod de cette session. + +--- + +### FTD-33 — Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) + +**Priorité :** 🟢 Mineur +**Statut :** Ouvert — introduit au Sprint 4c-1 +**Estimation de session :** 0,5 jour +**Description :** Dans `TaskSelector`, la carte EO_T2_LIVE a `tache: null` ce qui la rend inactive pour tous les plans, indépendamment de `hasAccess(plan, 'oral_t2_live')`. C'est volontaire tant que T2 Live n'est pas livré (Sprint 6) — un utilisateur Premium ne doit pas accéder à une feature non implémentée. À nettoyer dès que T2 Live est wired pour respecter strictement la Règle D (pas de logique de plan en dur). +**Condition de résolution :** lancement de T2 Live (Sprint 6). + +--- + ### FTD-24 — Pas de polling automatique pour exercices / modèle `pending` + **Priorité :** 🟡 Important **Statut :** Résolu — 2026-04-23 **Estimation de session :** 2h @@ -127,6 +229,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s **Impact UX :** l'utilisateur voit le rapport principal immédiatement, mais doit recharger pour voir ses exercices + production modèle. Expérience acceptable en MVP mais sous-optimale. **À faire :** + - Hook `useRapport` : déclencher un polling automatique via TanStack Query `refetchInterval: 3000` si `exercices_status === 'pending' || modele_status === 'pending'`. - Arrêt du polling dès que les deux statuts sortent de `'pending'` (ready ou error). - Afficher un indicateur visuel discret pendant le polling actif (petit spinner dans JobStatusFallback). @@ -139,12 +242,14 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s --- ### FTD-23 — `useAutosave` continue après correction → 400 VALIDATION_ERROR + **Priorité :** 🟡 Important **Statut :** Résolu — 2026-04-23 **Estimation de session :** 30 min **Description :** Le hook `useAutosave` (cf. `src/features/simulations/hooks/useAutosave.ts`) peut déclencher un `PATCH /simulations/:id/contenu` après que la correction a été persistée (colonne `rapport !== null`). Le backend refuse alors avec `400 VALIDATION_ERROR` message « Cette simulation a déjà été corrigée. » (cf. `simulationController.autosaveContenu` backend lignes 248-255). **Scénario déclencheur :** + 1. L'utilisateur soumet sa production → `rapport` persisté côté backend. 2. `SimulationForm` passe `step` à `'done'`, mais : - Le timer d'autosave debouncé (30 s) peut encore fire après cette transition si le debounce n'est pas clear. @@ -152,6 +257,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s 3. `useAutosave.enabled` est calculé comme `!isSubmitting` dans `SimulationForm` — il redevient `true` après la correction (quand `isSubmitting` repasse à `false`). **À faire :** + - Propager `enabled = !isSubmitting && step !== 'done' && step !== 'correcting'` depuis `SimulationForm` - OU : au montage, quand `rapport` devient non null après correction, clear le timeout debouncé et retirer le handler `beforeunload` immédiatement. - Ajouter un test regression dans `useAutosave.test.ts` qui vérifie qu'aucun `autosaveContenu` n'est appelé après `step='done'`. @@ -163,12 +269,14 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s --- ### FTD-25 — Mise à jour ARCHITECTURE.md §3 (arborescence réelle) + **Priorité :** 🟢 Mineur **Statut :** Ouvert **Estimation de session :** 1h **Description :** ARCHITECTURE.md §3 ne liste pas `entities/patterns`, `features/historique`, `features/progression`, `features/design-system` (ajoutés aux Sprints 3.6c et 3.7). Les composants layout (`AppLayout`, `Sidebar`, `MobileHeader`, `BottomNav`, `MaintenancePage`) sont dans `app/` alors que §3 ne prévoit que `providers`, `router`, `main` dans ce dossier. **À faire :** + - Mettre à jour ARCHITECTURE.md §3 pour refléter l'arborescence réelle. - Formaliser `app/` comme contenant entry points + composants layout de la coquille OU déplacer vers `shared/components/layout/`. @@ -177,10 +285,12 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s --- ### FTD-26 — Clarifier cohabitation `shared/ui/` vs `shared/components/ui/` + **Priorité :** 🟡 Important **Statut :** Ouvert **Estimation de session :** 2h **Description :** Deux conventions UI cohabitent sans documentation : + - `src/shared/ui/{Button,Card,Badge}.tsx` (PascalCase) — wrappers Expria, 40+ imports dans les features. - `src/shared/components/ui/{button,dialog,input,…}.tsx` (kebab-case) — primitives shadcn/ui, 7 fichiers consommateurs. @@ -195,12 +305,14 @@ Risque : confusion pour un futur dev sur quel composant utiliser. ## 3. Fonctionnalités reportées ### FTD-07 — Sentry non intégré + **Priorité :** 🟡 Important **Statut :** Planifié — après MVP **Estimation de session :** 3h **Description :** Le monitoring frontend (erreurs JS, performances, sessions) n'est pas encore en place. Sans Sentry (ou équivalent), les bugs en production ne remontent pas — on les découvre uniquement si un utilisateur prend la peine de les signaler. **À faire :** + - Créer un compte Sentry (tier gratuit suffit pour démarrer) - Ajouter `@sentry/react` au projet - Intégrer dans `src/app/providers.tsx` @@ -213,12 +325,14 @@ Risque : confusion pour un futur dev sur quel composant utiliser. --- ### FTD-21 — Persistance session simulation + **Priorité :** 🔴 Critique **Statut :** Partiellement résolu — `/simulation/ee` ✅ (2026-04-21) **Pages concernées par ordre de priorité :** ✅ **`/simulation/ee`** (résolu 2026-04-21) + - Autosave contenu toutes les 30 s (`useAutosave`) - Save on `beforeunload` - Reprise au refresh via `localStorage` (`expria_simulation_id`) + `GET /simulations/:id` @@ -227,13 +341,16 @@ Risque : confusion pour un futur dev sur quel composant utiliser. - `RapportPage` redirige vers `/simulation/ee` si simulation en cours 🟡 **`/simulation/eo`** (Sprint 4 — ouvert) + - Identique EE + état audio/enregistrement 🟡 **`/examen`** (Sprint 7 — ouvert) + - Autosave critique — timer inarrêtable + 3 tâches - Crash pendant examen = perte totale 🟢 **`/sujets`** (inclus dans la résolution EE) + - `localStorage simulation_id` suffit - Pas d'autosave (pas de données saisies) @@ -242,6 +359,7 @@ Risque : confusion pour un futur dev sur quel composant utiliser. **Résolution EE livrée (2026-04-21) :** Backend : + - `simulationController.create` persiste `sujet_id` à la création - `getById` retourne `SimulationState` (tolère `rapport=null` pour resume) - `autosaveContenu` + `updateSujet` controllers (refuse si `rapport !== null`) @@ -249,6 +367,7 @@ Backend : - CORS : `allowMethods` étendu à PATCH/PUT/DELETE Frontend : + - `useAutosave` : debounce 30 s + `beforeunload` flush + dedup par contenu - `SimulationForm` : hydrate `initialContenu`, affiche "Sauvegardé à HH:MM" - `SimulationFlowProvider` : hydratation au montage depuis `localStorage` → restaure step `task-selected` si rapport null, nettoie sinon @@ -263,6 +382,7 @@ Frontend : > Ces FTDs sont volontairement gelées : elles concernent des fonctionnalités non encore livrées (T2 Live, tests E2E) ou du confort utilisateur non bloquant (option `'system'` thème). Elles **ne comptent pas dans le cap de 15 FTD actives** et seront réactivées quand leur sprint arrive ou quand la condition de déblocage (post-MVP) est atteinte. ### FTD-06 — AudioWorklet au lieu de ScriptProcessorNode (T2 Live) + **Priorité :** 🟢 Mineur **Statut :** Gelé — post-MVP (T2 Live non encore implémenté) **Estimation de session :** 1 jour @@ -277,6 +397,7 @@ Frontend : --- ### FTD-08 — Tests E2E non implémentés + **Priorité :** 🟢 Mineur **Statut :** Gelé — post-MVP (accepté par design) **Estimation de session :** 2 jours (Playwright setup) @@ -289,12 +410,14 @@ Frontend : --- ### FTD-15 — Option `'system'` manquante dans ThemeProvider + **Priorité :** 🟢 Mineur **Statut :** Gelé — post-MVP **Estimation de session :** 2h **Description :** Le `ThemeProvider` est bi-state (`'light' | 'dark'`). L'option `'system'` (qui suit `prefers-color-scheme` en temps réel via `MediaQueryList.addEventListener`) a été volontairement différée (décision Sprint 0.5). **À faire :** + - Étendre le type `Theme` à `'light' | 'dark' | 'system'` - Dans `ThemeProvider`, si `theme === 'system'` : écouter `matchMedia('(prefers-color-scheme: dark)')` et appliquer/retirer `.dark` dynamiquement - `ThemeToggle` : cycle light → dark → system (ou un sélecteur 3 états) @@ -307,13 +430,15 @@ Frontend : ## 4. Tests à renforcer ### FTD-09 — Tests de la state machine T2 Live non implémentés + **Priorité :** 🟡 Important **Statut :** Planifié — à créer au Sprint 2.5 **Estimation de session :** 3h **Description :** La state machine T2 Live (`src/features/t2-live/state/t2-machine.ts`) n'existe pas encore. Quand elle sera créée, elle devra être testée de manière exhaustive (6+ tests couvrant les transitions d'états et les cas d'erreur). **À faire au Sprint 2.5 (spike T2 Live) :** -- Créer `t2-machine.test.ts` avec tests des transitions : idle → connecting, connecting → listening, listening ↔ speaking, * → error, * → ended + +- Créer `t2-machine.test.ts` avec tests des transitions : idle → connecting, connecting → listening, listening ↔ speaking, _ → error, _ → ended - Tests des messages d'erreur (close code 4001, 4003, autre) **Condition de résolution :** fin Sprint 2.5. @@ -321,6 +446,7 @@ Frontend : --- ### FTD-12 — Tests automatisés manquants pour `api-client.ts` + **Priorité :** 🟡 Important **Statut :** Ouvert — à faire avant intégration des features critiques **Estimation de session :** 3h @@ -329,6 +455,7 @@ Frontend : **Impact actuel :** toute régression sur ce fichier (oubli d'un header, mauvais parsing d'une erreur, boucle de retry infinie sur un edge case) passera inaperçue jusqu'aux tests manuels ou à un bug en production. **À faire :** + - Créer `src/shared/lib/__tests__/api-client.test.ts` - Mocker globalement `fetch` via `vi.fn()` - Couvrir : @@ -350,47 +477,50 @@ Frontend : ## 5. Historique des résolutions -| ID | Description | Résolu le | Comment | -|---|---|---|---| -| FTD-11 | `@theme` Tailwind 4 non défini — palette et typographie absentes | 2026-04-18 | Résolu au Sprint 0.5 (design system). Palette Direction H complète (canvas/surface/ink/expria/deep/semantic) + typo Plus Jakarta Sans définis dans `src/index.css` via `@theme {}` et `.dark {}`. shadcn/ui remappé sur ces tokens. Règle L ajoutée dans `DEVELOPMENT_PRINCIPLES.md` pour garantir l'usage exclusif des tokens. | -| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. | -| FTD-16 | `VITE_MAINTENANCE_MODE` non lu dans le code — la variable d'env était dans `env.ts` mais jamais consommée | 2026-04-18 | Résolu au Sprint 1 étape 6. Ajout de `isMaintenanceMode` dans `src/shared/config/env.ts` et garde dans `src/app/main.tsx` : `isMaintenanceMode ? : `. `MaintenancePage` est statique (aucun provider requis), tokens Direction H exclusivement. | -| FTD-22 | Code orphelin suite à la refonte UX `/sujets` (2026-04-21) — composant `SujetSelector` et helper `selectSujet` plus référencés après bascule dropdown → page dédiée | 2026-04-23 | Résolution complète. `SujetSelector` + `selectSujet` supprimés. Éléments conservés (`choosing-subject`, `goToSubjectPicker`) sont activement utilisés par `SimulationFlowProvider` et `SimulationForm` — ce n'est plus de la dette. | -| FTD-20 | `GET /simulations/:id` manquant dans le backend | 2026-04-22 | Implémenté au Sprint 3.6a (backend) — route complète avec auth, owner check, `REPORT_NOT_READY`. Consommé par `RapportPage` et `useAutosave`. | -| FTD-04 | Documents miroir sans automatisation de synchronisation | 2026-04-23 | Risque accepté par design (ADR 004). Mitigation en place (Règle G, commentaire `SOURCE OF TRUTH`, tests de parité). Condition de ré-ouverture : si une divergence silencieuse cause 2+ bugs en production. | -| FTD-05 | Ancien scaffold frontend possiblement caduc | 2026-04-23 | Audit Claude Code complet — aucun résidu scaffold Vite, aucun fichier orphelin, règles critiques (D, E, F, G, J + ADR 003/005) respectées. Désalignements documentaires traités via FTD-25 et FTD-26. | -| FTD-29 | `.github/dependabot.yml` dans les 2 dépôts | 2026-04-23 | Fichier créé dans expria-frontend et expria-backend. Ecosystem npm, weekly, limit 10 PRs. Dependabot alerts + security updates activés via UI GitHub. | -| FTD-27 | CI GitHub Actions pour expria-backend | 2026-04-23 | Workflow créé : npm ci → test → audit. Node 22, trigger push/PR sur main. CI verte au premier run (21s). Observations : typecheck absent (O1), ESLint absent (O2), engines.node absent (O3) — à traiter en FTDs séparées. | -| FTD-28 | Semgrep dans CI frontend + backend | 2026-04-23 | Step `semgrep scan --config=auto --error --severity=ERROR` ajouté aux deux workflows CI. Backend vert au 1er run. Frontend vert après correction de 4 erreurs ESLint préexistantes + fix Prettier + ajout env vars CI. | -| FTD-17 | Clé `['plan']` dupliquée entre features (`usePlan`, `SimulationPage`, `RapportPage`) | 2026-04-22 | Résolu au Sprint 3.5. Création de `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime) exportant `PLAN_QUERY_KEY = ['plan'] as const`. `features/dashboard/hooks/usePlan.ts` l'importe et le re-exporte pour conserver la rétro-compatibilité de l'import `PLAN_QUERY_KEY`. `SimulationPage.tsx` et `RapportPage.tsx` remplacent leur `useQuery` inline par le hook `usePlan()` — dédup totale de la clé et de la config staleTime. | -| FTD-18 | SimulationForm utilise encore le shadcn Button au lieu de la primitive `@/shared/ui/Button` | 2026-04-22 | Résolu au Sprint 3.5. Remplacement de l'import `@/shared/components/ui/button` par `@/shared/ui/Button` dans `SimulationForm.tsx`. Aucun variant à adapter (usage du Button sans prop `variant` → `primary` par défaut dans les deux implémentations). Les 7 autres consommateurs shadcn (`Login/RegisterPage`, `PaywallBanner`, `DesignSystemPage`, `ThemeToggle`, `dialog.tsx`) restent hors scope de cette FTD. | -| FTD-23 | `useAutosave` continue après correction → 400 VALIDATION_ERROR | 2026-04-23 | `enabled` corrigé dans `SimulationForm` (`!isSubmitting && step !== 'done' && step !== 'correcting'`). Le `beforeunload` handler et le debounce lisent `enabled` via `latestRef` — tous deux neutralisés dès que `step` transite. 2 tests de régression ajoutés dans `useAutosave.test.ts` : (a) `enabled` true→false annule le debounce en cours, (b) `enabled=false` + `beforeunload` = aucun appel. | -| FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. | -| FTD-19 | Token `--shadow-focus` absent de `src/index.css` | 2026-04-22 | Résolu au Sprint 3.5. Ajout de `--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18)` dans `@theme {}` (valeur conforme à `DESIGN_SYSTEM.md §2`) et `--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32)` dans `.dark {}` (recalculé sur la teinte expria dark `#5B7FFF`). Tailwind 4 génère automatiquement l'utility `shadow-focus`. Migration de 5 occurrences `ring-2 ring-expria/20` → `shadow-focus` dans `Button.tsx`, `Card.tsx`, `SimulationForm.tsx` (×3), `SpecialCharsKeyboard.tsx`. Factorisation bonus : className dupliquée des boutons secondaires de `SimulationForm` extraite en const `secondaryActionBtn`. | +| ID | Description | Résolu le | Comment | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FTD-11 | `@theme` Tailwind 4 non défini — palette et typographie absentes | 2026-04-18 | Résolu au Sprint 0.5 (design system). Palette Direction H complète (canvas/surface/ink/expria/deep/semantic) + typo Plus Jakarta Sans définis dans `src/index.css` via `@theme {}` et `.dark {}`. shadcn/ui remappé sur ces tokens. Règle L ajoutée dans `DEVELOPMENT_PRINCIPLES.md` pour garantir l'usage exclusif des tokens. | +| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. | +| FTD-16 | `VITE_MAINTENANCE_MODE` non lu dans le code — la variable d'env était dans `env.ts` mais jamais consommée | 2026-04-18 | Résolu au Sprint 1 étape 6. Ajout de `isMaintenanceMode` dans `src/shared/config/env.ts` et garde dans `src/app/main.tsx` : `isMaintenanceMode ? : `. `MaintenancePage` est statique (aucun provider requis), tokens Direction H exclusivement. | +| FTD-22 | Code orphelin suite à la refonte UX `/sujets` (2026-04-21) — composant `SujetSelector` et helper `selectSujet` plus référencés après bascule dropdown → page dédiée | 2026-04-23 | Résolution complète. `SujetSelector` + `selectSujet` supprimés. Éléments conservés (`choosing-subject`, `goToSubjectPicker`) sont activement utilisés par `SimulationFlowProvider` et `SimulationForm` — ce n'est plus de la dette. | +| FTD-20 | `GET /simulations/:id` manquant dans le backend | 2026-04-22 | Implémenté au Sprint 3.6a (backend) — route complète avec auth, owner check, `REPORT_NOT_READY`. Consommé par `RapportPage` et `useAutosave`. | +| FTD-04 | Documents miroir sans automatisation de synchronisation | 2026-04-23 | Risque accepté par design (ADR 004). Mitigation en place (Règle G, commentaire `SOURCE OF TRUTH`, tests de parité). Condition de ré-ouverture : si une divergence silencieuse cause 2+ bugs en production. | +| FTD-05 | Ancien scaffold frontend possiblement caduc | 2026-04-23 | Audit Claude Code complet — aucun résidu scaffold Vite, aucun fichier orphelin, règles critiques (D, E, F, G, J + ADR 003/005) respectées. Désalignements documentaires traités via FTD-25 et FTD-26. | +| FTD-29 | `.github/dependabot.yml` dans les 2 dépôts | 2026-04-23 | Fichier créé dans expria-frontend et expria-backend. Ecosystem npm, weekly, limit 10 PRs. Dependabot alerts + security updates activés via UI GitHub. | +| FTD-27 | CI GitHub Actions pour expria-backend | 2026-04-23 | Workflow créé : npm ci → test → audit. Node 22, trigger push/PR sur main. CI verte au premier run (21s). Observations : typecheck absent (O1), ESLint absent (O2), engines.node absent (O3) — à traiter en FTDs séparées. | +| FTD-28 | Semgrep dans CI frontend + backend | 2026-04-23 | Step `semgrep scan --config=auto --error --severity=ERROR` ajouté aux deux workflows CI. Backend vert au 1er run. Frontend vert après correction de 4 erreurs ESLint préexistantes + fix Prettier + ajout env vars CI. | +| FTD-17 | Clé `['plan']` dupliquée entre features (`usePlan`, `SimulationPage`, `RapportPage`) | 2026-04-22 | Résolu au Sprint 3.5. Création de `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime) exportant `PLAN_QUERY_KEY = ['plan'] as const`. `features/dashboard/hooks/usePlan.ts` l'importe et le re-exporte pour conserver la rétro-compatibilité de l'import `PLAN_QUERY_KEY`. `SimulationPage.tsx` et `RapportPage.tsx` remplacent leur `useQuery` inline par le hook `usePlan()` — dédup totale de la clé et de la config staleTime. | +| FTD-18 | SimulationForm utilise encore le shadcn Button au lieu de la primitive `@/shared/ui/Button` | 2026-04-22 | Résolu au Sprint 3.5. Remplacement de l'import `@/shared/components/ui/button` par `@/shared/ui/Button` dans `SimulationForm.tsx`. Aucun variant à adapter (usage du Button sans prop `variant` → `primary` par défaut dans les deux implémentations). Les 7 autres consommateurs shadcn (`Login/RegisterPage`, `PaywallBanner`, `DesignSystemPage`, `ThemeToggle`, `dialog.tsx`) restent hors scope de cette FTD. | +| FTD-23 | `useAutosave` continue après correction → 400 VALIDATION_ERROR | 2026-04-23 | `enabled` corrigé dans `SimulationForm` (`!isSubmitting && step !== 'done' && step !== 'correcting'`). Le `beforeunload` handler et le debounce lisent `enabled` via `latestRef` — tous deux neutralisés dès que `step` transite. 2 tests de régression ajoutés dans `useAutosave.test.ts` : (a) `enabled` true→false annule le debounce en cours, (b) `enabled=false` + `beforeunload` = aucun appel. | +| FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. | +| FTD-19 | Token `--shadow-focus` absent de `src/index.css` | 2026-04-22 | Résolu au Sprint 3.5. Ajout de `--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18)` dans `@theme {}` (valeur conforme à `DESIGN_SYSTEM.md §2`) et `--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32)` dans `.dark {}` (recalculé sur la teinte expria dark `#5B7FFF`). Tailwind 4 génère automatiquement l'utility `shadow-focus`. Migration de 5 occurrences `ring-2 ring-expria/20` → `shadow-focus` dans `Button.tsx`, `Card.tsx`, `SimulationForm.tsx` (×3), `SpecialCharsKeyboard.tsx`. Factorisation bonus : className dupliquée des boutons secondaires de `SimulationForm` extraite en const `secondaryActionBtn`. | --- ## 6. Historique de ce document -| Version | Date | Changements | -|---|---|---| -| 1.0 | 2026-04-17 | Création initiale avec 9 FTD identifiées depuis l'audit backend et les décisions d'architecture | -| 1.1 | 2026-04-17 | Ajout FTD-10 (Semgrep CI), FTD-11 (`@theme` Tailwind 4), FTD-12 (tests `api-client`) suite à l'étape 11 du Sprint 0 | -| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 | -| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) | -| 1.4 | 2026-04-18 | FTD-16 résolu (VITE_MAINTENANCE_MODE implémenté — Sprint 1 étape 6) | -| 1.5 | 2026-04-19 | Ajout FTD-17 (clé ['plan'] dupliquée entre features — Sprint 3 étape 14) | -| 1.6 | 2026-04-20 | Ajout FTD-18 (SimulationForm shadcn Button — Sprint 0.5 bis D2) ; ajout FTD-19 (token --shadow-focus manquant — Sprint 0.5 bis D2) | -| 1.7 | 2026-04-20 | Ajout FTD-20 🔴 (GET /simulations/:id manquant backend — bloque RapportPage Sprint 3 étape 15) | -| 1.8 | 2026-04-20 | Ajout FTD-21 🔴 (persistance session simulation — prod + sujet perdus au refresh, session dédiée après G1-G5) | -| 1.9 | 2026-04-21 | FTD-22 résolu partiellement (nettoyage code orphelin refonte `/sujets` — `SujetSelector` + `selectSujet` supprimés ; `choosing-subject` + `goToSubjectPicker` conservés) | -| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts | -| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) | -| 1.12 | 2026-04-22 | Sprint 3.6a — Ajout FTD-23 🟡 (useAutosave fire après correction). 16 FTD actives → cap de 15 dépassé temporairement, à revoir au prochain clean. | -| 1.13 | 2026-04-22 | Sprint 3.6b — Ajout FTD-24 🟡 (polling auto exercices/modèle pending). 17 FTD actives → cap dépassé, un clean 3.6.5 devra résoudre FTD-23/24 ensemble. | -| 1.14 | 2026-04-23 | Triage : FTD-04, FTD-05, FTD-20, FTD-22 fermées. FTD-25, FTD-26 ajoutées. 15 FTD actives (cap respecté). | -| 1.15 | 2026-04-23 | Réorg sécurité : FTD-06, FTD-08, FTD-15 gelées (backlog post-MVP). FTD-27 🔴, FTD-28 🔴, FTD-29 🟡 ajoutées (sécurité). 15 FTD actives (cap respecté). | -| 1.16 | 2026-04-23 | FTD-29 fermée (Dependabot config). 14 FTD actives. | -| 1.17 | 2026-04-23 | FTD-27 fermée (CI backend). 13 FTD actives. | -| 1.18 | 2026-04-23 | FTD-28 fermée (Semgrep CI). CI frontend verte pour la première fois. 12 FTD actives. | -| 1.19 | 2026-04-23 | FTD-23 et FTD-24 fermées (clean useAutosave après correction + polling automatique jobs pending dans useRapport). 10 FTD actives (cap 15). | +| Version | Date | Changements | +| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.0 | 2026-04-17 | Création initiale avec 9 FTD identifiées depuis l'audit backend et les décisions d'architecture | +| 1.1 | 2026-04-17 | Ajout FTD-10 (Semgrep CI), FTD-11 (`@theme` Tailwind 4), FTD-12 (tests `api-client`) suite à l'étape 11 du Sprint 0 | +| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 | +| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) | +| 1.4 | 2026-04-18 | FTD-16 résolu (VITE_MAINTENANCE_MODE implémenté — Sprint 1 étape 6) | +| 1.5 | 2026-04-19 | Ajout FTD-17 (clé ['plan'] dupliquée entre features — Sprint 3 étape 14) | +| 1.6 | 2026-04-20 | Ajout FTD-18 (SimulationForm shadcn Button — Sprint 0.5 bis D2) ; ajout FTD-19 (token --shadow-focus manquant — Sprint 0.5 bis D2) | +| 1.7 | 2026-04-20 | Ajout FTD-20 🔴 (GET /simulations/:id manquant backend — bloque RapportPage Sprint 3 étape 15) | +| 1.8 | 2026-04-20 | Ajout FTD-21 🔴 (persistance session simulation — prod + sujet perdus au refresh, session dédiée après G1-G5) | +| 1.9 | 2026-04-21 | FTD-22 résolu partiellement (nettoyage code orphelin refonte `/sujets` — `SujetSelector` + `selectSujet` supprimés ; `choosing-subject` + `goToSubjectPicker` conservés) | +| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts | +| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) | +| 1.12 | 2026-04-22 | Sprint 3.6a — Ajout FTD-23 🟡 (useAutosave fire après correction). 16 FTD actives → cap de 15 dépassé temporairement, à revoir au prochain clean. | +| 1.13 | 2026-04-22 | Sprint 3.6b — Ajout FTD-24 🟡 (polling auto exercices/modèle pending). 17 FTD actives → cap dépassé, un clean 3.6.5 devra résoudre FTD-23/24 ensemble. | +| 1.14 | 2026-04-23 | Triage : FTD-04, FTD-05, FTD-20, FTD-22 fermées. FTD-25, FTD-26 ajoutées. 15 FTD actives (cap respecté). | +| 1.15 | 2026-04-23 | Réorg sécurité : FTD-06, FTD-08, FTD-15 gelées (backlog post-MVP). FTD-27 🔴, FTD-28 🔴, FTD-29 🟡 ajoutées (sécurité). 15 FTD actives (cap respecté). | +| 1.16 | 2026-04-23 | FTD-29 fermée (Dependabot config). 14 FTD actives. | +| 1.17 | 2026-04-23 | FTD-27 fermée (CI backend). 13 FTD actives. | +| 1.18 | 2026-04-23 | FTD-28 fermée (Semgrep CI). CI frontend verte pour la première fois. 12 FTD actives. | +| 1.19 | 2026-04-23 | FTD-23 et FTD-24 fermées (clean useAutosave après correction + polling automatique jobs pending dans useRapport). 10 FTD actives (cap 15). | +| 1.20 | 2026-04-25 | Sprint 4c-1 — Ajout FTD-30 🟡 (rotation token Deepgram sans grace period), FTD-31 🟢 (page enregistrement EO non resumable), FTD-32 🟢 (Safari iOS non testé), FTD-33 🟢 (EO_T2_LIVE verrouillé en dur). 14 FTD actives (cap 15 respecté). | +| 1.21 | 2026-04-25 | Sprint 4c-2 — Ajout FTD-34 🟢 (présentation T1 en localStorage clair), FTD-35 🟡 (refresh sans simulation active sur PresentationGenereeT1Page). **16 FTD actives — cap dépassé temporairement, accepté par Hermann pour cette session ; clean à planifier au prochain Sprint.** | +| 1.22 | 2026-04-25 | Sprint 4c-3 — Ajout FTD-36 🟡 (upload audio base64 sans progression), FTD-37 🟢 (code Deepgram live dormant à trancher). FTD-30 dégradée 🟡→🟢 et passée en « gelé » (Deepgram live mis en pause). **17 FTD actives — cap toujours dépassé, clean prioritaire au Sprint suivant.** | diff --git a/src/app/router.tsx b/src/app/router.tsx index 00c0d86..a8f50a7 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -8,6 +8,13 @@ import { DashboardPage } from '@/features/dashboard/pages/DashboardPage' import { SimulationPage } from '@/features/simulations/pages/SimulationPage' import { SujetsPage } from '@/features/simulations/pages/SujetsPage' import { RapportPage } from '@/features/simulations/pages/RapportPage' +import { SimulationEOPage } from '@/features/simulations/pages/SimulationEOPage' +import { SujetsEOPage } from '@/features/simulations/pages/SujetsEOPage' +import { PreEnregistrementEOPage } from '@/features/simulations/pages/PreEnregistrementEOPage' +import { EnregistrementEOPage } from '@/features/simulations/pages/EnregistrementEOPage' +import { ModeChoixT1Page } from '@/features/simulations/pages/ModeChoixT1Page' +import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT1Page' +import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page' import { HistoriquePage } from '@/features/historique/pages/HistoriquePage' import { ProgressionPage } from '@/features/progression/pages/ProgressionPage' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' @@ -65,9 +72,17 @@ export function AppRouter() { }> } /> } /> + {/* Sprint 4c-1 — flow EO */} + } /> + } /> + } /> + } /> + {/* Sprint 4c-2 — flux T1 (questionnaire + génération + présentation) */} + } /> + } /> + } /> } /> - } /> {/* Autres sections — Sprint 4+ */} } /> diff --git a/src/entities/presentation/__tests__/api.test.ts b/src/entities/presentation/__tests__/api.test.ts new file mode 100644 index 0000000..c8d5a69 --- /dev/null +++ b/src/entities/presentation/__tests__/api.test.ts @@ -0,0 +1,59 @@ +/** + * Tests du domaine `presentation` — Sprint 4c-2. + * + * Valide : + * - succès : retourne { presentation } + * - erreur : ApiError propagée par apiFetch + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('@/shared/lib/api-client', () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from '@/shared/lib/api-client' +import { generatePresentation } from '../api' +import type { PresentationReponses } from '../types' + +const mocked = vi.mocked(apiFetch) + +const VALID_REPONSES: PresentationReponses = { + prenom_age_ville: 'Marie, 32 ans, Douala', + formation_metier: 'Master en gestion, comptable', + situation_familiale: 'Mariée, deux enfants', + loisirs: 'Lecture, cuisine', + motivation_canada: 'Opportunités, départ 2025', +} + +describe('generatePresentation', () => { + beforeEach(() => { + mocked.mockReset() + }) + + it('retourne la présentation générée et appelle le bon endpoint', async () => { + mocked.mockResolvedValueOnce({ presentation: 'Bonjour, je m appelle Marie...' }) + + const result = await generatePresentation(VALID_REPONSES) + + expect(result.presentation).toContain('Marie') + expect(mocked).toHaveBeenCalledWith('/presentations/generate', { + method: 'POST', + body: { reponses: VALID_REPONSES }, + timeoutMs: 25_000, + retry: { max: 0, baseDelayMs: 0 }, + }) + }) + + it('propage les ApiError du backend', async () => { + mocked.mockRejectedValueOnce({ + error: true, + code: 'INTERNAL_ERROR', + message: 'DeepSeek down', + }) + + await expect(generatePresentation(VALID_REPONSES)).rejects.toMatchObject({ + code: 'INTERNAL_ERROR', + }) + }) +}) diff --git a/src/entities/presentation/api.ts b/src/entities/presentation/api.ts new file mode 100644 index 0000000..1b04aa4 --- /dev/null +++ b/src/entities/presentation/api.ts @@ -0,0 +1,23 @@ +/** + * Appels API du domaine `presentation` — Sprint 4c-2. + * + * `POST /presentations/generate` : timeout 25 s (DeepSeek peut mettre 10-20 s), + * retry désactivé volontairement — un POST non-idempotent qui consomme un + * appel DeepSeek ne doit pas être rejoué silencieusement sur erreur réseau. + */ + +import { apiFetch } from '@/shared/lib/api-client' +import type { PresentationGenerated, PresentationReponses } from './types' + +const GENERATE_TIMEOUT_MS = 25_000 + +export function generatePresentation( + reponses: PresentationReponses, +): Promise { + return apiFetch('/presentations/generate', { + method: 'POST', + body: { reponses }, + timeoutMs: GENERATE_TIMEOUT_MS, + retry: { max: 0, baseDelayMs: 0 }, + }) +} diff --git a/src/entities/presentation/types.ts b/src/entities/presentation/types.ts new file mode 100644 index 0000000..feade68 --- /dev/null +++ b/src/entities/presentation/types.ts @@ -0,0 +1,23 @@ +/** + * Types publics du domaine `presentation` — Sprint 4c-2. + * + * Le domaine couvre la génération assistée d'un texte de présentation + * personnelle (Tâche 1 EO). Aucune persistance backend : le texte généré + * est mirroré côté client (localStorage `expria_eo_t1_presentation`) et + * porté dans le state du `SimulationFlowProvider` pour servir de + * référence pendant l'enregistrement. + */ + +/** Réponses au questionnaire — alignées sur le body du backend. */ +export interface PresentationReponses { + prenom_age_ville: string + formation_metier: string + situation_familiale: string + loisirs: string + motivation_canada: string +} + +/** Réponse de `POST /presentations/generate`. */ +export interface PresentationGenerated { + presentation: string +} diff --git a/src/entities/report/api.ts b/src/entities/report/api.ts index bbf5543..1a304a2 100644 --- a/src/entities/report/api.ts +++ b/src/entities/report/api.ts @@ -48,7 +48,14 @@ export function getReport(id: string): Promise { // Sprint 3.6a — le nouveau prompt maître (taxonomie + revelation + diagnostic + // criteres×6 champs + conseil_nclc + erreurs_codes) produit un JSON long ; // DeepSeek met typiquement 25-45 s pour répondre. Backend abort à 55 s. -const CORRECTION_TIMEOUT_MS = 60_000 +const CORRECTION_EE_TIMEOUT_MS = 60_000 + +// Sprint 4b.3 — EO en mode audio enchaîne Gemini transcribe (jusqu'à 60 s, +// 30 s + 1 retry de 30 s) puis DeepSeek correction (55 s côté backend). +// Pire cas serveur ≈ 115 s : on alloue 120 s côté client pour ne pas couper +// avant que la mutation aboutisse (le rapport apparaissait sinon dans +// l'historique sans navigation vers /rapport/:id). +const CORRECTION_EO_TIMEOUT_MS = 120_000 /** Soumet une production écrite pour correction. Endpoint : `POST /corrections/ee`. * Payload : { simulationId, contenu, tache } @@ -57,7 +64,7 @@ export function correctEe(payload: CorrectEePayload): Promise { return apiFetch('/corrections/ee', { method: 'POST', body: payload, - timeoutMs: CORRECTION_TIMEOUT_MS, + timeoutMs: CORRECTION_EE_TIMEOUT_MS, }) } @@ -69,7 +76,7 @@ export function correctEo(payload: CorrectEoPayload): Promise { return apiFetch('/corrections/eo', { method: 'POST', body: payload, - timeoutMs: CORRECTION_TIMEOUT_MS, + timeoutMs: CORRECTION_EO_TIMEOUT_MS, }) } diff --git a/src/entities/report/types.ts b/src/entities/report/types.ts index 89d01b7..7044652 100644 --- a/src/entities/report/types.ts +++ b/src/entities/report/types.ts @@ -127,10 +127,23 @@ export interface CorrectEePayload { * Corps de `POST /corrections/eo`. * transcript : transcription audio envoyée au backend (implémenté Sprint 4). */ +/** + * Corps de `POST /corrections/eo`. + * + * Modes (XOR — exactement un des deux) : + * - `transcript` (Sprint 4) : transcription texte fournie directement par le client. + * - `audioBase64` + `mimeType` (Sprint 4b.2) : audio brut, le backend transcrit + * via Gemini batch puis poursuit le pipeline correction. + */ export interface CorrectEoPayload { simulationId: string - transcript: string tache: string + /** Sprint 4a backend — cible NCLC (9 par défaut, 10 pour viser plus haut). */ + nclc_cible?: 9 | 10 + transcript?: string + audioBase64?: string + /** MIME du payload audio (audio/webm | audio/mp4 | audio/wav). */ + mimeType?: string } /** diff --git a/src/entities/transcription/__tests__/api.test.ts b/src/entities/transcription/__tests__/api.test.ts new file mode 100644 index 0000000..cd407cb --- /dev/null +++ b/src/entities/transcription/__tests__/api.test.ts @@ -0,0 +1,49 @@ +/** + * Tests du domaine `transcription` — Sprint 4c-1. + * + * Valide : + * - succès : retourne le token et expires_in + * - erreur : ApiError propagée par apiFetch + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('@/shared/lib/api-client', () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from '@/shared/lib/api-client' +import { requestDeepgramToken } from '../api' + +const mocked = vi.mocked(apiFetch) + +describe('requestDeepgramToken', () => { + beforeEach(() => { + mocked.mockReset() + }) + + it('retourne le token et expires_in en cas de succès', async () => { + mocked.mockResolvedValueOnce({ token: 'dg-temp-abc', expires_in: 600 }) + + const result = await requestDeepgramToken() + + expect(result).toEqual({ token: 'dg-temp-abc', expires_in: 600 }) + expect(mocked).toHaveBeenCalledWith('/transcriptions/token', { + method: 'POST', + timeoutMs: 10_000, + retry: { max: 0, baseDelayMs: 0 }, + }) + }) + + it('propage les ApiError du backend', async () => { + mocked.mockRejectedValueOnce({ + error: true, + code: 'AUTH_REQUIRED', + message: 'Auth required', + }) + + await expect(requestDeepgramToken()).rejects.toMatchObject({ + code: 'AUTH_REQUIRED', + }) + }) +}) diff --git a/src/entities/transcription/api.ts b/src/entities/transcription/api.ts new file mode 100644 index 0000000..cbc7c94 --- /dev/null +++ b/src/entities/transcription/api.ts @@ -0,0 +1,21 @@ +/** + * Appels API du domaine `transcription`. + * + * `POST /transcriptions/token` : timeout 10 s, retry désactivé. + * Le retry est désactivé volontairement : un POST non-idempotent qui + * consomme un crédit Deepgram à chaque appel ne doit pas être rejoué + * silencieusement en cas d'erreur réseau transitoire. + */ + +import { apiFetch } from '@/shared/lib/api-client' +import type { TranscriptionToken } from './types' + +const TOKEN_TIMEOUT_MS = 10_000 + +export function requestDeepgramToken(): Promise { + return apiFetch('/transcriptions/token', { + method: 'POST', + timeoutMs: TOKEN_TIMEOUT_MS, + retry: { max: 0, baseDelayMs: 0 }, + }) +} diff --git a/src/entities/transcription/types.ts b/src/entities/transcription/types.ts new file mode 100644 index 0000000..6807dd3 --- /dev/null +++ b/src/entities/transcription/types.ts @@ -0,0 +1,15 @@ +/** + * Types publics du domaine `transcription`. + * + * Le frontend obtient un token Deepgram éphémère via le backend + * (`POST /transcriptions/token`) puis ouvre une connexion WebSocket + * directe vers Deepgram pour la transcription live. La clé maître + * Deepgram reste côté backend (cf. SECURITY.md). + */ + +export interface TranscriptionToken { + /** JWT éphémère Deepgram (durée de vie ~10 min). */ + token: string + /** Durée de validité du token, en secondes. */ + expires_in: number +} diff --git a/src/features/simulations/components/AudioRecorder.tsx b/src/features/simulations/components/AudioRecorder.tsx new file mode 100644 index 0000000..9e86836 --- /dev/null +++ b/src/features/simulations/components/AudioRecorder.tsx @@ -0,0 +1,187 @@ +/** + * Composant d'enregistrement audio pour les productions orales — + * Sprint 4c-1, simplifié au Sprint 4c-3. + * + * Encapsule `useAudioRecorder` côté UI : timer montant MM:SS, indicateur + * visuel d'enregistrement, garde-fou minimum 30 s, bouton de téléchargement + * local de l'audio (le backend ne stocke aucun audio). + * + * Le streaming chunk-par-chunk a été retiré au Sprint 4c-3 : l'audio est + * envoyé entier au backend après stop, le backend appelle Gemini batch pour + * transcrire. `useAudioRecorder.subscribeChunks` reste exposé côté hook + * pour un usage futur (ex. réactivation Deepgram live). + */ + +import { useEffect } from 'react' +import { Download, Mic, MicOff, Square } from 'lucide-react' +import { Button } from '@/shared/ui/Button' +import { formatTimer } from '../lib/simulationConfig' +import { useAudioRecorder } from '../hooks/useAudioRecorder' + +interface Props { + /** Durée minimale (s) avant que la soumission soit autorisée. */ + minSeconds: number + /** + * Sprint 4b.3 — durée maximale recommandée (s). À l'atteinte, le hook + * arrête automatiquement l'enregistrement et l'`onSubmit` est déclenché + * via le chemin existant (status='stopped' → useEffect onSubmit). + */ + maxSeconds?: number + /** Notification optionnelle quand `maxSeconds` est atteint. */ + onMaxReached?: () => void + /** Nom de fichier proposé au téléchargement local (sans extension). */ + downloadFilename: string + /** Appelé au clic « Arrêter et soumettre » avec le blob final + son MIME. */ + onSubmit: (audioBlob: Blob, audioMimeType: string | null) => void + onCancel: () => void + /** Initialisé à true → l'utilisateur démarre l'enregistrement automatiquement + * au mount. Sinon, un bouton « Démarrer » est affiché. */ + autoStart?: boolean + /** + * Sprint 4c-3 — désactive les contrôles tant qu'une soumission est en + * cours côté parent (transcription + correction backend ~30-60 s). + */ + disabled?: boolean +} + +export function AudioRecorder({ + minSeconds, + maxSeconds, + onMaxReached, + downloadFilename, + onSubmit, + onCancel, + autoStart = true, + disabled = false, +}: Props) { + const recorder = useAudioRecorder({ maxSeconds, onMaxReached }) + + // Auto-start au mount si demandé. Pas de dépendance sur `recorder.start` + // pour éviter les re-runs au changement d'identité de la fonction. + useEffect(() => { + if (!autoStart) return + void recorder.start() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoStart]) + + const isRecording = recorder.status === 'recording' + const isStopped = recorder.status === 'stopped' + const remaining = Math.max(0, minSeconds - recorder.elapsedSeconds) + const submitEnabled = isRecording && remaining === 0 + + function handleSubmitClick() { + recorder.stop() + } + + // Quand le recorder passe en 'stopped', on remonte le blob au parent. + useEffect(() => { + if (recorder.status === 'stopped' && recorder.audioBlob) { + onSubmit(recorder.audioBlob, recorder.audioMimeType) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recorder.status, recorder.audioBlob]) + + if (recorder.status === 'error') { + return ( +
+
+
+
+ + +
+
+ ) + } + + return ( +
+
+
+ + {isRecording && remaining > 0 && ( +

+ Minimum 30 secondes requis ({remaining} s restantes). +

+ )} + +
+ {isRecording && ( + <> + + + + )} + + {recorder.status === 'idle' && ( + + )} + + {isStopped && ( + + )} +
+
+ ) +} diff --git a/src/features/simulations/components/TranscriptionDisplay.tsx b/src/features/simulations/components/TranscriptionDisplay.tsx new file mode 100644 index 0000000..0d65ef9 --- /dev/null +++ b/src/features/simulations/components/TranscriptionDisplay.tsx @@ -0,0 +1,60 @@ +/** + * Affichage du transcript live Deepgram — Sprint 4c-1. + * + * Présente le transcript final accumulé + l'interim en cours (en italique). + * Compteur de mots informatif. Empty state explicite tant qu'aucun mot n'a + * été retourné. + */ + +import { Loader2 } from 'lucide-react' +import { countWords } from '../lib/simulationConfig' + +interface Props { + /** Transcript final accumulé (segments is_final=true). */ + transcript: string + /** Buffer interim (segment is_final=false en cours). */ + interim?: string + /** True quand la WS Deepgram est ouverte. */ + isConnected: boolean +} + +export function TranscriptionDisplay({ transcript, interim = '', isConnected }: Props) { + const total = transcript + (interim ? ` ${interim}` : '') + const wordCount = countWords(transcript) + const isEmpty = total.trim().length === 0 + + return ( +
+
+
+ {isConnected ? ( + <> +
+ + {wordCount} mot{wordCount > 1 ? 's' : ''} + +
+ +
+ {isEmpty ? ( + En attente du premier mot… + ) : ( + <> + {transcript} + {interim && {interim}} + + )} +
+
+ ) +} diff --git a/src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx b/src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx new file mode 100644 index 0000000..0045ea9 --- /dev/null +++ b/src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { TranscriptionDisplay } from '../TranscriptionDisplay' + +describe('TranscriptionDisplay', () => { + it('affiche un état "Transcription en attente" quand non connecté et vide', () => { + render() + expect(screen.getByText(/Transcription en attente/i)).toBeInTheDocument() + expect(screen.getByText(/En attente du premier mot/i)).toBeInTheDocument() + expect(screen.getByText(/^0 mot$/)).toBeInTheDocument() + }) + + it('affiche le label "Transcription en cours…" quand connecté', () => { + render() + expect(screen.getByText(/Transcription en cours/i)).toBeInTheDocument() + }) + + it("compte les mots du transcript final (ignore l'interim)", () => { + render( + , + ) + expect(screen.getByText(/^5 mots$/)).toBeInTheDocument() + }) + + it('rend transcript final + interim concaténés', () => { + const { container } = render( + , + ) + expect(container.textContent).toContain('Bonjour') + expect(container.textContent).toContain('je continue') + }) +}) diff --git a/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts b/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts new file mode 100644 index 0000000..bf87541 --- /dev/null +++ b/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts @@ -0,0 +1,195 @@ +/** + * Tests du hook useAudioRecorder — Sprint 4c-1. + * + * jsdom ne fournit ni MediaRecorder ni navigator.mediaDevices : on les mocke. + * On valide : + * - permission denied → status 'error' + permissionDenied=true + * - start → status 'recording', timer incrémente + * - stop → status 'stopped' + audioBlob produit + * - subscribeChunks reçoit les chunks pendant l'enregistrement + */ + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAudioRecorder } from '../useAudioRecorder' + +// ── Mocks MediaRecorder + getUserMedia ────────────────────────────────── + +class FakeMediaStream { + getTracks() { + return [{ stop: vi.fn() }] + } +} + +interface FakeRecorderInstance { + state: 'inactive' | 'recording' + start: (timeslice?: number) => void + stop: () => void + ondataavailable: ((e: { data: Blob }) => void) | null + onstop: (() => void) | null + onerror: ((e: unknown) => void) | null + emitChunk: (chunk: Blob) => void +} + +const recorderInstances: FakeRecorderInstance[] = [] + +class FakeMediaRecorder { + state: 'inactive' | 'recording' = 'inactive' + ondataavailable: ((e: { data: Blob }) => void) | null = null + onstop: (() => void) | null = null + onerror: ((e: unknown) => void) | null = null + + constructor() { + const inst: FakeRecorderInstance = { + get state() { + return self.state + }, + set state(v: 'inactive' | 'recording') { + self.state = v + }, + start: (timeslice?: number) => self.start(timeslice), + stop: () => self.stop(), + get ondataavailable() { + return self.ondataavailable + }, + set ondataavailable(v) { + self.ondataavailable = v + }, + get onstop() { + return self.onstop + }, + set onstop(v) { + self.onstop = v + }, + get onerror() { + return self.onerror + }, + set onerror(v) { + self.onerror = v + }, + emitChunk: (chunk: Blob) => self.ondataavailable?.({ data: chunk }), + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this + recorderInstances.push(inst) + } + + static isTypeSupported(_t: string): boolean { + return true + } + + start(_timeslice?: number) { + this.state = 'recording' + } + + stop() { + this.state = 'inactive' + this.onstop?.() + } +} + +function setupMediaMocks(opts: { allow: boolean } = { allow: true }) { + ;(globalThis as unknown as { MediaRecorder: typeof FakeMediaRecorder }).MediaRecorder = + FakeMediaRecorder + Object.defineProperty(globalThis, 'navigator', { + value: { + mediaDevices: { + getUserMedia: vi.fn().mockImplementation(() => { + if (!opts.allow) { + const err = new Error('denied') + err.name = 'NotAllowedError' + return Promise.reject(err) + } + return Promise.resolve(new FakeMediaStream()) + }), + }, + }, + writable: true, + configurable: true, + }) +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +describe('useAudioRecorder', () => { + beforeEach(() => { + recorderInstances.length = 0 + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("permission denied → status 'error' et permissionDenied=true", async () => { + setupMediaMocks({ allow: false }) + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.start() + }) + + expect(result.current.status).toBe('error') + expect(result.current.permissionDenied).toBe(true) + }) + + it("start passe en 'recording' et le timer incrémente", async () => { + setupMediaMocks({ allow: true }) + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.start() + }) + + expect(result.current.status).toBe('recording') + expect(result.current.elapsedSeconds).toBe(0) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + + expect(result.current.elapsedSeconds).toBe(3) + }) + + it("stop produit un audioBlob et passe en 'stopped'", async () => { + setupMediaMocks({ allow: true }) + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.start() + }) + + const inst = recorderInstances[0]! + act(() => { + inst.emitChunk(new Blob(['chunk1'], { type: 'audio/webm' })) + }) + + act(() => { + result.current.stop() + }) + + expect(result.current.status).toBe('stopped') + expect(result.current.audioBlob).toBeInstanceOf(Blob) + }) + + it('subscribeChunks reçoit les chunks émis pendant l’enregistrement', async () => { + setupMediaMocks({ allow: true }) + const { result } = renderHook(() => useAudioRecorder()) + + const received: Blob[] = [] + const unsub = result.current.subscribeChunks((c) => received.push(c)) + + await act(async () => { + await result.current.start() + }) + + const inst = recorderInstances[0]! + act(() => { + inst.emitChunk(new Blob(['a'], { type: 'audio/webm' })) + inst.emitChunk(new Blob(['b'], { type: 'audio/webm' })) + }) + + expect(received).toHaveLength(2) + unsub() + }) +}) diff --git a/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts b/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts new file mode 100644 index 0000000..ab54fd7 --- /dev/null +++ b/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts @@ -0,0 +1,216 @@ +/** + * Tests du hook useDeepgramLive — Sprint 4c-1. + * + * jsdom ne fournit pas de WebSocket utilisable : on installe un fake + * minimaliste pilotable depuis les tests. On valide : + * - connect → demande un token + ouvre une WS sur le bon endpoint + * - is_final → append au transcript ; sinon → interim + * - sendChunk envoie via la WS ouverte ; bufferise sinon + * - close envoie CloseStream et passe en status='closed' + * - rotation : un nouveau token est demandé avant expiration + */ + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/entities/transcription/api', () => ({ + requestDeepgramToken: vi.fn(), +})) + +import { requestDeepgramToken } from '@/entities/transcription/api' +import { useDeepgramLive } from '../useDeepgramLive' + +const mockedToken = vi.mocked(requestDeepgramToken) + +// ── Fake WebSocket ────────────────────────────────────────────────────── + +type WSListener = (e: { data: string }) => void + +interface FakeWS { + url: string + readyState: number + send: ReturnType + close: ReturnType + onopen: (() => void) | null + onmessage: WSListener | null + onerror: (() => void) | null + onclose: (() => void) | null + addEventListener: (e: string, cb: () => void) => void + removeEventListener: (e: string, cb: () => void) => void + emitOpen: () => void + emitMessage: (payload: unknown) => void + protocols: string | string[] | undefined +} + +const wsInstances: FakeWS[] = [] + +class FakeWebSocket implements Partial { + static OPEN = 1 + static CLOSED = 3 + url: string + protocols: string | string[] | undefined + readyState = 0 + send = vi.fn() + close = vi.fn(() => { + this.readyState = FakeWebSocket.CLOSED + }) + onopen: (() => void) | null = null + onmessage: WSListener | null = null + onerror: (() => void) | null = null + onclose: (() => void) | null = null + private listeners: Map void>> = new Map() + + constructor(url: string, protocols?: string | string[]) { + this.url = url + this.protocols = protocols + wsInstances.push(this as unknown as FakeWS) + } + + addEventListener(event: string, cb: () => void) { + const arr = this.listeners.get(event) ?? [] + arr.push(cb) + this.listeners.set(event, arr) + } + removeEventListener(event: string, cb: () => void) { + const arr = this.listeners.get(event) ?? [] + this.listeners.set( + event, + arr.filter((c) => c !== cb), + ) + } + emitOpen() { + this.readyState = FakeWebSocket.OPEN + this.onopen?.() + ;(this.listeners.get('open') ?? []).forEach((cb) => cb()) + } + emitMessage(payload: unknown) { + this.onmessage?.({ data: JSON.stringify(payload) }) + } +} + +beforeEach(() => { + wsInstances.length = 0 + ;(globalThis as unknown as { WebSocket: typeof FakeWebSocket }).WebSocket = FakeWebSocket + mockedToken.mockReset() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +// ── Tests ─────────────────────────────────────────────────────────────── + +describe('useDeepgramLive', () => { + it('connect demande un token et ouvre une WS sur Deepgram', async () => { + mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) + const { result } = renderHook(() => useDeepgramLive()) + + await act(async () => { + await result.current.connect() + }) + + expect(mockedToken).toHaveBeenCalledTimes(1) + expect(wsInstances).toHaveLength(1) + expect(wsInstances[0]!.url).toContain('wss://api.deepgram.com/v1/listen') + expect(wsInstances[0]!.url).toContain('language=fr') + expect(wsInstances[0]!.url).toContain('model=nova-2') + // Le token n'est PAS dans l'URL — il est passé via Sec-WebSocket-Protocol. + expect(wsInstances[0]!.url).not.toContain('token=') + expect(wsInstances[0]!.protocols).toEqual(['token', 'tok-1']) + }) + + it('is_final accumule le transcript ; interim non accumulé', async () => { + mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) + const { result } = renderHook(() => useDeepgramLive()) + + await act(async () => { + await result.current.connect() + }) + + const ws = wsInstances[0]! + act(() => ws.emitOpen()) + + act(() => { + ws.emitMessage({ + channel: { alternatives: [{ transcript: 'Bonjour' }] }, + is_final: false, + }) + }) + expect(result.current.interim).toBe('Bonjour') + expect(result.current.transcript).toBe('') + + act(() => { + ws.emitMessage({ + channel: { alternatives: [{ transcript: 'Bonjour je m appelle Pierre' }] }, + is_final: true, + }) + }) + expect(result.current.transcript).toBe('Bonjour je m appelle Pierre') + expect(result.current.interim).toBe('') + }) + + it('sendChunk envoie sur la WS ouverte', async () => { + mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) + const { result } = renderHook(() => useDeepgramLive()) + + await act(async () => { + await result.current.connect() + }) + + const ws = wsInstances[0]! + act(() => ws.emitOpen()) + + const blob = new Blob(['chunk'], { type: 'audio/webm' }) + act(() => result.current.sendChunk(blob)) + + expect(ws.send).toHaveBeenCalledWith(blob) + }) + + it("close envoie CloseStream et passe en status='closed'", async () => { + mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) + const { result } = renderHook(() => useDeepgramLive()) + + await act(async () => { + await result.current.connect() + }) + + const ws = wsInstances[0]! + act(() => ws.emitOpen()) + + act(() => result.current.close()) + + expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'CloseStream' })) + expect(ws.close).toHaveBeenCalled() + expect(result.current.status).toBe('closed') + }) + + it('rotation : un nouveau token est demandé à T-60 s avant expiration', async () => { + mockedToken + .mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) + .mockResolvedValueOnce({ token: 'tok-2', expires_in: 600 }) + + const { result } = renderHook(() => useDeepgramLive()) + + await act(async () => { + await result.current.connect() + }) + act(() => wsInstances[0]!.emitOpen()) + + expect(mockedToken).toHaveBeenCalledTimes(1) + + // Avancer juste avant l'échéance (rotation à T-60 s = 540 s). + await act(async () => { + await vi.advanceTimersByTimeAsync(539_000) + }) + expect(mockedToken).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect(mockedToken).toHaveBeenCalledTimes(2) + expect(wsInstances).toHaveLength(2) + expect(wsInstances[1]!.url).not.toContain('token=') + expect(wsInstances[1]!.protocols).toEqual(['token', 'tok-2']) + }) +}) diff --git a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx index e9ed3c6..0f8394b 100644 --- a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx +++ b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx @@ -114,9 +114,11 @@ describe('useSimulation — selectTask', () => { expect(result.current.production).toEqual(mockProduction) }) - it('step passe directement à task-selected pour EO_T1 (sans catalogue)', async () => { - const eoProduction: Production = { ...mockProduction, tache: 'EO_T1' } - mockCreateSimulation.mockResolvedValue(eoProduction) + it('Sprint 4c-2 — selectTask EO_T1 crée la simulation et passe à task-selected (sans catalogue)', async () => { + // L'interception de 4c-1 est levée : EO_T1 dispose désormais d'un flux + // dédié (/simulation/eo/t1/mode). Sans catalogue → step=task-selected. + const eoT1Production: Production = { ...mockProduction, tache: 'EO_T1' } + mockCreateSimulation.mockResolvedValue(eoT1Production) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) @@ -125,7 +127,8 @@ describe('useSimulation — selectTask', () => { }) await waitFor(() => expect(result.current.step).toBe('task-selected')) - expect(result.current.production).toEqual(eoProduction) + expect(result.current.production).toEqual(eoT1Production) + expect(mockCreateSimulation).toHaveBeenCalledTimes(1) }) it('isCreating = true pendant la mutation createSimulation', async () => { diff --git a/src/features/simulations/hooks/useAudioRecorder.ts b/src/features/simulations/hooks/useAudioRecorder.ts new file mode 100644 index 0000000..1211377 --- /dev/null +++ b/src/features/simulations/hooks/useAudioRecorder.ts @@ -0,0 +1,271 @@ +/** + * Hook MediaRecorder pour les productions orales — Sprint 4c-1. + * + * Capture le micro via getUserMedia + MediaRecorder, expose un timer montant + * et un Blob webm/opus à l'arrêt. Permet aussi de s'abonner aux chunks + * (timeslice 250 ms) pour streamer en parallèle vers Deepgram. + * + * Compat : préfère `audio/webm;codecs=opus`, fallback `audio/webm`, puis + * `audio/mp4` (Safari iOS — cf. FTD audio iOS). + * + * Le hook ne stocke pas l'audio côté serveur — la sauvegarde locale via + * `downloadAudio` est une commodité utilisateur (cf. Sprint 4b backend). + */ + +import { useCallback, useEffect, useRef, useState } from 'react' + +export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopped' | 'error' + +export interface UseAudioRecorderOptions { + /** + * Sprint 4b.3 — durée maximale d'enregistrement en secondes. Quand + * `elapsedSeconds` atteint cette valeur, le hook stoppe automatiquement + * le MediaRecorder et appelle `onMaxReached` une fois. + */ + maxSeconds?: number + onMaxReached?: () => void +} + +export interface UseAudioRecorderResult { + status: RecorderStatus + elapsedSeconds: number + audioBlob: Blob | null + audioMimeType: string | null + error: string | null + permissionDenied: boolean + start: () => Promise + stop: () => void + cancel: () => void + downloadAudio: (filename: string) => void + /** S'abonne aux chunks (timeslice). Retourne un unsubscribe. */ + subscribeChunks: (cb: (chunk: Blob) => void) => () => void +} + +/** Choisit le mimeType supporté par le navigateur, par ordre de préférence. */ +function pickMimeType(): string | null { + if (typeof MediaRecorder === 'undefined') return null + const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'] + for (const m of candidates) { + if (MediaRecorder.isTypeSupported(m)) return m + } + return null +} + +const TIMESLICE_MS = 250 + +export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderResult { + const [status, setStatus] = useState('idle') + const [elapsedSeconds, setElapsedSeconds] = useState(0) + const [audioBlob, setAudioBlob] = useState(null) + const [audioMimeType, setAudioMimeType] = useState(null) + const [error, setError] = useState(null) + const [permissionDenied, setPermissionDenied] = useState(false) + + const recorderRef = useRef(null) + const streamRef = useRef(null) + const chunksRef = useRef([]) + const timerRef = useRef | null>(null) + const subscribersRef = useRef void>>(new Set()) + + // Capture options dans une ref pour éviter de réabonner les effets sur + // chaque render (les callers fournissent souvent des fonctions inline). + const optionsRef = useRef(options) + optionsRef.current = options + const maxReachedFiredRef = useRef(false) + + const cleanupTimer = useCallback(() => { + if (timerRef.current !== null) { + clearInterval(timerRef.current) + timerRef.current = null + } + }, []) + + const cleanupStream = useCallback(() => { + streamRef.current?.getTracks().forEach((t) => t.stop()) + streamRef.current = null + }, []) + + const start = useCallback(async () => { + if (status === 'recording' || status === 'requesting') return + + setError(null) + setPermissionDenied(false) + setAudioBlob(null) + setElapsedSeconds(0) + chunksRef.current = [] + setStatus('requesting') + + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { + setError('Votre navigateur ne supporte pas la capture audio.') + setStatus('error') + return + } + + let stream: MediaStream + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (err) { + const name = err instanceof Error ? err.name : '' + if (name === 'NotAllowedError' || name === 'SecurityError') { + setPermissionDenied(true) + setError("L'accès au micro est refusé. Autorisez-le dans les réglages du navigateur.") + } else { + setError("Impossible d'accéder au micro. Vérifiez vos périphériques.") + } + setStatus('error') + return + } + streamRef.current = stream + + const mimeType = pickMimeType() + if (!mimeType) { + cleanupStream() + setError('Aucun format audio supporté par votre navigateur.') + setStatus('error') + return + } + setAudioMimeType(mimeType) + + let recorder: MediaRecorder + try { + recorder = new MediaRecorder(stream, { mimeType }) + } catch { + cleanupStream() + setError("Impossible d'initialiser l'enregistreur audio.") + setStatus('error') + return + } + recorderRef.current = recorder + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + chunksRef.current.push(event.data) + subscribersRef.current.forEach((cb) => cb(event.data)) + } + } + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { type: mimeType }) + setAudioBlob(blob) + cleanupStream() + cleanupTimer() + setStatus('stopped') + } + recorder.onerror = () => { + cleanupStream() + cleanupTimer() + setError("L'enregistrement a échoué.") + setStatus('error') + } + + recorder.start(TIMESLICE_MS) + setStatus('recording') + maxReachedFiredRef.current = false + + timerRef.current = setInterval(() => { + setElapsedSeconds((s) => { + const next = s + 1 + const max = optionsRef.current.maxSeconds + // Cap visuel à `max` et arrête d'incrémenter au-delà. L'auto-stop + // est déclenché par l'effet observant `elapsedSeconds`. + return max && next >= max ? max : next + }) + }, 1000) + }, [status, cleanupStream, cleanupTimer]) + + const stop = useCallback(() => { + // Arrêter le timer SYNCHRONE — sinon il continue d'incrémenter pendant + // les ~50-200 ms entre l'appel à `recorder.stop()` et la réception du + // callback `onstop` (qui appelle aussi cleanupTimer en sécurité). + cleanupTimer() + const recorder = recorderRef.current + if (recorder && recorder.state !== 'inactive') { + recorder.stop() + } + }, [cleanupTimer]) + + const cancel = useCallback(() => { + const recorder = recorderRef.current + if (recorder && recorder.state !== 'inactive') { + // Vide les chunks AVANT le stop pour produire un blob nul. + chunksRef.current = [] + recorder.stop() + } + cleanupStream() + cleanupTimer() + setStatus('idle') + setElapsedSeconds(0) + setAudioBlob(null) + }, [cleanupStream, cleanupTimer]) + + const downloadAudio = useCallback( + (filename: string) => { + if (!audioBlob) return + const url = URL.createObjectURL(audioBlob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, + [audioBlob], + ) + + const subscribeChunks = useCallback((cb: (chunk: Blob) => void) => { + subscribersRef.current.add(cb) + return () => { + subscribersRef.current.delete(cb) + } + }, []) + + // Sprint 4b.3 — auto-stop à expiration de la durée recommandée. + // Quand le timer atteint `maxSeconds`, on stoppe le MediaRecorder (ce qui + // déclenche `onstop` → audioBlob, status='stopped') et on notifie le caller + // une seule fois via `onMaxReached`. Le composant parent peut câbler son + // onSubmit sur le passage en status='stopped' (cf. AudioRecorder). + useEffect(() => { + if (status !== 'recording') return + const max = optionsRef.current.maxSeconds + if (!max || elapsedSeconds < max) return + if (maxReachedFiredRef.current) return + maxReachedFiredRef.current = true + cleanupTimer() + const recorder = recorderRef.current + if (recorder && recorder.state !== 'inactive') { + recorder.stop() + } + optionsRef.current.onMaxReached?.() + }, [elapsedSeconds, status, cleanupTimer]) + + // Cleanup global au démontage : libère le micro même si l'utilisateur + // navigue ailleurs sans cliquer sur Stop ou Annuler. + useEffect(() => { + return () => { + cleanupTimer() + const recorder = recorderRef.current + if (recorder && recorder.state !== 'inactive') { + try { + recorder.stop() + } catch { + /* noop */ + } + } + cleanupStream() + } + }, [cleanupStream, cleanupTimer]) + + return { + status, + elapsedSeconds, + audioBlob, + audioMimeType, + error, + permissionDenied, + start, + stop, + cancel, + downloadAudio, + subscribeChunks, + } +} diff --git a/src/features/simulations/hooks/useDeepgramLive.ts b/src/features/simulations/hooks/useDeepgramLive.ts new file mode 100644 index 0000000..52006ca --- /dev/null +++ b/src/features/simulations/hooks/useDeepgramLive.ts @@ -0,0 +1,226 @@ +/** + * Hook de transcription live Deepgram — Sprint 4c-1. + * + * Demande un token éphémère au backend (`POST /transcriptions/token`), + * ouvre une connexion WebSocket directe vers Deepgram, expose le + * transcript final accumulé + l'interim en cours. + * + * Rotation de token : Deepgram fournit un token valide ~10 min. On + * redemande un nouveau token à T-60 s avant expiration et on bascule + * la WebSocket en hot-swap (open new → ferme l'ancienne). Pendant le + * gap (typiquement < 200 ms), des chunks peuvent être perdus — + * acceptable au MVP, durci en Sprint 4c-2 (FTD à tracer). + * + * Paramètres Deepgram (cf. consigne Hermann) : + * language=fr, model=nova-2, smart_format=true, + * interim_results=true, punctuate=true. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { requestDeepgramToken } from '@/entities/transcription/api' + +const DEEPGRAM_BASE = 'wss://api.deepgram.com/v1/listen' +const DEEPGRAM_QUERY = + 'language=fr&model=nova-2&smart_format=true&interim_results=true&punctuate=true' +const ROTATION_LEAD_SECONDS = 60 + +export type DeepgramStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error' + +export interface UseDeepgramLiveResult { + status: DeepgramStatus + /** Transcript final accumulé (chaque segment is_final=true ajouté). */ + transcript: string + /** Buffer interim courant (segment is_final=false en attente). */ + interim: string + isConnected: boolean + error: string | null + connect: () => Promise + sendChunk: (chunk: Blob) => void + close: () => void +} + +interface DeepgramMessage { + channel?: { alternatives?: { transcript?: string }[] } + is_final?: boolean + type?: string +} + +export function useDeepgramLive(): UseDeepgramLiveResult { + const [status, setStatus] = useState('idle') + const [transcript, setTranscript] = useState('') + const [interim, setInterim] = useState('') + const [error, setError] = useState(null) + + const wsRef = useRef(null) + const rotationTimerRef = useRef | null>(null) + const pendingChunksRef = useRef([]) + + const clearRotationTimer = useCallback(() => { + if (rotationTimerRef.current !== null) { + clearTimeout(rotationTimerRef.current) + rotationTimerRef.current = null + } + }, []) + + const wireWs = useCallback((ws: WebSocket) => { + ws.onopen = () => { + setStatus('open') + // Vider le buffer FIFO — chunks accumulés pendant `connecting`. + const pending = pendingChunksRef.current + pendingChunksRef.current = [] + for (const chunk of pending) { + if (ws.readyState === WebSocket.OPEN) ws.send(chunk) + } + } + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data as string) as DeepgramMessage + const text = data.channel?.alternatives?.[0]?.transcript ?? '' + if (!text) return + if (data.is_final) { + setTranscript((prev) => (prev ? `${prev} ${text}` : text)) + setInterim('') + } else { + setInterim(text) + } + } catch { + /* messages non-JSON ignorés (keep-alive, metadata) */ + } + } + ws.onerror = () => { + setError('Erreur de connexion à la transcription.') + setStatus('error') + } + ws.onclose = () => { + // Ne pas écraser un état 'error' déjà posé. + setStatus((s) => (s === 'error' ? s : 'closed')) + } + }, []) + + const openConnection = useCallback(async (): Promise => { + const { token, expires_in } = await requestDeepgramToken() + // Le navigateur ne permet pas de header custom à l'init d'une WebSocket : + // Deepgram accepte le JWT via Sec-WebSocket-Protocol en passant + // ['token', ''] comme sous-protocoles. Ne PAS mettre le token dans + // l'URL — l'auth via query string est rejetée pour les tokens éphémères + // (cf. doc Deepgram « WebSocket authentication »). + const url = `${DEEPGRAM_BASE}?${DEEPGRAM_QUERY}` + const ws = new WebSocket(url, ['token', token]) + wireWs(ws) + + // Programmer la rotation de token avant expiration. + const leadMs = Math.max((expires_in - ROTATION_LEAD_SECONDS) * 1000, 5_000) + clearRotationTimer() + rotationTimerRef.current = setTimeout(() => { + void rotateToken() + }, leadMs) + + return ws + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wireWs, clearRotationTimer]) + + // Hot-swap : ouvre une nouvelle WS avec un nouveau token, attend 'open', + // puis ferme l'ancienne. Si l'ouverture échoue, on garde l'ancienne. + const rotateToken = useCallback(async () => { + const oldWs = wsRef.current + try { + const newWs = await openConnection() + const swap = () => { + wsRef.current = newWs + if (oldWs && oldWs.readyState === WebSocket.OPEN) { + try { + oldWs.send(JSON.stringify({ type: 'CloseStream' })) + } catch { + /* noop */ + } + oldWs.close() + } + } + if (newWs.readyState === WebSocket.OPEN) { + swap() + } else { + const onOpen = () => { + newWs.removeEventListener('open', onOpen) + swap() + } + newWs.addEventListener('open', onOpen) + } + } catch { + // Conserver l'ancienne connexion ; nouvelle tentative à la prochaine échéance. + // FTD-XX : durcir avec une retry policy plus fine en Sprint 4c-2. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openConnection]) + + const connect = useCallback(async () => { + if (status === 'connecting' || status === 'open') return + setError(null) + setTranscript('') + setInterim('') + pendingChunksRef.current = [] + setStatus('connecting') + try { + const ws = await openConnection() + wsRef.current = ws + } catch (err) { + const message = err instanceof Error ? err.message : "Erreur d'initialisation." + setError(message) + setStatus('error') + } + }, [status, openConnection]) + + const sendChunk = useCallback((chunk: Blob) => { + const ws = wsRef.current + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(chunk) + return + } + // FIFO borné — évite la fuite mémoire si la WS reste closed. + const buf = pendingChunksRef.current + buf.push(chunk) + if (buf.length > 5) buf.shift() + }, []) + + const close = useCallback(() => { + clearRotationTimer() + const ws = wsRef.current + if (ws) { + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'CloseStream' })) + } + ws.close() + } catch { + /* noop */ + } + wsRef.current = null + } + setStatus('closed') + }, [clearRotationTimer]) + + // Cleanup global — coupe la WS et annule la rotation au démontage. + useEffect(() => { + return () => { + clearRotationTimer() + const ws = wsRef.current + if (ws) { + try { + ws.close() + } catch { + /* noop */ + } + } + } + }, [clearRotationTimer]) + + return { + status, + transcript, + interim, + isConnected: status === 'open', + error, + connect, + sendChunk, + close, + } +} diff --git a/src/features/simulations/lib/__tests__/simulationConfig.test.ts b/src/features/simulations/lib/__tests__/simulationConfig.test.ts new file mode 100644 index 0000000..241b40e --- /dev/null +++ b/src/features/simulations/lib/__tests__/simulationConfig.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { countWords, formatTimer, getSimulationConfig, isEOTache } from '../simulationConfig' + +describe('simulationConfig — Sprint 4c-1', () => { + it('EO_T1 : durée recommandée 120 s, min 30 s', () => { + const c = getSimulationConfig('EO_T1') + expect(c.dureeRecommandeeSecondes).toBe(120) + expect(c.enregistrementMinSecondes).toBe(30) + }) + + it('EO_T3 : durée recommandée 270 s (4 min 30), min 30 s', () => { + const c = getSimulationConfig('EO_T3') + expect(c.dureeRecommandeeSecondes).toBe(270) + expect(c.enregistrementMinSecondes).toBe(30) + }) + + it('EE_T1 : aucune durée recommandée (champ EO uniquement)', () => { + const c = getSimulationConfig('EE_T1') + expect(c.dureeRecommandeeSecondes).toBeUndefined() + expect(c.enregistrementMinSecondes).toBeUndefined() + }) + + it('isEOTache distingue EO de EE', () => { + expect(isEOTache('EO_T1')).toBe(true) + expect(isEOTache('EO_T3')).toBe(true) + expect(isEOTache('EE_T1')).toBe(false) + expect(isEOTache('EE_T2')).toBe(false) + expect(isEOTache('EE_T3')).toBe(false) + }) + + it('formatTimer pad correctement MM:SS', () => { + expect(formatTimer(0)).toBe('00:00') + expect(formatTimer(59)).toBe('00:59') + expect(formatTimer(60)).toBe('01:00') + expect(formatTimer(270)).toBe('04:30') + }) + + it('countWords sur transcript oral (espaces multiples ignorés)', () => { + expect(countWords('')).toBe(0) + expect(countWords(' ')).toBe(0) + expect(countWords('un mot')).toBe(2) + expect(countWords("Bonjour je m'appelle Pierre")).toBe(4) + }) +}) diff --git a/src/features/simulations/lib/simulationConfig.ts b/src/features/simulations/lib/simulationConfig.ts index 6dafcdc..ade996d 100644 --- a/src/features/simulations/lib/simulationConfig.ts +++ b/src/features/simulations/lib/simulationConfig.ts @@ -11,22 +11,53 @@ import type { Tache } from '@/entities/production/types' export interface SimulationConfig { - /** Durée du minuteur en minutes. */ + /** Durée du minuteur EE en minutes. Pour EO, durée informative non bloquante. */ dureeMinutes: number - /** Seuil minimum de mots pour autoriser la soumission. */ + /** Seuil minimum de mots EE. Non utilisé pour EO. */ motsMin: number - /** Borne basse de la cible TCF. */ + /** Borne basse de la cible TCF (mots). EE uniquement. */ motsCibleMin: number - /** Borne haute de la cible TCF. */ + /** Borne haute de la cible TCF (mots). EE uniquement. */ motsCibleMax: number + /** + * EO uniquement — durée recommandée d'enregistrement, en secondes. + * Affichée comme repère pédagogique, sans coupure automatique. + */ + dureeRecommandeeSecondes?: number + /** + * EO uniquement — durée minimale d'enregistrement avant que la soumission + * soit autorisée (sécurité contre les soumissions vides). + */ + enregistrementMinSecondes?: number } +const EO_MIN_RECORDING_SECONDS = 30 + const SIMULATION_CONFIG: Record = { EE_T1: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 120 }, EE_T2: { dureeMinutes: 20, motsMin: 30, motsCibleMin: 120, motsCibleMax: 150 }, EE_T3: { dureeMinutes: 30, motsMin: 30, motsCibleMin: 120, motsCibleMax: 180 }, - EO_T1: { dureeMinutes: 5, motsMin: 30, motsCibleMin: 30, motsCibleMax: 80 }, - EO_T3: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 150 }, + EO_T1: { + dureeMinutes: 2, + motsMin: 0, + motsCibleMin: 0, + motsCibleMax: 0, + dureeRecommandeeSecondes: 120, + enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS, + }, + EO_T3: { + dureeMinutes: 5, + motsMin: 0, + motsCibleMin: 0, + motsCibleMax: 0, + dureeRecommandeeSecondes: 270, + enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS, + }, +} + +/** True si la tâche est une production orale (EO_T1 ou EO_T3). */ +export function isEOTache(tache: Tache): boolean { + return tache.startsWith('EO_') } export function getSimulationConfig(tache: Tache): SimulationConfig { diff --git a/src/features/simulations/pages/EnregistrementEOPage.tsx b/src/features/simulations/pages/EnregistrementEOPage.tsx new file mode 100644 index 0000000..b1a5a96 --- /dev/null +++ b/src/features/simulations/pages/EnregistrementEOPage.tsx @@ -0,0 +1,171 @@ +/** + * Page /simulation/eo/enregistrement — Sprint 4c-1, refondue Sprint 4c-3. + * + * Capture audio via `` (basé sur `useAudioRecorder`). À l'arrêt : + * 1. Conversion du Blob en base64 via `blobToBase64`. + * 2. Appel `submitEoAudio(base64, mimeType)` du provider. + * 3. Le backend transcrit via Gemini batch puis corrige via DeepSeek. + * + * Aucun audio n'est stocké côté serveur — `` propose un + * téléchargement local après stop pour que l'utilisateur conserve son + * enregistrement s'il le souhaite. + */ + +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2, Timer } from 'lucide-react' +import { formatTache } from '@/entities/production/lib' +import { Badge } from '@/shared/ui/Badge' +import { blobToBase64 } from '@/shared/lib/audio' +import { useSimulationFlow } from '../state/simulationFlow' +import { AudioRecorder } from '../components/AudioRecorder' +import { SujetDisplay } from '../components/SujetDisplay' +import { getSimulationConfig, formatTimer } from '../lib/simulationConfig' + +export function EnregistrementEOPage() { + const navigate = useNavigate() + const { + step, + production, + sujet, + presentationT1, + isCorrecting, + correctError, + submitEoAudio, + reset, + } = useSimulationFlow() + + // Sprint 4c-3 — `submitting` couvre la fenêtre entre le clic « Arrêter » et + // le démarrage effectif de la mutation : conversion base64 du Blob (peut + // prendre quelques centaines de ms sur gros enregistrements) + petit décalage + // avant que `isCorrecting` ne passe à true. + const [submitting, setSubmitting] = useState(false) + const [encodingError, setEncodingError] = useState(null) + + // Garde-fous : refresh direct sans état → retour TaskSelector EO. + // NOTE : on n'inclut PAS `step === 'done'` ici. Quand correctEoMutation.onSuccess + // passe step à 'done' et navigate vers /rapport/:id, ce useEffect tirerait + // une seconde navigation (replace) qui écraserait la première — résultat : + // l'utilisateur reste sur /simulation/eo au lieu de voir son rapport. + const shouldRedirect = !production || step === 'idle' + useEffect(() => { + if (shouldRedirect) navigate('/simulation/eo', { replace: true }) + }, [shouldRedirect, navigate]) + + const handleSubmit = useCallback( + async (audioBlob: Blob, audioMimeType: string | null) => { + setEncodingError(null) + setSubmitting(true) + try { + const base64 = await blobToBase64(audioBlob) + // Normalisation du MIME : MediaRecorder produit souvent + // `audio/webm;codecs=opus`. Le backend compare par égalité stricte + // contre `audio/webm` / `audio/mp4` / `audio/wav` — on strip le + // suffixe `;codecs=...` ici. Fallback `audio/webm` si vide. + const rawMime = audioMimeType ?? 'audio/webm' + const normalizedMime = rawMime.split(';')[0]!.trim() || 'audio/webm' + submitEoAudio(base64, normalizedMime) + } catch (err) { + const message = err instanceof Error ? err.message : 'Encodage audio impossible.' + setEncodingError(message) + setSubmitting(false) + } + }, + [submitEoAudio], + ) + + const handleCancel = useCallback(() => { + reset() + navigate('/simulation/eo') + }, [reset, navigate]) + + if (shouldRedirect || !production) return null + + const config = getSimulationConfig(production.tache) + const dureeRecommandee = config.dureeRecommandeeSecondes ?? 0 + const minSeconds = config.enregistrementMinSecondes ?? 30 + + // Le composant AudioRecorder reste visible (pour le bouton « Télécharger + // l'audio ») mais ses contrôles d'arrêt/annulation sont désactivés pendant + // la soumission backend. + const lockControls = submitting || isCorrecting + + return ( +
+
+

{formatTache(production.tache)}

+ + +
+ + {/* T1 affiche la présentation générée comme texte de référence à lire. + T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */} + {production.tache === 'EO_T1' && presentationT1 && ( +
+

+ Ta présentation (référence) +

+
+ {presentationT1} +
+
+ )} + + {production.tache !== 'EO_T1' && sujet && ( +
+ +
+ )} + + + + {lockControls && ( +
+
+ )} + + {encodingError && ( +
+ {encodingError} +
+ )} + + {correctError && !lockControls && ( +
+ La correction a échoué. Réessayez dans quelques instants. +
+ )} +
+ ) +} diff --git a/src/features/simulations/pages/ModeChoixT1Page.tsx b/src/features/simulations/pages/ModeChoixT1Page.tsx new file mode 100644 index 0000000..7b89423 --- /dev/null +++ b/src/features/simulations/pages/ModeChoixT1Page.tsx @@ -0,0 +1,78 @@ +/** + * Page /simulation/eo/t1/mode — Sprint 4c-2. + * + * Choix du mode d'entraînement pour la Tâche 1 EO : + * a) Générer ma présentation → /simulation/eo/t1/questionnaire + * b) Enregistrer directement → /simulation/eo/pre-enregistrement + * + * Garde-fou : si la simulation courante n'est pas EO_T1 → retour TaskSelector EO. + * Aucune logique de plan ici (déjà vérifiée à la création de la simulation). + */ + +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Mic, Sparkles } from 'lucide-react' +import { Card } from '@/shared/ui/Card' +import { useSimulationFlow } from '../state/simulationFlow' + +export function ModeChoixT1Page() { + const navigate = useNavigate() + const { production, step, reset } = useSimulationFlow() + + const shouldRedirect = + !production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done' + + useEffect(() => { + if (shouldRedirect) navigate('/simulation/eo', { replace: true }) + }, [shouldRedirect, navigate]) + + if (shouldRedirect) return null + + return ( +
+
+ +
+ +

Tâche 1 — Présentation personnelle

+

Choisis ton mode d'entraînement.

+ +
+ navigate('/simulation/eo/t1/questionnaire')} + > + + + navigate('/simulation/eo/pre-enregistrement')} + > + +
+
+ ) +} diff --git a/src/features/simulations/pages/PreEnregistrementEOPage.tsx b/src/features/simulations/pages/PreEnregistrementEOPage.tsx new file mode 100644 index 0000000..b833878 --- /dev/null +++ b/src/features/simulations/pages/PreEnregistrementEOPage.tsx @@ -0,0 +1,105 @@ +/** + * Page /simulation/eo/pre-enregistrement — Sprint 4c-1. + * + * Affiche le sujet sélectionné, la durée recommandée pour la tâche, des + * instructions courtes, puis un bouton primaire qui démarre l'enregistrement. + * Fait le lien entre `SujetsEOPage` (choix d'un sujet T3) et + * `EnregistrementEOPage` (capture audio + transcription). + * + * Aucune logique de quota / plan ici : déjà vérifiée à la création. + */ + +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Mic, Timer } from 'lucide-react' +import { Button } from '@/shared/ui/Button' +import { Badge } from '@/shared/ui/Badge' +import { formatTache } from '@/entities/production/lib' +import { useSimulationFlow } from '../state/simulationFlow' +import { SujetDisplay } from '../components/SujetDisplay' +import { getSimulationConfig, formatTimer } from '../lib/simulationConfig' + +export function PreEnregistrementEOPage() { + const navigate = useNavigate() + const { step, production, sujet, setStep } = useSimulationFlow() + + // Garde-fous : refresh direct sans état → retour au TaskSelector EO. + const shouldRedirect = !production || step === 'idle' || step === 'done' + useEffect(() => { + if (shouldRedirect) navigate('/simulation/eo', { replace: true }) + }, [shouldRedirect, navigate]) + + if (shouldRedirect || !production) return null + + const config = getSimulationConfig(production.tache) + const dureeRecommandee = config.dureeRecommandeeSecondes ?? 0 + const isT1 = production.tache === 'EO_T1' + const isT3 = production.tache === 'EO_T3' + + // Sprint 4c-2 — T1 : pas de sujet pré-défini (présentation personnelle). + // Le titre, l'encart d'instructions et l'absence du bouton « Changer de + // sujet » diffèrent de T3. + const heading = isT1 ? 'Tâche 1 — Présentation personnelle' : formatTache(production.tache) + + function handleStart() { + setStep('recording') + navigate('/simulation/eo/enregistrement') + } + + function handleChangeSujet() { + navigate('/simulation/eo/sujets') + } + + return ( +
+
+

{heading}

+ + +
+ + {sujet && !isT1 && ( +
+ +
+ )} + +
+

Avant de commencer

+
    +
  • Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.
  • +
  • Autorisez l'accès au micro dans votre navigateur lorsque demandé.
  • +
  • Parlez de manière naturelle. La durée est indicative, pas un cap.
  • + {isT1 && ( +
  • + Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs, + projet d'immigration au Canada. +
  • + )} +
  • + Vous pourrez télécharger votre enregistrement à la fin — il n'est pas conservé sur nos + serveurs. +
  • +
+
+ +
+ + {isT3 && ( + + )} +
+
+ ) +} diff --git a/src/features/simulations/pages/PresentationGenereeT1Page.tsx b/src/features/simulations/pages/PresentationGenereeT1Page.tsx new file mode 100644 index 0000000..91a7a92 --- /dev/null +++ b/src/features/simulations/pages/PresentationGenereeT1Page.tsx @@ -0,0 +1,198 @@ +/** + * Page /simulation/eo/t1/presentation — Sprint 4c-2. + * + * Affiche la présentation générée par DeepSeek et permet : + * - Lecture (mode readonly par défaut) + * - Édition manuelle (toggle « Modifier » / « Enregistrer les modifications ») + * - Copier dans le presse-papier + * - Télécharger en .txt + * - Refaire (efface localStorage + retour questionnaire) + * + * Source du texte au mount, par ordre : + * 1. `presentationT1` du provider (vient de finir le questionnaire) + * 2. localStorage (refresh direct ou retour différé) + * 3. Aucune → redirection /simulation/eo/t1/mode + * + * Les modifications manuelles sont persistées localStorage + provider. + */ + +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Check, Copy, Download, Mic, Pencil, RotateCcw, Save } from 'lucide-react' +import { Button } from '@/shared/ui/Button' +import { useSimulationFlow } from '../state/simulationFlow' + +export function PresentationGenereeT1Page() { + const navigate = useNavigate() + const { production, step, presentationT1, setPresentationT1, setStep } = useSimulationFlow() + + // Garde-fou tâche EO_T1 + présence d'une présentation. Si absente, redirection + // vers /t1/mode pour relancer un questionnaire. + const shouldRedirect = + !production || + production.tache !== 'EO_T1' || + step === 'idle' || + step === 'done' || + presentationT1 === null + + useEffect(() => { + if (!production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done') { + navigate('/simulation/eo', { replace: true }) + return + } + if (presentationT1 === null) { + navigate('/simulation/eo/t1/mode', { replace: true }) + } + }, [production, step, presentationT1, navigate]) + + const [text, setText] = useState(presentationT1 ?? '') + const [isEditing, setIsEditing] = useState(false) + const [copied, setCopied] = useState(false) + + // Resync si le provider change (ex : retour depuis « Refaire » → null → re-générer). + useEffect(() => { + if (presentationT1 !== null && !isEditing) { + setText(presentationT1) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [presentationT1]) + + const downloadFilename = useMemo( + () => `expria-presentation-t1-${(production?.id ?? 'session').slice(0, 8)}.txt`, + [production?.id], + ) + + if (shouldRedirect) return null + + function handleCopy() { + if (typeof navigator === 'undefined' || !navigator.clipboard) return + void navigator.clipboard.writeText(text).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } + + function handleDownload() { + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = downloadFilename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + function handleToggleEdit() { + if (isEditing) { + // Sauvegarder les modifications. + setPresentationT1(text) + } + setIsEditing((v) => !v) + } + + function handleRefaire() { + setPresentationT1(null) + navigate('/simulation/eo/t1/questionnaire') + } + + function handleStartRecording() { + // S'assurer que le provider porte bien la dernière version du texte + // (au cas où l'utilisateur a édité sans cliquer sur Enregistrer). + if (text !== presentationT1) { + setPresentationT1(text) + } + setStep('recording') + navigate('/simulation/eo/enregistrement') + } + + return ( +
+
+
+

Ta présentation générée

+

+ Lis-la, modifie-la si nécessaire, puis enregistre-toi. +

+
+
+ + + +
+
+ +