diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4a45bac..103309f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,7 +29,27 @@ Chaque entrée suit ce format : --- -## [Unreleased] — 2026-04-25 — Sprint 4.6 — UI EO (waveform + timeline) +## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO (frontend) + +### Added + +- `src/entities/report/__tests__/getMaxScorePerCritere.test.ts` — 7 tests (détection maxScore + mapping libellés EO). + +### Changed + +- `src/entities/report/lib.ts` — nouveau helper `getMaxScorePerCritere(rapport): 4 | 5` (détection sur criteres.length === 5). `CRITERE_NOM_TO_CODE` étendu avec les 4 libellés EO Sprint 4.8. +- `src/features/simulations/components/rapport/CritereCard.tsx` — nouvelle prop `maxScore` : affiche `X/4` (EO Sprint 4.8) ou `X/5` (EE, EO legacy). +- `src/features/simulations/pages/RapportPage.tsx` — calcul maxScore propagé aux CritereCard. +- `src/entities/report/types.ts` — commentaire Critere.score clarifié. + +### Notes + +- Rétrocompatibilité : rapports EO legacy (4 critères × /5) et EE (4 × /5) inchangés. +- Tests : 191 → 198 verts (+7). + +--- + +## [Unreleased] — 2026-04-26 — Sprint 4.6 — UI EO (waveform + timeline) ### Added diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..0a776ab --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,148 @@ +# DEPLOYMENT.md — Expria V2 +> Version 1.0 — Rédigé avant lancement +> Procédure officielle de bascule V1 → V2 sur expria.app +> À lire intégralement avant toute action de déploiement. + +--- + +## 1. Architecture cible + +| Composant | V1 (actuel) | V2 (cible) | +|---|---|---| +| Frontend | Next.js sur Render | React/Vite sur Cloudflare Pages | +| Backend | Next.js API routes sur Render | Hono.js sur Render (déjà live) | +| DNS | Vercel | Vercel (inchangé) | +| Domaine | expria.app | expria.app (inchangé) | +| Auth | Supabase | Supabase (inchangé) | +| Paiement | Stripe | Stripe (inchangé) | + +--- + +## 2. Prérequis — ne pas lancer la bascule sans cocher tout + +### Code +- [ ] Tous les tests Vitest backend passent (0 échec) +- [ ] Tous les tests Vitest frontend passent (0 échec) +- [ ] npm run typecheck frontend → 0 erreur +- [ ] Smoke test complet (Groupe Z du GOLDEN_DATASET.md) validé en local + +### Infrastructure +- [ ] Backend V2 stable sur api.expria.app depuis au moins 48h sans erreur critique Sentry +- [ ] Sentry configuré et actif sur le frontend V2 +- [ ] Variables d'environnement Cloudflare Pages configurées : + - VITE_API_URL=https://api.expria.app + - VITE_SUPABASE_URL=... + - VITE_SUPABASE_ANON_KEY=... + - VITE_ENABLE_T2_LIVE=false +- [ ] CNAME Cloudflare Pages créé et testé sur une URL de preview + +### Stripe +- [ ] Webhooks Stripe pointent vers api.expria.app (backend V2) +- [ ] Test de paiement réel effectué sur l'URL de preview Cloudflare Pages + +### Rollback DNS — valeurs de référence (ne pas supprimer) +- @ → A → 216.24.57.1 (frontend V1 Render) +- www → CNAME → expria.onrender.com (frontend V1 Render) +- api → CNAME → expria-backend.onrender.com (backend V2 — inchangé) + +--- + +## 3. Procédure de bascule — dans l'ordre exact + +### Étape 1 — Mettre V1 en mode maintenance (2 min) +Dans Render, sur le service frontend V1 : +- Modifier la variable d'environnement `MAINTENANCE_MODE=true` +- Redéployer le service V1 +- Vérifier que expria.app affiche la page de maintenance + +> ⚠️ À partir de ce moment, expria.app est inaccessible pour les utilisateurs. +> Faire cette étape à une heure creuse (nuit, week-end). + +### Étape 2 — Configurer le CNAME dans Vercel (5 min) +Dans le dashboard Vercel → Domains → expria.app : +- Supprimer ou modifier l'enregistrement A/CNAME actuel qui pointe vers Render +- Ajouter un CNAME : `expria.app` → `.pages.dev` (URL Cloudflare Pages) +- Sauvegarder + +### Étape 3 — Configurer le domaine dans Cloudflare Pages (5 min) +Dans Cloudflare Pages → projet expria-frontend → Custom domains : +- Ajouter `expria.app` +- Cloudflare Pages vérifie le CNAME automatiquement +- Attendre la validation (peut prendre 1-5 min) + +### Étape 4 — Vérifier la propagation DNS (5-15 min) +Vérifier sur https://dnschecker.org que `expria.app` pointe vers Cloudflare Pages. +Ne pas continuer avant que la propagation soit visible depuis au moins 3 régions. + +### Étape 5 — Smoke test en production (15 min) +Rejouer le Groupe Z du GOLDEN_DATASET.md sur expria.app : +- [ ] Z1 — Inscription + première simulation Free +- [ ] Z2 — Blocage quota Free +- [ ] Z3 — Simulation Standard complète +- [ ] Z4 — Mode examen bloqué en Standard +- [ ] Z5 — T2 live Premium +- [ ] Z6 — Mode examen EE complet +- [ ] Z7 — Paiement Free → Standard +- [ ] Z8 — Prorata Standard → Premium +- [ ] Z9 — Déconnexion + accès protégé +- [ ] Z10 — Responsive mobile Home + Login + +### Étape 6 — Vérifier Sentry (5 min) +- Ouvrir le dashboard Sentry projet expria-frontend +- Vérifier qu'aucune erreur critique n'apparaît dans les 5 premières minutes +- Si erreur critique → déclencher le rollback immédiatement + +### Étape 7 — Déclarer la bascule réussie +- Noter la date et l'heure dans ce fichier (section 6) +- Désactiver MAINTENANCE_MODE sur V1 (optionnel — V1 reste sur Render comme fallback 30 jours) + +--- + +## 4. Rollback — si quelque chose casse + +**Objectif : revenir sur V1 en moins de 10 minutes.** + +### Étape 1 — Désactiver V2 (2 min) +Dans Cloudflare Pages → projet expria-frontend : +- Désactiver le domaine personnalisé expria.app + +### Étape 2 — Remettre V1 en ligne (3 min) +Dans Vercel → Domains → expria.app : +- Remettre le CNAME/A record vers Render (valeur originale) +Dans Render → service frontend V1 : +- Modifier `MAINTENANCE_MODE=false` +- Redéployer + +### Étape 3 — Vérifier (5 min) +- Vérifier que expria.app affiche à nouveau V1 +- Vérifier que la connexion et une simulation fonctionnent + +### Étape 4 — Diagnostiquer +- Ouvrir Sentry V2 — identifier l'erreur critique +- Ne pas retenter la bascule avant d'avoir corrigé et rejoué le Groupe Z complet + +--- + +## 5. Post-bascule — checks 24h après + +- [ ] Sentry : aucune erreur critique nouvelle +- [ ] Stripe : webhooks reçus et traités correctement +- [ ] Supabase : aucune erreur d'auth dans les logs +- [ ] Au moins 1 simulation complète effectuée par un vrai utilisateur +- [ ] V1 sur Render toujours en ligne comme fallback (désactiver après 30 jours) + +--- + +## 6. Historique des déploiements + +| Date | Version | Résultat | Notes | +|---|---|---|---| +| — | — | — | — | + +--- + +## 7. Historique du document + +| Version | Date | Changements | +|---|---|---| +| 1.0 | 2026-04-19 | Création initiale | \ No newline at end of file diff --git a/src/entities/report/__tests__/getMaxScorePerCritere.test.ts b/src/entities/report/__tests__/getMaxScorePerCritere.test.ts new file mode 100644 index 0000000..91482b6 --- /dev/null +++ b/src/entities/report/__tests__/getMaxScorePerCritere.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { getMaxScorePerCritere, critereCodeFromNom } from '../lib' +import type { Critere } from '../types' + +function critere(nom: string, score: number): Critere { + return { nom, score, commentaire: '', exemple: '', suggestion: '', astuce: '' } +} + +describe('getMaxScorePerCritere — Sprint 4.8', () => { + it('5 critères (EO Sprint 4.8) → maxScore = 4', () => { + const rapport = { + criteres: [ + critere('Adéquation à la tâche', 3), + critere('Cohérence et cohésion', 3), + critere('Étendue et maîtrise du lexique', 2), + critere('Maîtrise morphosyntaxique', 3), + critere('Phonologie', 3), + ], + } + expect(getMaxScorePerCritere(rapport)).toBe(4) + }) + + it('4 critères (EE / EO legacy) → maxScore = 5', () => { + const rapport = { + criteres: [ + critere('Adéquation à la tâche et au registre', 4), + critere('Cohérence et cohésion du discours', 3), + critere('Compétence lexicale', 3), + critere('Compétence grammaticale', 4), + ], + } + expect(getMaxScorePerCritere(rapport)).toBe(5) + }) + + it('0 critère (cas limite) → maxScore = 5 (défaut sécurité)', () => { + expect(getMaxScorePerCritere({ criteres: [] })).toBe(5) + }) + + it('6+ critères (cas hypothétique) → maxScore = 5 (défaut sécurité)', () => { + const rapport = { + criteres: Array.from({ length: 6 }, (_, i) => critere(`c${i}`, 0)), + } + expect(getMaxScorePerCritere(rapport)).toBe(5) + }) +}) + +describe('critereCodeFromNom — extension Sprint 4.8 EO', () => { + it('mappe les libellés EO Sprint 4.8 vers les codes taxonomie', () => { + expect(critereCodeFromNom('Adéquation à la tâche')).toBe('adequation_tache') + expect(critereCodeFromNom('Cohérence et cohésion')).toBe('coherence_cohesion') + expect(critereCodeFromNom('Étendue et maîtrise du lexique')).toBe('competence_lexicale') + expect(critereCodeFromNom('Maîtrise morphosyntaxique')).toBe('competence_grammaticale') + }) + + it("Phonologie n'a pas de code taxonomie → null", () => { + expect(critereCodeFromNom('Phonologie')).toBeNull() + }) + + it('libellés EE legacy toujours mappés (rétrocompat)', () => { + expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache') + expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale') + }) +}) diff --git a/src/entities/report/lib.ts b/src/entities/report/lib.ts index 252a7a0..b629e7a 100644 --- a/src/entities/report/lib.ts +++ b/src/entities/report/lib.ts @@ -16,7 +16,7 @@ import { hasAccess } from '@/entities/user/lib' import type { Plan } from '@/entities/user/lib' -import type { BlurableSection, Critere, ErreurCode, CritereCode } from './types' +import type { BlurableSection, Critere, ErreurCode, CritereCode, Report } from './types' const SECTION_FEATURE: Record = { criteres: 'detailed_report', @@ -61,16 +61,40 @@ export function groupErreursByCritere( * pour rattacher `erreurs_codes` à la bonne carte critère côté UI. */ const CRITERE_NOM_TO_CODE: Record = { + // Libellés EE (CRITERE_LABELS backend) 'Adéquation à la tâche et au registre': 'adequation_tache', 'Cohérence et cohésion du discours': 'coherence_cohesion', 'Compétence lexicale': 'competence_lexicale', 'Compétence grammaticale': 'competence_grammaticale', + // Libellés EO Sprint 4.8 (CRITERE_LABELS_EO backend) — mappés vers les + // mêmes codes taxonomie pour rattacher les `erreurs_codes`. La 5e dimension + // « Phonologie » n'a pas de CritereCode (aucune erreur taxonomie associée + // côté backend) : `critereCodeFromNom('Phonologie')` retourne donc `null`. + 'Adéquation à la tâche': 'adequation_tache', + 'Cohérence et cohésion': 'coherence_cohesion', + 'Étendue et maîtrise du lexique': 'competence_lexicale', + 'Maîtrise morphosyntaxique': 'competence_grammaticale', } export function critereCodeFromNom(nom: string): CritereCode | null { return CRITERE_NOM_TO_CODE[nom] ?? null } +/** + * Sprint 4.8 — Détecte le score maximum par critère selon le format du rapport. + * + * - Rapports EO Sprint 4.8 : 5 critères × /4 (4 textuels DeepSeek + Phonologie Gemini). + * - Rapports EE et EO legacy : 4 critères × /5. + * + * Détection sur la donnée elle-même (pas sur la tâche) pour rester rétrocompatible + * avec les rapports EO en base d'avant Sprint 4.8. + * + * Défaut sécurité : tout autre nombre de critères → 5. + */ +export function getMaxScorePerCritere(rapport: Pick): 4 | 5 { + return rapport.criteres.length === 5 ? 4 : 5 +} + /** * Calcule l'écart en points /20 entre le score obtenu et l'objectif NCLC cible. * Barème TCF Canada (cf. Prompt_maître.md §Barème) : NCLC 9 → 14/20, NCLC 10 → 16/20. diff --git a/src/entities/report/types.ts b/src/entities/report/types.ts index 7044652..9217972 100644 --- a/src/entities/report/types.ts +++ b/src/entities/report/types.ts @@ -28,7 +28,7 @@ export interface ErreurCode { export interface Critere { nom: string - score: number // 0-5 + score: number // 0-4 (EO Sprint 4.8 — 5 critères) | 0-5 (EE, EO legacy 4 critères) commentaire: string exemple: string suggestion: string diff --git a/src/features/simulations/components/rapport/CritereCard.tsx b/src/features/simulations/components/rapport/CritereCard.tsx index 9f3baf4..7ad461f 100644 --- a/src/features/simulations/components/rapport/CritereCard.tsx +++ b/src/features/simulations/components/rapport/CritereCard.tsx @@ -19,15 +19,17 @@ import type { Critere, ErreurCode } from '@/entities/report/types' interface Props { critere: Critere erreursCodes: ErreurCode[] + /** Sprint 4.8 — échelle par critère : 4 (EO 5 critères) ou 5 (EE / EO legacy 4 critères). */ + maxScore?: 4 | 5 } -export function CritereCard({ critere, erreursCodes }: Props) { +export function CritereCard({ critere, erreursCodes, maxScore = 5 }: Props) { return (

{critere.nom}

- {critere.score}/5 + {critere.score}/{maxScore}
diff --git a/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts b/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts index bf87541..2c19961 100644 --- a/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts +++ b/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts @@ -75,10 +75,12 @@ class FakeMediaRecorder { } static isTypeSupported(_t: string): boolean { + void _t return true } start(_timeslice?: number) { + void _timeslice this.state = 'recording' } diff --git a/src/features/simulations/hooks/useDeepgramLive.ts b/src/features/simulations/hooks/useDeepgramLive.ts index 52006ca..6bcb75f 100644 --- a/src/features/simulations/hooks/useDeepgramLive.ts +++ b/src/features/simulations/hooks/useDeepgramLive.ts @@ -149,7 +149,6 @@ export function useDeepgramLive(): UseDeepgramLiveResult { // 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 () => { diff --git a/src/features/simulations/pages/RapportPage.tsx b/src/features/simulations/pages/RapportPage.tsx index f4174e3..2eff19b 100644 --- a/src/features/simulations/pages/RapportPage.tsx +++ b/src/features/simulations/pages/RapportPage.tsx @@ -20,7 +20,12 @@ import { useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Lock } from 'lucide-react' import { usePlan } from '@/features/dashboard/hooks/usePlan' -import { isSectionVisible, groupErreursByCritere, critereCodeFromNom } from '@/entities/report/lib' +import { + isSectionVisible, + groupErreursByCritere, + critereCodeFromNom, + getMaxScorePerCritere, +} from '@/entities/report/lib' import type { Report } from '@/entities/report/types' import { useRapport } from '../hooks/useRapport' import { useSimulation } from '../hooks/useSimulation' @@ -93,6 +98,7 @@ function RapportSkeleton() { function CriteresSection({ rapport }: { rapport: Report }) { const grouped = groupErreursByCritere(rapport.erreurs_codes) + const maxScore = getMaxScorePerCritere(rapport) return (
@@ -101,7 +107,7 @@ function CriteresSection({ rapport }: { rapport: Report }) { {rapport.criteres.map((c) => { const code = critereCodeFromNom(c.nom) const codes = code ? grouped[code] : [] - return + return })}
diff --git a/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx b/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx index 9bf6562..dc820ea 100644 --- a/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx +++ b/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx @@ -7,7 +7,6 @@ * - 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' diff --git a/src/index.css b/src/index.css index dcd98fb..d1b1948 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,5 @@ -@import 'tailwindcss'; @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); +@import 'tailwindcss'; /* Dark = défaut. `.light` sur active le mode clair (override sur --color-*). */ @custom-variant light (&:where(.light, .light *));