feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
71c1ad3018
commit
d1c8b548bb
34 changed files with 3255 additions and 70 deletions
|
|
@ -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 `<head>`
|
||||
|
||||
**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
|
||||
<script>
|
||||
(function(){var t=localStorage.getItem('expria-theme');
|
||||
if(t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches))
|
||||
document.documentElement.classList.add('dark')})()
|
||||
;(function () {
|
||||
var t = localStorage.getItem('expria-theme')
|
||||
if (t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme:dark)').matches))
|
||||
document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
```
|
||||
|
||||
|
|
@ -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<any>` 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 /> : <Providers />`. `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<any>` 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 /> : <Providers />`. `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.** |
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route element={<SimulationFlowLayout />}>
|
||||
<Route path="/simulation/ee" element={<SimulationPage />} />
|
||||
<Route path="/sujets" element={<SujetsPage />} />
|
||||
{/* Sprint 4c-1 — flow EO */}
|
||||
<Route path="/simulation/eo" element={<SimulationEOPage />} />
|
||||
<Route path="/simulation/eo/sujets" element={<SujetsEOPage />} />
|
||||
<Route path="/simulation/eo/pre-enregistrement" element={<PreEnregistrementEOPage />} />
|
||||
<Route path="/simulation/eo/enregistrement" element={<EnregistrementEOPage />} />
|
||||
{/* Sprint 4c-2 — flux T1 (questionnaire + génération + présentation) */}
|
||||
<Route path="/simulation/eo/t1/mode" element={<ModeChoixT1Page />} />
|
||||
<Route path="/simulation/eo/t1/questionnaire" element={<QuestionnaireT1Page />} />
|
||||
<Route path="/simulation/eo/t1/presentation" element={<PresentationGenereeT1Page />} />
|
||||
<Route path="/rapport/:id" element={<RapportPage />} />
|
||||
</Route>
|
||||
<Route path="/simulation/eo" element={<ComingSoon />} />
|
||||
|
||||
{/* Autres sections — Sprint 4+ */}
|
||||
<Route path="/examen" element={<ComingSoon />} />
|
||||
|
|
|
|||
59
src/entities/presentation/__tests__/api.test.ts
Normal file
59
src/entities/presentation/__tests__/api.test.ts
Normal file
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
23
src/entities/presentation/api.ts
Normal file
23
src/entities/presentation/api.ts
Normal file
|
|
@ -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<PresentationGenerated> {
|
||||
return apiFetch<PresentationGenerated>('/presentations/generate', {
|
||||
method: 'POST',
|
||||
body: { reponses },
|
||||
timeoutMs: GENERATE_TIMEOUT_MS,
|
||||
retry: { max: 0, baseDelayMs: 0 },
|
||||
})
|
||||
}
|
||||
23
src/entities/presentation/types.ts
Normal file
23
src/entities/presentation/types.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -48,7 +48,14 @@ export function getReport(id: string): Promise<Report> {
|
|||
// 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<Report> {
|
|||
return apiFetch<Report>('/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<Report> {
|
|||
return apiFetch<Report>('/corrections/eo', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
timeoutMs: CORRECTION_TIMEOUT_MS,
|
||||
timeoutMs: CORRECTION_EO_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
49
src/entities/transcription/__tests__/api.test.ts
Normal file
49
src/entities/transcription/__tests__/api.test.ts
Normal file
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
21
src/entities/transcription/api.ts
Normal file
21
src/entities/transcription/api.ts
Normal file
|
|
@ -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<TranscriptionToken> {
|
||||
return apiFetch<TranscriptionToken>('/transcriptions/token', {
|
||||
method: 'POST',
|
||||
timeoutMs: TOKEN_TIMEOUT_MS,
|
||||
retry: { max: 0, baseDelayMs: 0 },
|
||||
})
|
||||
}
|
||||
15
src/entities/transcription/types.ts
Normal file
15
src/entities/transcription/types.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
187
src/features/simulations/components/AudioRecorder.tsx
Normal file
187
src/features/simulations/components/AudioRecorder.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<MicOff className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{recorder.error ?? 'Erreur audio.'}</p>
|
||||
{recorder.permissionDenied && (
|
||||
<p className="mt-1 text-xs">
|
||||
Vérifiez que le site a l'autorisation d'utiliser le micro dans les réglages du
|
||||
navigateur, puis réessayez.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => void recorder.start()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
isRecording
|
||||
? 'inline-block size-3 animate-pulse rounded-pill bg-danger'
|
||||
: 'inline-block size-3 rounded-pill bg-ink-tertiary/40'
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium text-ink-primary">
|
||||
{recorder.status === 'requesting' && 'Autorisation du micro…'}
|
||||
{isRecording && 'Enregistrement actif'}
|
||||
{isStopped && 'Enregistrement terminé'}
|
||||
{recorder.status === 'idle' && 'Prêt'}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto font-mono text-xl tabular-nums text-ink-primary"
|
||||
aria-live="polite"
|
||||
>
|
||||
{formatTimer(recorder.elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isRecording && remaining > 0 && (
|
||||
<p className="mt-3 text-xs text-ink-secondary">
|
||||
Minimum 30 secondes requis ({remaining} s restantes).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{isRecording && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Square className="size-4" aria-hidden="true" />}
|
||||
onClick={handleSubmitClick}
|
||||
disabled={!submitEnabled || disabled}
|
||||
>
|
||||
{submitEnabled ? 'Arrêter et soumettre' : `Arrêter et soumettre (${remaining}s)`}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={disabled}>
|
||||
Annuler
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recorder.status === 'idle' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={() => void recorder.start()}
|
||||
>
|
||||
Démarrer l'enregistrement
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download className="size-4" aria-hidden="true" />}
|
||||
onClick={() => recorder.downloadAudio(`${downloadFilename}.webm`)}
|
||||
>
|
||||
Télécharger l'audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
src/features/simulations/components/TranscriptionDisplay.tsx
Normal file
60
src/features/simulations/components/TranscriptionDisplay.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin text-brand-text" aria-hidden="true" />
|
||||
<span className="text-sm font-medium text-ink-primary">Transcription en cours…</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink-secondary">Transcription en attente</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs tabular-nums text-ink-secondary">
|
||||
{wordCount} mot{wordCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto rounded-md bg-canvas p-3 text-sm leading-relaxed text-ink-primary"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{isEmpty ? (
|
||||
<span className="italic text-ink-tertiary">En attente du premier mot…</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{transcript}</span>
|
||||
{interim && <span className="ml-1 italic text-ink-secondary">{interim}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(<TranscriptionDisplay transcript="" isConnected={false} />)
|
||||
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(<TranscriptionDisplay transcript="" isConnected={true} />)
|
||||
expect(screen.getByText(/Transcription en cours/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("compte les mots du transcript final (ignore l'interim)", () => {
|
||||
render(
|
||||
<TranscriptionDisplay
|
||||
transcript="Bonjour je m appelle Pierre"
|
||||
interim="et je"
|
||||
isConnected={true}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/^5 mots$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rend transcript final + interim concaténés', () => {
|
||||
const { container } = render(
|
||||
<TranscriptionDisplay transcript="Bonjour" interim="je continue" isConnected={true} />,
|
||||
)
|
||||
expect(container.textContent).toContain('Bonjour')
|
||||
expect(container.textContent).toContain('je continue')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
216
src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
Normal file
216
src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
close: ReturnType<typeof vi.fn>
|
||||
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<FakeWS> {
|
||||
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<string, Array<() => 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
271
src/features/simulations/hooks/useAudioRecorder.ts
Normal file
271
src/features/simulations/hooks/useAudioRecorder.ts
Normal file
|
|
@ -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<void>
|
||||
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<RecorderStatus>('idle')
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null)
|
||||
const [audioMimeType, setAudioMimeType] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [permissionDenied, setPermissionDenied] = useState(false)
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const subscribersRef = useRef<Set<(chunk: Blob) => 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,
|
||||
}
|
||||
}
|
||||
226
src/features/simulations/hooks/useDeepgramLive.ts
Normal file
226
src/features/simulations/hooks/useDeepgramLive.ts
Normal file
|
|
@ -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<void>
|
||||
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<DeepgramStatus>('idle')
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [interim, setInterim] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const rotationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingChunksRef = useRef<Blob[]>([])
|
||||
|
||||
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<WebSocket> => {
|
||||
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', '<jwt>'] 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<Tache, SimulationConfig> = {
|
||||
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 {
|
||||
|
|
|
|||
171
src/features/simulations/pages/EnregistrementEOPage.tsx
Normal file
171
src/features/simulations/pages/EnregistrementEOPage.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Page /simulation/eo/enregistrement — Sprint 4c-1, refondue Sprint 4c-3.
|
||||
*
|
||||
* Capture audio via `<AudioRecorder>` (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 — `<AudioRecorder>` 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<string | null>(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 (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<section
|
||||
className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
|
||||
aria-label="Texte de présentation de référence"
|
||||
>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
|
||||
Ta présentation (référence)
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
|
||||
{presentationT1}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{production.tache !== 'EO_T1' && sujet && (
|
||||
<div className="mb-6">
|
||||
<SujetDisplay sujet={sujet} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AudioRecorder
|
||||
minSeconds={minSeconds}
|
||||
maxSeconds={dureeRecommandee || undefined}
|
||||
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
autoStart
|
||||
disabled={lockControls}
|
||||
/>
|
||||
|
||||
{lockControls && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
|
||||
>
|
||||
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-medium">Transcription et correction en cours…</p>
|
||||
<p className="mt-0.5 text-xs">
|
||||
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
|
||||
automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{encodingError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{encodingError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{correctError && !lockControls && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
La correction a échoué. Réessayez dans quelques instants.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
78
src/features/simulations/pages/ModeChoixT1Page.tsx
Normal file
78
src/features/simulations/pages/ModeChoixT1Page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset()
|
||||
navigate('/simulation/eo')
|
||||
}}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 — Présentation personnelle</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
onClick={() => navigate('/simulation/eo/t1/questionnaire')}
|
||||
>
|
||||
<Sparkles className="size-6 text-brand-text" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Réponds à 5 questions — Expria génère ton texte personnalisé que tu lis avant
|
||||
d'enregistrer.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
onClick={() => navigate('/simulation/eo/pre-enregistrement')}
|
||||
>
|
||||
<Mic className="size-6 text-brand-text" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Tu as déjà préparé ta présentation — enregistre-toi directement sans passer par le
|
||||
formulaire.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
105
src/features/simulations/pages/PreEnregistrementEOPage.tsx
Normal file
105
src/features/simulations/pages/PreEnregistrementEOPage.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{sujet && !isT1 && (
|
||||
<div className="mb-6">
|
||||
<SujetDisplay sujet={sujet} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
|
||||
<p className="font-medium text-ink-primary">Avant de commencer</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.</li>
|
||||
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
|
||||
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
|
||||
{isT1 && (
|
||||
<li>
|
||||
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
|
||||
projet d'immigration au Canada.
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Vous pourrez télécharger votre enregistrement à la fin — il n'est pas conservé sur nos
|
||||
serveurs.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={handleStart}
|
||||
>
|
||||
Démarrer l'enregistrement
|
||||
</Button>
|
||||
{isT3 && (
|
||||
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
|
||||
Changer de sujet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
198
src/features/simulations/pages/PresentationGenereeT1Page.tsx
Normal file
198
src/features/simulations/pages/PresentationGenereeT1Page.tsx
Normal file
|
|
@ -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<string>(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 (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
|
||||
<p className="mt-1 text-sm text-ink-secondary">
|
||||
Lis-la, modifie-la si nécessaire, puis enregistre-toi.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={
|
||||
copied ? (
|
||||
<Check className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? 'Copié' : 'Copier'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download className="size-4" aria-hidden="true" />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
.txt
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={
|
||||
isEditing ? (
|
||||
<Save className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleToggleEdit}
|
||||
>
|
||||
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={!isEditing}
|
||||
rows={12}
|
||||
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
|
||||
/>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
|
||||
>
|
||||
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois avant
|
||||
d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Présentation sauvegardée — retrouvée automatiquement à ta prochaine visite.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefaire}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
|
||||
>
|
||||
<RotateCcw className="size-3.5" aria-hidden="true" />
|
||||
Refaire
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={handleStartRecording}
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
Je suis prêt — Enregistrer
|
||||
</Button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
243
src/features/simulations/pages/QuestionnaireT1Page.tsx
Normal file
243
src/features/simulations/pages/QuestionnaireT1Page.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* Page /simulation/eo/t1/questionnaire — Sprint 4c-2.
|
||||
*
|
||||
* Formulaire des 5 réponses utilisées pour générer la présentation T1
|
||||
* via DeepSeek (POST /presentations/generate). State 100 % local (pas de
|
||||
* provider) : les réponses ne survivent pas au refresh, c'est volontaire
|
||||
* — c'est la *présentation générée* qui est persistée (cf. provider).
|
||||
*
|
||||
* SEC-04 : validation Zod côté client (chaque champ trim non vide, max 500 chars).
|
||||
* Garde-fou : tâche EO_T1 obligatoire, sinon retour TaskSelector EO.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
import { generatePresentation } from '@/entities/presentation/api'
|
||||
import type { PresentationReponses } from '@/entities/presentation/types'
|
||||
import { useSimulationFlow } from '../state/simulationFlow'
|
||||
|
||||
const FIELD_MAX = 500
|
||||
|
||||
const reponsesSchema = z.object({
|
||||
prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||
formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||
situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||
loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||
motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
|
||||
})
|
||||
|
||||
type FieldKey = keyof PresentationReponses
|
||||
|
||||
interface FieldDef {
|
||||
key: FieldKey
|
||||
label: string
|
||||
placeholder: string
|
||||
multiline?: boolean
|
||||
}
|
||||
|
||||
const FIELDS: FieldDef[] = [
|
||||
{
|
||||
key: 'prenom_age_ville',
|
||||
label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
|
||||
placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
|
||||
},
|
||||
{
|
||||
key: 'formation_metier',
|
||||
label: 'Quelle est ta formation et ton métier actuel ou passé ?',
|
||||
placeholder: 'Ex : Master en gestion, comptable dans une PME',
|
||||
},
|
||||
{
|
||||
key: 'situation_familiale',
|
||||
label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
|
||||
placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
|
||||
},
|
||||
{
|
||||
key: 'loisirs',
|
||||
label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
|
||||
placeholder: 'Ex : Lecture, cuisine, randonnée',
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: 'motivation_canada',
|
||||
label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
|
||||
placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
|
||||
multiline: true,
|
||||
},
|
||||
]
|
||||
|
||||
const EMPTY_REPONSES: PresentationReponses = {
|
||||
prenom_age_ville: '',
|
||||
formation_metier: '',
|
||||
situation_familiale: '',
|
||||
loisirs: '',
|
||||
motivation_canada: '',
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus'
|
||||
|
||||
function mapApiError(err: ApiError | null): string | null {
|
||||
if (!err) return null
|
||||
switch (err.code) {
|
||||
case 'AUTH_REQUIRED':
|
||||
return 'Votre session a expiré. Reconnectez-vous.'
|
||||
case 'VALIDATION_ERROR':
|
||||
case 'INVALID_BODY':
|
||||
return 'Les réponses saisies sont invalides. Vérifiez chaque champ.'
|
||||
default:
|
||||
return 'La génération a échoué. Réessayez dans quelques instants.'
|
||||
}
|
||||
}
|
||||
|
||||
export function QuestionnaireT1Page() {
|
||||
const navigate = useNavigate()
|
||||
const { production, step, setPresentationT1 } = useSimulationFlow()
|
||||
|
||||
const shouldRedirect =
|
||||
!production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done'
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
|
||||
}, [shouldRedirect, navigate])
|
||||
|
||||
const [reponses, setReponses] = useState<PresentationReponses>(EMPTY_REPONSES)
|
||||
const [touched, setTouched] = useState<Record<FieldKey, boolean>>({
|
||||
prenom_age_ville: false,
|
||||
formation_metier: false,
|
||||
situation_familiale: false,
|
||||
loisirs: false,
|
||||
motivation_canada: false,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: generatePresentation,
|
||||
onSuccess: (data) => {
|
||||
setPresentationT1(data.presentation)
|
||||
navigate('/simulation/eo/t1/presentation')
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = reponsesSchema.safeParse(reponses)
|
||||
const fieldErrors = !parsed.success
|
||||
? parsed.error.issues.reduce<Partial<Record<FieldKey, string>>>((acc, issue) => {
|
||||
const key = issue.path[0] as FieldKey | undefined
|
||||
if (key && !acc[key]) acc[key] = issue.message
|
||||
return acc
|
||||
}, {})
|
||||
: {}
|
||||
|
||||
const formValid = parsed.success
|
||||
const apiErrorMessage = mapApiError(mutation.error as ApiError | null)
|
||||
|
||||
function handleChange(key: FieldKey, value: string) {
|
||||
setReponses((r) => ({ ...r, [key]: value.slice(0, FIELD_MAX) }))
|
||||
}
|
||||
|
||||
function handleBlur(key: FieldKey) {
|
||||
setTouched((t) => ({ ...t, [key]: true }))
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setTouched({
|
||||
prenom_age_ville: true,
|
||||
formation_metier: true,
|
||||
situation_familiale: true,
|
||||
loisirs: true,
|
||||
motivation_canada: true,
|
||||
})
|
||||
if (!parsed.success) return
|
||||
mutation.mutate(parsed.data)
|
||||
}
|
||||
|
||||
if (shouldRedirect) return null
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/simulation/eo/t1/mode')}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 — Questionnaire</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">
|
||||
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
|
||||
automatiquement.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||
{FIELDS.map((field) => {
|
||||
const value = reponses[field.key]
|
||||
const showError = touched[field.key] && fieldErrors[field.key]
|
||||
const id = `q-${field.key}`
|
||||
return (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onBlur={() => handleBlur(field.key)}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={FIELD_MAX}
|
||||
rows={2}
|
||||
className={inputBase}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onBlur={() => handleBlur(field.key)}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={FIELD_MAX}
|
||||
className={inputBase}
|
||||
/>
|
||||
)}
|
||||
{showError && (
|
||||
<p className="text-xs text-danger" role="alert">
|
||||
{fieldErrors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{apiErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Sparkles className="size-4" aria-hidden="true" />}
|
||||
loading={mutation.isPending}
|
||||
disabled={!formValid || mutation.isPending}
|
||||
>
|
||||
Générer ma présentation
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
77
src/features/simulations/pages/SimulationEOPage.tsx
Normal file
77
src/features/simulations/pages/SimulationEOPage.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Page de simulation Expression Orale — Sprint 4c-1.
|
||||
*
|
||||
* Affiche le TaskSelector type='EO' (T1 / T3 / T2 Live verrouillé) +
|
||||
* un bandeau d'info quand l'utilisateur clique sur une tâche temporairement
|
||||
* indisponible (EO_T1 livré en 4c-2).
|
||||
*
|
||||
* Règle D : quotas et permissions passent par canSimulate / hasAccess.
|
||||
* Règle H : aucune logique métier — délègue au provider.
|
||||
*/
|
||||
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { TaskSelector } from '../components/TaskSelector'
|
||||
import { useSimulationFlow } from '../state/simulationFlow'
|
||||
|
||||
function SimulationEOSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true" aria-label="Chargement…">
|
||||
<div className="h-6 w-40 animate-pulse rounded bg-surface" />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-lg bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimulationEOPage() {
|
||||
const {
|
||||
data: planData,
|
||||
isLoading: isPlanLoading,
|
||||
isError: isPlanError,
|
||||
refetch: refetchPlan,
|
||||
} = usePlan()
|
||||
|
||||
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationEOSkeleton />}
|
||||
|
||||
{isPlanError && (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planData && (
|
||||
<div className="space-y-4">
|
||||
<TaskSelector
|
||||
type="EO"
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
/>
|
||||
|
||||
{taskUnavailableMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
|
||||
>
|
||||
{taskUnavailableMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
126
src/features/simulations/pages/SujetsEOPage.tsx
Normal file
126
src/features/simulations/pages/SujetsEOPage.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Page /simulation/eo/sujets — sélection d'un sujet EO_T3 en cartes.
|
||||
*
|
||||
* Clone fonctionnel de SujetsPage (EE) : même grille, même bouton aléatoire,
|
||||
* mêmes redirections de garde — adapté pour rester dans le flow EO et
|
||||
* naviguer vers /simulation/eo/pre-enregistrement après sélection.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shuffle } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { formatTache } from '@/entities/production/lib'
|
||||
import type { SujetData } from '@/entities/production/types'
|
||||
import { useSimulationFlow } from '../state/simulationFlow'
|
||||
import { useSujets } from '../hooks/useSujets'
|
||||
import { SujetCard } from '../components/SujetCard'
|
||||
|
||||
function SujetsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-lg bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SujetsEOPage() {
|
||||
const navigate = useNavigate()
|
||||
const { step, production, changeSubject, setStep, reset } = useSimulationFlow()
|
||||
|
||||
// Garde-fous identiques à SujetsPage : refresh direct ou état incohérent
|
||||
// → retour au TaskSelector EO. step='done' = simulation déjà corrigée,
|
||||
// /sujets ne doit pas patcher (cf. FTD-23).
|
||||
const shouldRedirect = !production || step === 'idle' || step === 'done'
|
||||
useEffect(() => {
|
||||
if (shouldRedirect) navigate('/simulation/eo', { replace: true })
|
||||
}, [shouldRedirect, navigate])
|
||||
|
||||
const {
|
||||
data: sujets,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useSujets(production?.tache ?? 'EO_T3', !!production && !shouldRedirect)
|
||||
|
||||
if (shouldRedirect || !production) return null
|
||||
|
||||
function handleSelect(sujet: SujetData) {
|
||||
changeSubject(sujet)
|
||||
setStep('task-selected')
|
||||
navigate('/simulation/eo/pre-enregistrement')
|
||||
}
|
||||
|
||||
function handleRandom() {
|
||||
if (!sujets || sujets.length === 0) return
|
||||
const pool = production?.sujet ? sujets.filter((s) => s.id !== production.sujet?.id) : sujets
|
||||
const list = pool.length > 0 ? pool : sujets
|
||||
const pick = list[Math.floor(Math.random() * list.length)]
|
||||
if (pick) handleSelect(pick)
|
||||
}
|
||||
|
||||
const hasSujets = (sujets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset()
|
||||
navigate('/simulation/eo')
|
||||
}}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
|
||||
Choisir un sujet — {formatTache(production.tache)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{isLoading
|
||||
? 'Chargement des sujets…'
|
||||
: hasSujets
|
||||
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
|
||||
: 'Aucun sujet disponible pour cette tâche.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Shuffle className="size-4" aria-hidden="true" />}
|
||||
onClick={handleRandom}
|
||||
disabled={!hasSujets}
|
||||
>
|
||||
Sujet aléatoire
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
Impossible de charger les sujets.{' '}
|
||||
<button type="button" onClick={() => refetch()} className="underline underline-offset-2">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<SujetsSkeleton />
|
||||
) : hasSujets ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sujets!.map((sujet) => (
|
||||
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Tests — QuestionnaireT1Page (Sprint 4c-2).
|
||||
*
|
||||
* Couvre :
|
||||
* - bouton désactivé tant que des champs sont vides, actif quand tous remplis
|
||||
* - submit appelle generatePresentation avec le bon payload
|
||||
* - onSuccess : setPresentationT1 + navigate vers /simulation/eo/t1/presentation
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
const { navigateMock, generatePresentationMock, setPresentationT1Mock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
generatePresentationMock: vi.fn(),
|
||||
setPresentationT1Mock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return { ...actual, useNavigate: () => navigateMock }
|
||||
})
|
||||
|
||||
vi.mock('@/entities/presentation/api', () => ({
|
||||
generatePresentation: generatePresentationMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../state/simulationFlow', () => ({
|
||||
useSimulationFlow: () => ({
|
||||
production: {
|
||||
id: 'sim-eo-1',
|
||||
tache: 'EO_T1',
|
||||
mode: 'entrainement',
|
||||
created_at: '2026-04-25',
|
||||
sujet: null,
|
||||
},
|
||||
step: 'task-selected',
|
||||
setPresentationT1: setPresentationT1Mock,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { QuestionnaireT1Page } from '../QuestionnaireT1Page'
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QuestionnaireT1Page />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
{ label: /prénom, âge/i, value: 'Marie, 32 ans, Douala' },
|
||||
{ label: /formation et ton métier/i, value: 'Master gestion, comptable' },
|
||||
{ label: /situation familiale/i, value: 'Mariée, 2 enfants' },
|
||||
{ label: /loisirs ou passions/i, value: 'Lecture, cuisine' },
|
||||
{ label: /immigrer au Canada/i, value: 'Opportunités, départ 2025' },
|
||||
] as const
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
navigateMock.mockReset()
|
||||
generatePresentationMock.mockReset()
|
||||
setPresentationT1Mock.mockReset()
|
||||
})
|
||||
|
||||
describe('QuestionnaireT1Page', () => {
|
||||
it('bouton désactivé tant que les champs sont vides', () => {
|
||||
renderPage()
|
||||
const submit = screen.getByRole('button', { name: /Générer ma présentation/i })
|
||||
expect(submit).toBeDisabled()
|
||||
})
|
||||
|
||||
it('bouton actif quand les 5 champs sont remplis', async () => {
|
||||
renderPage()
|
||||
const user = userEvent.setup()
|
||||
|
||||
for (const field of FIELDS) {
|
||||
await user.type(screen.getByLabelText(field.label), field.value)
|
||||
}
|
||||
|
||||
const submit = screen.getByRole('button', { name: /Générer ma présentation/i })
|
||||
expect(submit).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('submit appelle generatePresentation, setPresentationT1, puis navigate', async () => {
|
||||
generatePresentationMock.mockResolvedValueOnce({
|
||||
presentation: 'Bonjour, je m appelle Marie. Voilà.',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
const user = userEvent.setup()
|
||||
|
||||
for (const field of FIELDS) {
|
||||
await user.type(screen.getByLabelText(field.label), field.value)
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Générer ma présentation/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generatePresentationMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(generatePresentationMock.mock.calls[0]?.[0]).toEqual({
|
||||
prenom_age_ville: 'Marie, 32 ans, Douala',
|
||||
formation_metier: 'Master gestion, comptable',
|
||||
situation_familiale: 'Mariée, 2 enfants',
|
||||
loisirs: 'Lecture, cuisine',
|
||||
motivation_canada: 'Opportunités, départ 2025',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setPresentationT1Mock).toHaveBeenCalledWith('Bonjour, je m appelle Marie. Voilà.')
|
||||
})
|
||||
expect(navigateMock).toHaveBeenCalledWith('/simulation/eo/t1/presentation')
|
||||
})
|
||||
})
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
getSimulationState,
|
||||
updateSujet as updateSujetApi,
|
||||
} from '@/entities/production/api'
|
||||
import { correctEe } from '@/entities/report/api'
|
||||
import { correctEe, correctEo } from '@/entities/report/api'
|
||||
import type { CreateSimulationPayload, Production, Tache } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
|
@ -25,10 +25,29 @@ import { SimulationFlowContext, type FlowValue, type SimulationStep } from './si
|
|||
|
||||
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
||||
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
||||
const LS_EO_T1_PRESENTATION_KEY = 'expria_eo_t1_presentation'
|
||||
|
||||
function isEoTache(tache: Tache): boolean {
|
||||
return tache.startsWith('EO_')
|
||||
}
|
||||
|
||||
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||
const [step, setStep] = useState<SimulationStep>('idle')
|
||||
const [production, setProduction] = useState<Production | null>(null)
|
||||
const [taskUnavailableMessage, setTaskUnavailableMessage] = useState<string | null>(null)
|
||||
// Sprint 4c-2 — état initialisé depuis localStorage pour survivre au refresh
|
||||
// tout au long du flux T1 (questionnaire → présentation → enregistrement).
|
||||
const [presentationT1, setPresentationT1State] = useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
|
||||
})
|
||||
|
||||
function setPresentationT1(text: string | null): void {
|
||||
setPresentationT1State(text)
|
||||
if (typeof window === 'undefined') return
|
||||
if (text === null) window.localStorage.removeItem(LS_EO_T1_PRESENTATION_KEY)
|
||||
else window.localStorage.setItem(LS_EO_T1_PRESENTATION_KEY, text)
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hydratedRef = useRef(false)
|
||||
|
|
@ -59,8 +78,25 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
sujet_id: state.sujet?.id,
|
||||
})
|
||||
setStep('task-selected')
|
||||
if (!location.pathname.startsWith('/simulation/ee')) {
|
||||
navigate('/simulation/ee')
|
||||
// Sprint 4c-2 — restauration EO :
|
||||
// - EO_T1 + présentation déjà générée → /t1/presentation
|
||||
// - EO_T1 sans présentation → /t1/mode (choix mode)
|
||||
// - EO_T3 → /pre-enregistrement
|
||||
// - EE → /simulation/ee
|
||||
let targetBase: string
|
||||
if (state.tache === 'EO_T1') {
|
||||
const stored =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
|
||||
: null
|
||||
targetBase = stored ? '/simulation/eo/t1/presentation' : '/simulation/eo/t1/mode'
|
||||
} else if (isEoTache(state.tache)) {
|
||||
targetBase = '/simulation/eo/pre-enregistrement'
|
||||
} else {
|
||||
targetBase = '/simulation/ee'
|
||||
}
|
||||
if (!location.pathname.startsWith(targetBase)) {
|
||||
navigate(targetBase)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -75,11 +111,15 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
setProduction(data)
|
||||
const hasCatalogue = !TACHES_SANS_CATALOGUE.includes(data.tache)
|
||||
setStep(hasCatalogue ? 'choosing-subject' : 'task-selected')
|
||||
// Navigation initiale vers /sujets pour les tâches avec catalogue —
|
||||
// gérée ici (et non dans un useEffect sticky côté SimulationPage) pour
|
||||
// éviter la boucle infinie quand l'utilisateur revient depuis /sujets.
|
||||
if (hasCatalogue) {
|
||||
navigate('/sujets')
|
||||
// Sprint 4c-2 — routage post-création :
|
||||
// - EE_T1/T2/T3 (avec catalogue) → /sujets (legacy)
|
||||
// - EO_T3 (avec catalogue) → /simulation/eo/sujets
|
||||
// - EO_T1 (sans catalogue) → /simulation/eo/t1/mode (choix génération
|
||||
// vs enregistrement direct).
|
||||
if (data.tache === 'EO_T1') {
|
||||
navigate('/simulation/eo/t1/mode')
|
||||
} else if (hasCatalogue) {
|
||||
navigate(isEoTache(data.tache) ? '/simulation/eo/sujets' : '/sujets')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -98,7 +138,24 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
onError: () => setStep('task-selected'),
|
||||
})
|
||||
|
||||
// Sprint 4c-1 — mutation EO. Sépare le pipeline pour éviter de devoir
|
||||
// discriminer dynamiquement le payload (EE vs EO) côté mutationFn.
|
||||
const correctEoMutation = useMutation({
|
||||
mutationFn: correctEo,
|
||||
onMutate: () => setStep('correcting'),
|
||||
onSuccess: (_data, variables) => {
|
||||
setStep('done')
|
||||
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
||||
navigate(`/rapport/${variables.simulationId}`)
|
||||
},
|
||||
onError: () => setStep('recording'),
|
||||
})
|
||||
|
||||
function selectTask(payload: CreateSimulationPayload): void {
|
||||
// Sprint 4c-2 — l'interception EO_T1 introduite en 4c-1 est levée :
|
||||
// le flux T1 est désormais wired (cf. createMutation.onSuccess).
|
||||
// `taskUnavailableMessage` reste exposé pour de futurs cas (ex. T2 Live).
|
||||
setTaskUnavailableMessage(null)
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +169,21 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
})
|
||||
}
|
||||
|
||||
// Sprint 4c-3 — bascule transcription live → audio batch backend.
|
||||
// Le frontend envoie l'audio brut en base64 + mimeType ; le backend appelle
|
||||
// Gemini batch pour la transcription puis poursuit le pipeline correction
|
||||
// (cf. POST /corrections/eo en mode audio).
|
||||
function submitEoAudio(audioBase64: string, mimeType: string, nclcCible: 9 | 10 = 9): void {
|
||||
if (!production) return
|
||||
correctEoMutation.mutate({
|
||||
simulationId: production.id,
|
||||
tache: production.tache,
|
||||
audioBase64,
|
||||
mimeType,
|
||||
nclc_cible: nclcCible,
|
||||
})
|
||||
}
|
||||
|
||||
function changeSubject(sujet: SujetData): void {
|
||||
// FTD-21 — persiste le changement côté backend (best-effort : l'UI ne bloque pas).
|
||||
if (production) {
|
||||
|
|
@ -125,22 +197,29 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
|||
function reset(): void {
|
||||
setStep('idle')
|
||||
setProduction(null)
|
||||
setTaskUnavailableMessage(null)
|
||||
setPresentationT1(null)
|
||||
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
||||
createMutation.reset()
|
||||
correctMutation.reset()
|
||||
correctEoMutation.reset()
|
||||
}
|
||||
|
||||
const value: FlowValue = {
|
||||
step,
|
||||
production,
|
||||
sujet: production?.sujet ?? null,
|
||||
report: (correctMutation.data ?? null) as Report | null,
|
||||
report: (correctMutation.data ?? correctEoMutation.data ?? null) as Report | null,
|
||||
isCreating: createMutation.isPending,
|
||||
isCorrecting: correctMutation.isPending,
|
||||
isCorrecting: correctMutation.isPending || correctEoMutation.isPending,
|
||||
createError: createMutation.error as ApiError | null,
|
||||
correctError: correctMutation.error as ApiError | null,
|
||||
correctError: (correctMutation.error ?? correctEoMutation.error) as ApiError | null,
|
||||
taskUnavailableMessage,
|
||||
presentationT1,
|
||||
setPresentationT1,
|
||||
selectTask,
|
||||
submitText,
|
||||
submitEoAudio,
|
||||
changeSubject,
|
||||
setStep,
|
||||
reset,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Tests du flow EO ajoutés en Sprint 4c-1.
|
||||
*
|
||||
* Couvre :
|
||||
* - selectTask EO_T1 → message inline, pas de création
|
||||
* - selectTask EO_T3 → création + step='choosing-subject' (navigation testée
|
||||
* via le mock de useNavigate)
|
||||
* - submitEoAudio appelle correctEo avec audioBase64 + mimeType (Sprint 4c-3)
|
||||
* - non-régression EE : selectTask EE_T1 fonctionne toujours
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/entities/production/api')
|
||||
vi.mock('@/entities/report/api')
|
||||
|
||||
import { createSimulation, getSimulationState } from '@/entities/production/api'
|
||||
import { correctEo } from '@/entities/report/api'
|
||||
import { SimulationFlowProvider } from '../SimulationFlowProvider'
|
||||
import { useSimulationFlow } from '../simulationFlow'
|
||||
import type { Production } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
|
||||
const mockCreate = vi.mocked(createSimulation)
|
||||
const mockCorrectEo = vi.mocked(correctEo)
|
||||
const mockGetState = vi.mocked(getSimulationState)
|
||||
|
||||
const eoT3Production: Production = {
|
||||
id: 'sim-eo-1',
|
||||
tache: 'EO_T3',
|
||||
mode: 'entrainement',
|
||||
created_at: '2026-04-25T00:00:00Z',
|
||||
sujet: null,
|
||||
}
|
||||
|
||||
const mockEoReport: Report = {
|
||||
simulation_id: 'sim-eo-1',
|
||||
score: 14,
|
||||
nclc: 9,
|
||||
nclc_cible: 9,
|
||||
revelation: { croyance: '', realite: '', consequence: '' },
|
||||
diagnostic: '',
|
||||
criteres: [],
|
||||
conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
|
||||
erreurs_codes: [],
|
||||
exercices: null,
|
||||
exercices_status: 'pending',
|
||||
modele: null,
|
||||
modele_status: 'pending',
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(SimulationFlowProvider, null, children),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockGetState.mockRejectedValue(new Error('no resume'))
|
||||
})
|
||||
|
||||
describe('SimulationFlowProvider EO — Sprint 4c-1', () => {
|
||||
it('Sprint 4c-2 — EO_T1 crée la simulation (interception 4c-1 levée)', async () => {
|
||||
const eoT1: Production = { ...eoT3Production, tache: 'EO_T1' }
|
||||
mockCreate.mockResolvedValue(eoT1)
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.taskUnavailableMessage).toBeNull()
|
||||
})
|
||||
|
||||
it('EO_T3 : selectTask crée la simulation et passe en choosing-subject', async () => {
|
||||
mockCreate.mockResolvedValue(eoT3Production)
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||
expect(result.current.production).toEqual(eoT3Production)
|
||||
expect(mockCreate.mock.calls[0]?.[0]).toEqual({ tache: 'EO_T3', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
it('submitEoAudio appelle correctEo avec audioBase64 + mimeType', async () => {
|
||||
mockCreate.mockResolvedValue(eoT3Production)
|
||||
mockCorrectEo.mockResolvedValue(mockEoReport)
|
||||
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
|
||||
})
|
||||
await waitFor(() => expect(result.current.production).toEqual(eoT3Production))
|
||||
|
||||
act(() => {
|
||||
result.current.submitEoAudio('AAAAAA==', 'audio/webm', 9)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCorrectEo).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockCorrectEo.mock.calls[0]?.[0]).toEqual({
|
||||
simulationId: 'sim-eo-1',
|
||||
tache: 'EO_T3',
|
||||
audioBase64: 'AAAAAA==',
|
||||
mimeType: 'audio/webm',
|
||||
nclc_cible: 9,
|
||||
})
|
||||
})
|
||||
|
||||
it('non-régression EE : selectTask EE_T1 reste fonctionnel', async () => {
|
||||
const eeProduction: Production = { ...eoT3Production, id: 'sim-ee', tache: 'EE_T1' }
|
||||
mockCreate.mockResolvedValue(eeProduction)
|
||||
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||
expect(result.current.production).toEqual(eeProduction)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Tests du flow T1 — Sprint 4c-2.
|
||||
*
|
||||
* Couvre :
|
||||
* - setPresentationT1 expose la valeur via le hook + persiste en localStorage
|
||||
* - reset() remet presentationT1 à null + nettoie localStorage
|
||||
* - hydratation au mount lit la valeur depuis localStorage
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/entities/production/api')
|
||||
vi.mock('@/entities/report/api')
|
||||
|
||||
import { getSimulationState } from '@/entities/production/api'
|
||||
import { SimulationFlowProvider } from '../SimulationFlowProvider'
|
||||
import { useSimulationFlow } from '../simulationFlow'
|
||||
|
||||
const mockGetState = vi.mocked(getSimulationState)
|
||||
|
||||
const LS_KEY = 'expria_eo_t1_presentation'
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(SimulationFlowProvider, null, children),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mockGetState.mockRejectedValue(new Error('no resume'))
|
||||
})
|
||||
|
||||
describe('SimulationFlowProvider T1 — Sprint 4c-2', () => {
|
||||
it('setPresentationT1 expose la valeur et la persiste en localStorage', () => {
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.presentationT1).toBeNull()
|
||||
|
||||
act(() => {
|
||||
result.current.setPresentationT1('Bonjour je m appelle Marie...')
|
||||
})
|
||||
|
||||
expect(result.current.presentationT1).toBe('Bonjour je m appelle Marie...')
|
||||
expect(localStorage.getItem(LS_KEY)).toBe('Bonjour je m appelle Marie...')
|
||||
})
|
||||
|
||||
it('setPresentationT1(null) supprime la clé localStorage', () => {
|
||||
localStorage.setItem(LS_KEY, 'old')
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.setPresentationT1(null)
|
||||
})
|
||||
|
||||
expect(result.current.presentationT1).toBeNull()
|
||||
expect(localStorage.getItem(LS_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('hydrate presentationT1 depuis localStorage au mount', () => {
|
||||
localStorage.setItem(LS_KEY, 'présentation persistée')
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.presentationT1).toBe('présentation persistée')
|
||||
})
|
||||
|
||||
it('reset() remet presentationT1 à null et nettoie localStorage', () => {
|
||||
const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.setPresentationT1('texte')
|
||||
})
|
||||
expect(result.current.presentationT1).toBe('texte')
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.presentationT1).toBeNull()
|
||||
expect(localStorage.getItem(LS_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -11,7 +11,13 @@ import type { CreateSimulationPayload, Production, SujetData } from '@/entities/
|
|||
import type { Report } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
||||
export type SimulationStep = 'idle' | 'choosing-subject' | 'task-selected' | 'correcting' | 'done'
|
||||
export type SimulationStep =
|
||||
| 'idle'
|
||||
| 'choosing-subject'
|
||||
| 'task-selected'
|
||||
| 'recording'
|
||||
| 'correcting'
|
||||
| 'done'
|
||||
|
||||
export interface FlowValue {
|
||||
step: SimulationStep
|
||||
|
|
@ -22,8 +28,32 @@ export interface FlowValue {
|
|||
isCorrecting: boolean
|
||||
createError: ApiError | null
|
||||
correctError: ApiError | null
|
||||
/**
|
||||
* Sprint 4c-1 — message d'info non bloquant remonté par `selectTask` quand
|
||||
* l'utilisateur clique sur une tâche temporairement indisponible (EO_T1
|
||||
* dans 4c-1). La tâche n'est pas créée et l'UI affiche le message.
|
||||
* Réinitialisé à null à chaque nouvelle action.
|
||||
*/
|
||||
taskUnavailableMessage: string | null
|
||||
/**
|
||||
* Sprint 4c-2 — texte de présentation T1 généré par DeepSeek (ou édité
|
||||
* manuellement par l'utilisateur). Utilisé comme texte de référence
|
||||
* affiché pendant l'enregistrement EO_T1. Mirroré aussi dans
|
||||
* `localStorage.expria_eo_t1_presentation` pour survivre aux refresh.
|
||||
* `null` quand aucune présentation n'a encore été générée pour la session
|
||||
* en cours, ou quand l'utilisateur a choisi le mode « enregistrer
|
||||
* directement » (sans questionnaire).
|
||||
*/
|
||||
presentationT1: string | null
|
||||
setPresentationT1: (text: string | null) => void
|
||||
selectTask: (payload: CreateSimulationPayload) => void
|
||||
submitText: (texte: string, nclcCible?: 9 | 10) => void
|
||||
/**
|
||||
* Sprint 4c-1 (transcript live Deepgram) → 4c-3 (audio batch Gemini backend) :
|
||||
* envoie l'audio brut en base64 au backend qui transcrit puis corrige. Le
|
||||
* paramètre `mimeType` indique le format produit par MediaRecorder.
|
||||
*/
|
||||
submitEoAudio: (audioBase64: string, mimeType: string, nclcCible?: 9 | 10) => void
|
||||
changeSubject: (sujet: SujetData) => void
|
||||
setStep: (step: SimulationStep) => void
|
||||
reset: () => void
|
||||
|
|
|
|||
54
src/shared/lib/__tests__/audio.test.ts
Normal file
54
src/shared/lib/__tests__/audio.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Tests de `blobToBase64` — Sprint 4c-3.
|
||||
*
|
||||
* jsdom fournit FileReader. On vérifie :
|
||||
* - encodage correct (base64 sans préfixe data URI)
|
||||
* - rejet propre si le reader émet onerror
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { blobToBase64 } from '../audio'
|
||||
|
||||
describe('blobToBase64', () => {
|
||||
it('encode un Blob en base64 sans le préfixe data URI', async () => {
|
||||
const blob = new Blob(['hello'], { type: 'audio/webm' })
|
||||
const base64 = await blobToBase64(blob)
|
||||
// 'hello' en base64 = 'aGVsbG8='
|
||||
expect(base64).toBe('aGVsbG8=')
|
||||
})
|
||||
|
||||
it('reject si FileReader émet une erreur', async () => {
|
||||
class FailingFileReader {
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
result: unknown = null
|
||||
readAsDataURL() {
|
||||
// Simule une erreur asynchrone.
|
||||
setTimeout(() => this.onerror?.(), 0)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', FailingFileReader)
|
||||
|
||||
await expect(blobToBase64(new Blob(['x']))).rejects.toThrow(
|
||||
/FileReader: lecture du Blob audio impossible/,
|
||||
)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("reject si le résultat n'est pas une data URI bien formée", async () => {
|
||||
class WeirdFileReader {
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
result: string = 'pas-une-data-uri'
|
||||
readAsDataURL() {
|
||||
setTimeout(() => this.onload?.(), 0)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', WeirdFileReader)
|
||||
|
||||
await expect(blobToBase64(new Blob(['x']))).rejects.toThrow(/format data URI/)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
36
src/shared/lib/audio.ts
Normal file
36
src/shared/lib/audio.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Helpers audio partagés — Sprint 4c-3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit un Blob en chaîne base64 (sans le préfixe `data:<mime>;base64,`).
|
||||
*
|
||||
* Utilise FileReader.readAsDataURL puis strip le préfixe avant retour. Le
|
||||
* payload audio EO est ensuite envoyé tel quel dans le body JSON de
|
||||
* `POST /corrections/eo` (cf. SimulationFlowProvider.submitEoAudio).
|
||||
*
|
||||
* Reject si le reader émet une erreur ou si le résultat n'est pas une chaîne
|
||||
* data URI bien formée.
|
||||
*/
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => {
|
||||
reject(new Error('FileReader: lecture du Blob audio impossible.'))
|
||||
}
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result !== 'string') {
|
||||
reject(new Error('FileReader: résultat inattendu (non-string).'))
|
||||
return
|
||||
}
|
||||
const commaIdx = result.indexOf(',')
|
||||
if (commaIdx < 0 || !result.startsWith('data:')) {
|
||||
reject(new Error('FileReader: résultat non conforme au format data URI.'))
|
||||
return
|
||||
}
|
||||
resolve(result.slice(commaIdx + 1))
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue