From 1d9516661114e36e3a6430eca6785de183c84560 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 26 Apr 2026 20:28:35 +0300 Subject: [PATCH] =?UTF-8?q?Sprint=206c=20=E2=80=94=20Frontend=20T2=20Live?= =?UTF-8?q?=20UI=20+=20state=20machine=20+=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(t2-live): state machine pure 9 states x 8 events (resolves FTD-09) feat(t2-live): useT2LiveSession WS orchestrator + audio hooks + close codes feat(t2-live): T2SujetsPage, T2PreparationPage (2min timer + notes + ideas), T2DialoguePage (3:30 dialogue + terminal screen with WAV download) feat(t2-live): T2LiveContext provider for sujet sharing between pages fix(TaskSelector): unlock EO_T2_LIVE card via hasAccess (resolves FTD-33) chore: Tache type + labels + config extended with EO_T2_LIVE test: 21 t2-machine tests — 259/259 green (+21) --- docs/CHANGELOG.md | 28 ++ docs/TECH_DEBT.md | 29 +- src/app/router.tsx | 19 + src/entities/production/api.ts | 7 +- src/entities/production/lib.ts | 1 + src/entities/production/types.ts | 7 +- src/features/historique/lib/historique.ts | 1 + .../simulations/components/TaskSelector.tsx | 36 +- .../simulations/lib/simulationConfig.ts | 9 + .../simulations/pages/SimulationEOPage.tsx | 3 + .../t2-live/hooks/useT2LiveSession.ts | 376 ++++++++++++++++++ src/features/t2-live/pages/T2DialoguePage.tsx | 212 ++++++++++ .../t2-live/pages/T2PreparationPage.tsx | 210 ++++++++++ src/features/t2-live/pages/T2SujetsPage.tsx | 109 +++++ src/features/t2-live/state/T2LiveContext.tsx | 38 ++ .../state/__tests__/t2-machine.test.ts | 75 ++++ src/features/t2-live/state/t2-machine.ts | 102 +++++ 17 files changed, 1229 insertions(+), 33 deletions(-) create mode 100644 src/features/t2-live/hooks/useT2LiveSession.ts create mode 100644 src/features/t2-live/pages/T2DialoguePage.tsx create mode 100644 src/features/t2-live/pages/T2PreparationPage.tsx create mode 100644 src/features/t2-live/pages/T2SujetsPage.tsx create mode 100644 src/features/t2-live/state/T2LiveContext.tsx create mode 100644 src/features/t2-live/state/__tests__/t2-machine.test.ts create mode 100644 src/features/t2-live/state/t2-machine.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 246fdeb..b4480d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,6 +29,34 @@ Chaque entrée suit ce format : --- +## [Unreleased] — 2026-04-26 — Sprint 6c — Frontend T2 Live UI + +### Added + +- `t2-machine.ts` — state machine pure T2 Live : 9 états (`idle` → `preparing` → `connecting` → `ready` → `speaking` ↔ `listening` → `processing` → `ended` / `error`), 8 events. 21 tests. Résout FTD-09. +- `useT2LiveSession.ts` — hook orchestrateur : WebSocket + state machine + hooks audio (capture/playback/recording). Parse format Gemini natif (`serverContent.modelTurn`) + messages applicatifs backend (`warning`/`report`/`error`). Close codes 1000/4001/4003/4004. Timer dialogue 210 s. Ping 30 s keep-alive. +- `T2LiveContext.tsx` — Provider léger pour partager le sujet sélectionné entre les pages T2. +- `T2SujetsPage.tsx` — grille de sélection des sujets T2 (`GET /sujets?mode=EO&tache=2`). +- `T2PreparationPage.tsx` — timer 2 min, consigne affichée, zone de notes locale, bouton « Suggestions d'idées » (DeepSeek, actif immédiatement), bouton « Je suis prêt », pré-warm micro via `getUserMedia`. Transition auto vers dialogue à 0:00. +- `T2DialoguePage.tsx` — timer 3:30, indicateur d'état IA, waveform, bouton « Terminer ». Écran terminal (state `ended`) : bouton « Télécharger l'audio » (WAV mono 24 kHz) + bouton « Voir le rapport » (→ `/rapport/:id`). +- 3 routes : `/simulation/eo/t2`, `/simulation/eo/t2/preparation`, `/simulation/eo/t2/dialogue` sous `T2LiveLayout`. + +### Changed + +- `TaskSelector.tsx` — carte EO T2 Live déverrouillée via `hasAccess(plan, 'oral_t2_live')` + prop `onT2LiveSelect`. Résout FTD-33. +- `SimulationEOPage.tsx` — branche `onT2LiveSelect` vers `/simulation/eo/t2`. +- `entities/production/` — `Tache` type, labels, `mapTacheToSujetParams`, config étendus avec `EO_T2_LIVE`. +- `features/historique/` — `TACHE_NUMBER` étendu. + +### Notes + +- Tests frontend : 238 → 259 verts (+21 — tous sur t2-machine). +- FTD-09 résolue (state machine testée). +- FTD-33 résolue (carte déverrouillée via hasAccess). +- `useT2LiveSession` non testé en unit (WebSocket non supporté jsdom) — validation manuelle prévue. + +--- + ## [Unreleased] — 2026-04-26 — Sprint 6b — Frontend audio (T2 Live) ### Added diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 0cfebe2..c2ec53e 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -1,6 +1,6 @@ # TECH_DEBT.md — Expria Frontend -> **Document de référence — Version 1.27** +> **Document de référence — Version 1.28** > Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées. > À mettre à jour après chaque session de développement. > @@ -421,29 +421,7 @@ Frontend : --- -### FTD-09 — Tests de la state machine T2 Live non implémentés - -**Priorité :** 🟡 Important -**Statut :** Gelé — Sprint 5.5 (2026-04-26) -**Estimation de session :** 3h -**Description :** La state machine T2 Live (`src/features/t2-live/state/t2-machine.ts`) n'existe pas encore. Quand elle sera créée, elle devra être testée de manière exhaustive (6+ tests couvrant les transitions d'états et les cas d'erreur). - -**Motif de gel :** Gelé — le code n'existe pas encore, sera créé et testé au Sprint 6. - -**Condition de résolution :** fin Sprint 6 (T2 Live). - ---- - -### FTD-33 — Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) - -**Priorité :** 🟢 Mineur -**Statut :** Gelé — Sprint 5.5 (2026-04-26) -**Estimation de session :** 0,5 jour -**Description :** Dans `TaskSelector`, la carte EO_T2_LIVE a `tache: null` ce qui la rend inactive pour tous les plans, indépendamment de `hasAccess(plan, 'oral_t2_live')`. C'est volontaire tant que T2 Live n'est pas livré (Sprint 6) — un utilisateur Premium ne doit pas accéder à une feature non implémentée. À nettoyer dès que T2 Live est wired pour respecter strictement la Règle D. - -**Motif de gel :** Gelé — condition de résolution = Sprint 6 T2 Live. - -**Condition de résolution :** lancement de T2 Live (Sprint 6). +> FTD-09 et FTD-33 résolues au Sprint 6c (2026-04-26) — voir §5 Historique. --- @@ -534,6 +512,8 @@ Frontend : | FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. | | FTD-25 | Mise à jour ARCHITECTURE.md §3 (arborescence réelle) | 2026-04-25 | §3 réécrite : `app/` documenté avec entry points + layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage) ; ajout `entities/{patterns,presentation,transcription}` ; ajout `features/{historique,progression,design-system}` ; extension `simulations/` (pages EO, components/rapport/, lib/, state/) ; mise à jour `shared/`. `t2-live/` et `billing/` retirés (non implémentés — voir ROADMAP). Note explicative ajoutée sous `app/`. Bump doc v1.1. | | FTD-26 | Clarifier cohabitation `shared/ui/` vs `shared/components/ui/` | 2026-04-25 | Section dédiée ajoutée dans ARCHITECTURE.md §3 : tableau de distinction (PascalCase wrappers Expria vs kebab-case primitives shadcn) + règle d'évolution (toute nouvelle primitive Expria va dans `shared/ui/`, `shared/components/ui/` réservé à la CLI shadcn). Aucun fichier déplacé — documentation uniquement. | +| FTD-09 | Tests de la state machine T2 Live non implémentés | 2026-04-26 | Sprint 6c — State machine pure créée (`src/features/t2-live/state/t2-machine.ts`, 9 états × 8 events) + 21 tests Vitest couvrant transitions nominales, END_REQUESTED depuis tout état actif, ERROR terminal, événements invalides ignorés. Dégelée et fermée. | +| FTD-33 | Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) | 2026-04-26 | Sprint 6c — Carte EO_T2_LIVE déverrouillée via `hasAccess(plan, 'oral_t2_live')` + nouvelle prop `onT2LiveSelect` dans `TaskSelector`. Si plan donne accès, clic navigue vers `/simulation/eo/t2` (la production est créée par le backend en fin de session, pas au clic). Sinon, carte reste verrouillée avec lockLabel « Exclusivité Premium ». Dégelée et fermée. | | FTD-14 | Anti-FOUC thème : script inline manquant dans `` | 2026-04-26 | Sprint 5.5 — Script `.light` déjà en place dans `index.html` (lignes 14-20), conforme DESIGN_SYSTEM v2.0. L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0 (obsolète). Aucune action code requise — FTD fermée comme déjà résolue. | | FTD-35 | `PresentationGenereeT1Page` : refresh sans simulation active | 2026-04-26 | Sprint 5.5 — Subsumée par FTD-41 : la résolution de FTD-41 (persistance T1 en BDD) élimine le problème de FTD-35 (localStorage instable). Aucune action propre. | | FTD-38 | `useAudioRecorder` : mise à jour de ref pendant le render | 2026-04-26 | Sprint 5.5 — Refactor `optionsRef.current = options` (assignation pendant render + eslint-disable) en `useEffect(() => { optionsRef.current = options })`. Sémantique préservée : effet sans deps run après chaque commit, donc avant le prochain render qui lit la ref. eslint-disable retiré. 195 lignes de tests `useAudioRecorder.test.ts` toujours vertes (219/219). | @@ -574,3 +554,4 @@ Frontend : | 1.25 | 2026-04-25 | Sprint 4.5 — Ajout FTD-40 🟡 (conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible — patch frontend en place dans `ConseilNclcCallout`) et FTD-41 🔴 (persistance présentation EO T1 en BDD — résout FTD-35). **19 FTD actives — cap 15 dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.** | | 1.26 | 2026-04-26 | Sprint 5e (clean Sprint 5 Billing) — Ajout FTD-42 🟡 (modal prorata Standard→Premium avec montant exact — divergence PARCOURS_UTILISATEURS §3, actuellement Customer Portal natif sans preview in-app) et FTD-43 🟢 (race condition webhook post-redirect Stripe — `usePlan()` peut retourner ancien plan brièvement). **21 FTD actives — cap 15 dépassé de 6. Résorption FTD critique au Sprint 5.5 avant Sprint 6.** | | 1.27 | 2026-04-26 | Sprint 5.5 Clean — FTD-09, FTD-33, FTD-42 gelées. FTD-35 fermée (subsumée par FTD-41). FTD-14, FTD-38, FTD-39 résolues. **14 FTD actives** (cap 15 respecté). | +| 1.28 | 2026-04-26 | Sprint 6c — FTD-09 et FTD-33 résolues (dégelées → fermées). **14 FTD actives** (inchangé — les gelées ne comptaient pas dans le cap). | diff --git a/src/app/router.tsx b/src/app/router.tsx index 1bde52d..5fcabcb 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -20,6 +20,10 @@ import { ProgressionPage } from '@/features/progression/pages/ProgressionPage' import { PricingPage } from '@/features/billing/pages/PricingPage' import { ParametresPage } from '@/features/account/pages/ParametresPage' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' +import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext' +import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage' +import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage' +import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage' import { AppLayout } from './AppLayout' const DesignSystemPage = import.meta.env.DEV @@ -53,6 +57,14 @@ function SimulationFlowLayout() { ) } +function T2LiveLayout() { + return ( + + + + ) +} + export function AppRouter() { return ( @@ -86,6 +98,13 @@ export function AppRouter() { } /> + {/* Sprint 6c — T2 Live (Premium) : sélection sujet → prépa 2min → dialogue 3:30 */} + }> + } /> + } /> + } /> + + {/* Autres sections — Sprint 4+ */} } /> } /> diff --git a/src/entities/production/api.ts b/src/entities/production/api.ts index ceebca1..ab845cc 100644 --- a/src/entities/production/api.ts +++ b/src/entities/production/api.ts @@ -78,7 +78,10 @@ export async function updateSujet(id: string, sujetId: string): Promise { /** * Mappe une Tache vers les paramètres de la route `GET /sujets`. * Retourne `null` pour les tâches sans catalogue de sujets côté base - * (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet). + * (EO_T1 : sujet fixe connu). + * + * Sprint 6c : `EO_T2_LIVE` mappe vers (mode='EO', tache=2) pour récupérer + * la grille de sujets T2 (rôle + contexte alimentés en BDD). */ function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null { switch (tache) { @@ -88,6 +91,8 @@ function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: return { mode: 'EE', tacheNumber: 2 } case 'EE_T3': return { mode: 'EE', tacheNumber: 3 } + case 'EO_T2_LIVE': + return { mode: 'EO', tacheNumber: 2 } case 'EO_T3': return { mode: 'EO', tacheNumber: 3 } case 'EO_T1': diff --git a/src/entities/production/lib.ts b/src/entities/production/lib.ts index bf44c17..47edaba 100644 --- a/src/entities/production/lib.ts +++ b/src/entities/production/lib.ts @@ -10,6 +10,7 @@ const TACHE_LABELS: Record = { EE_T2: 'Expression Écrite — Tâche 2', EE_T3: 'Expression Écrite — Tâche 3', EO_T1: 'Expression Orale — Tâche 1', + EO_T2_LIVE: 'Expression Orale — Tâche 2 Live', EO_T3: 'Expression Orale — Tâche 3', } diff --git a/src/entities/production/types.ts b/src/entities/production/types.ts index 7946edc..0cc4806 100644 --- a/src/entities/production/types.ts +++ b/src/entities/production/types.ts @@ -11,8 +11,11 @@ * Ne jamais les injecter comme HTML — passer par react-markdown dans les composants. */ -/** Identifiants des tâches disponibles en mode simulation (hors T2 Live). */ -export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' +/** + * Identifiants des tâches disponibles en mode simulation. + * `EO_T2_LIVE` désigne la T2 EO en dialogue live (Sprint 6). + */ +export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T2_LIVE' | 'EO_T3' /** Mode de la simulation — examen uniquement accessible au plan Premium. */ export type Mode = 'entrainement' | 'examen' diff --git a/src/features/historique/lib/historique.ts b/src/features/historique/lib/historique.ts index 0230b63..ed5d5f8 100644 --- a/src/features/historique/lib/historique.ts +++ b/src/features/historique/lib/historique.ts @@ -127,6 +127,7 @@ const TACHE_NUMBER: Record = { EE_T2: 'EE · Tâche 2', EE_T3: 'EE · Tâche 3', EO_T1: 'EO · Tâche 1', + EO_T2_LIVE: 'EO · Tâche 2 Live', EO_T3: 'EO · Tâche 3', } diff --git a/src/features/simulations/components/TaskSelector.tsx b/src/features/simulations/components/TaskSelector.tsx index 2dd7248..a27db91 100644 --- a/src/features/simulations/components/TaskSelector.tsx +++ b/src/features/simulations/components/TaskSelector.tsx @@ -10,7 +10,7 @@ */ import { Lock, Loader2 } from 'lucide-react' -import { canSimulate } from '@/entities/user/lib' +import { canSimulate, hasAccess } from '@/entities/user/lib' import { cn } from '@/shared/lib/utils' import { Card } from '@/shared/ui/Card' import { Badge } from '@/shared/ui/Badge' @@ -25,6 +25,14 @@ interface Props { simulationsUsed: number isLoading: boolean onSelect: (payload: CreateSimulationPayload) => void + /** + * Sprint 6c — handler dédié pour la carte EO_T2_LIVE. Si fourni ET que + * l'utilisateur a accès (`oral_t2_live`), un clic appelle ce handler au + * lieu d'`onSelect` (la production T2 Live est créée en backend en fin + * de session, pas au clic). Si absent OU plan insuffisant, la carte + * reste verrouillée. + */ + onT2LiveSelect?: () => void } interface TaskCard { @@ -46,17 +54,25 @@ const EO_CARDS: readonly TaskCard[] = [ { key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' }, { key: 'EO_T2_LIVE', - tache: null, + tache: 'EO_T2_LIVE', label: 'Expression Orale', sublabel: 'Tâche 2 — Live', lockLabel: 'Exclusivité Premium', }, ] -export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) { +export function TaskSelector({ + type, + plan, + simulationsUsed, + isLoading, + onSelect, + onT2LiveSelect, +}: Props) { const simulationCheck = canSimulate(plan, simulationsUsed) const quotaBlocked = !simulationCheck.allowed const cards = type === 'EE' ? EE_CARDS : EO_CARDS + const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect) return (
@@ -82,13 +98,16 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
{cards.map((card) => { - const locked = card.tache === null || quotaBlocked + const isT2Live = card.tache === 'EO_T2_LIVE' + // T2 Live ne consomme pas le quota Free (plan Premium uniquement, illimité). + // Verrouillage : T2 Live → hasAccess(plan, 'oral_t2_live') ; autres → quota. + const locked = isT2Live ? !t2LiveUnlocked : card.tache === null || quotaBlocked const abbrev = card.tache ? card.tache.split('_')[0] : 'EO' if (locked) { return ( - {card.tache === null && ( + {(card.tache === null || isT2Live) && (