609 lines
18 KiB
Markdown
609 lines
18 KiB
Markdown
# TESTS_AUTOMATISES.md — Expria Frontend
|
|
|
|
> **Document de référence — Version 1.0**
|
|
> Ce document contient les tests Vitest automatisés à implémenter dans le frontend.
|
|
> Ces tests s'exécutent en quelques secondes et détectent les régressions invisibles que le Golden Dataset ne peut pas attraper par des tests manuels.
|
|
>
|
|
> Calqué sur `TESTS_AUTOMATISES.md` du backend pour garantir la parité et appliquer le même standard de rigueur.
|
|
|
|
---
|
|
|
|
## 1. Principe
|
|
|
|
**Tests ciblés, pas exhaustifs.** On teste ce qui vérifie un droit, modifie des données, calcule un résultat, ou orchestre un flux critique. On ne teste pas les composants d'affichage (trop fragiles, couverts par le Golden Dataset manuel).
|
|
|
|
### Fonctions critiques à tester
|
|
|
|
| Fonction | Rôle | Conséquence si cassée |
|
|
|---|---|---|
|
|
| `checkFeatureAccess` / `hasAccess` | Vérifie l'accès à une feature selon le plan | Feature Premium accessible gratuitement |
|
|
| `canUserSimulate` / `canSimulate` | Vérifie quota simulations Free | Blocage quota cassé = coût explosif DeepSeek |
|
|
| `getPlanPermissions` | Retourne les permissions d'un plan | Mauvaises features affichées |
|
|
| `apiFetch` | Wrapper HTTP avec retry, timeout, parsing erreur | Tous les appels API peuvent échouer silencieusement |
|
|
| `parseApiError` | Parse les erreurs backend vers `ApiError` typée | Mauvaise gestion des codes erreur côté UI |
|
|
| `applyReportFloutage` | Filtre le rapport selon le plan (Free = flouté) | Rapport Premium accessible en Free (fuite de valeur) |
|
|
| `t2LiveReducer` | State machine T2 Live | Transitions d'états cassées = bugs audio/WebSocket |
|
|
| `usePlan` (hook) | Cache du plan utilisateur + refetch après upgrade | Plan stale après paiement = UX brisée |
|
|
|
|
---
|
|
|
|
## 2. Installation
|
|
|
|
### Dépendances à ajouter
|
|
|
|
```bash
|
|
npm install --save-dev vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
|
|
```
|
|
|
|
### Configuration `vitest.config.ts` à la racine
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vitest/config'
|
|
import react from '@vitejs/plugin-react'
|
|
import path from 'node:path'
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
test: {
|
|
environment: 'jsdom',
|
|
globals: true,
|
|
setupFiles: ['./src/test-setup.ts'],
|
|
coverage: {
|
|
reporter: ['text', 'html'],
|
|
include: [
|
|
'src/entities/**',
|
|
'src/shared/lib/**',
|
|
'src/features/*/hooks/**',
|
|
'src/features/*/state/**',
|
|
],
|
|
exclude: ['**/__tests__/**', '**/*.test.ts', '**/*.test.tsx'],
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
### Fichier `src/test-setup.ts`
|
|
|
|
```typescript
|
|
import '@testing-library/jest-dom/vitest'
|
|
import { cleanup } from '@testing-library/react'
|
|
import { afterEach } from 'vitest'
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
})
|
|
```
|
|
|
|
### Scripts dans `package.json`
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"test": "vitest run",
|
|
"test:watch": "vitest",
|
|
"test:coverage": "vitest run --coverage"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Tests déjà produits (session documentation — avant scaffold)
|
|
|
|
### `src/entities/user/__tests__/access.test.ts` — 26 assertions
|
|
|
|
Teste la parité avec le backend sur les 3 fonctions de `access.ts` :
|
|
- `canUserSimulate` : 7 tests (free dans limites, free au quota, standard, premium, plan inconnu)
|
|
- `getPlanPermissions` : 4 tests (les 3 plans + plan inconnu lève une erreur)
|
|
- `checkFeatureAccess` : 14 tests (1 basic_report + 4 blocages free + 5 standard + 4 premium)
|
|
- `PLANS` structure : 1 test (parité des clés entre plans)
|
|
|
|
### `src/entities/user/__tests__/lib.test.ts` — 11 assertions
|
|
|
|
Teste les alias `hasAccess` et `canSimulate` :
|
|
- `hasAccess` : 3 tests (comportement identique à `checkFeatureAccess` pour chaque plan)
|
|
- `canSimulate` : 7 tests (parité + signature ergonomique)
|
|
- `getPlanPermissions` réexporté : 1 test
|
|
|
|
**Total déjà produit : 37 tests.**
|
|
|
|
---
|
|
|
|
## 4. Tests à implémenter par sprint
|
|
|
|
### Sprint 1 — Auth + API layer
|
|
|
|
#### `src/shared/lib/__tests__/api-client.test.ts`
|
|
Tester `apiFetch<T>` dans toutes ses variantes.
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { apiFetch } from '../api-client'
|
|
|
|
// Mock de fetch global
|
|
global.fetch = vi.fn()
|
|
|
|
describe('apiFetch', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
})
|
|
|
|
describe('succès', () => {
|
|
it('retourne le payload JSON pour une réponse 2xx', async () => {
|
|
const mockResponse = { plan: 'premium', simulations_used: 0 }
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => mockResponse,
|
|
} as Response)
|
|
|
|
const result = await apiFetch<typeof mockResponse>('/plans/status')
|
|
expect(result).toEqual(mockResponse)
|
|
})
|
|
|
|
it('ajoute automatiquement le header Authorization si un token est fourni', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
} as Response)
|
|
|
|
await apiFetch('/plans/status', {}, 'jwt-token-xyz')
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Authorization': 'Bearer jwt-token-xyz',
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('ajoute X-API-Version à toutes les requêtes', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
} as Response)
|
|
|
|
await apiFetch('/plans/status')
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'X-API-Version': '1.0',
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('erreurs backend', () => {
|
|
it('throw une ApiError sur 401 AUTH_REQUIRED', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: async () => ({
|
|
error: true,
|
|
code: 'AUTH_REQUIRED',
|
|
message: 'Authentification requise.',
|
|
}),
|
|
} as Response)
|
|
|
|
await expect(apiFetch('/plans/status')).rejects.toMatchObject({
|
|
code: 'AUTH_REQUIRED',
|
|
})
|
|
})
|
|
|
|
it('throw une ApiError sur 403 QUOTA_REACHED', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 403,
|
|
json: async () => ({
|
|
error: true,
|
|
code: 'QUOTA_REACHED',
|
|
message: 'Quota atteint.',
|
|
status: 403, // quirk backend (voir ARCHITECTURE.md §5)
|
|
}),
|
|
} as Response)
|
|
|
|
await expect(apiFetch('/simulations', { method: 'POST' })).rejects.toMatchObject({
|
|
code: 'QUOTA_REACHED',
|
|
})
|
|
})
|
|
|
|
it('throw NETWORK_ERROR si fetch lui-même échoue', async () => {
|
|
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
|
|
await expect(apiFetch('/plans/status')).rejects.toMatchObject({
|
|
code: 'NETWORK_ERROR',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('retry et timeout', () => {
|
|
it('retry 3 fois sur 500 INTERNAL_ERROR', async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: true, code: 'INTERNAL_ERROR', message: '...' }) } as Response)
|
|
.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: true, code: 'INTERNAL_ERROR', message: '...' }) } as Response)
|
|
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ plan: 'free' }) } as Response)
|
|
|
|
const result = await apiFetch('/plans/status')
|
|
expect(result).toEqual({ plan: 'free' })
|
|
expect(fetch).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('ne retry PAS sur 400 VALIDATION_ERROR', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
json: async () => ({ error: true, code: 'VALIDATION_ERROR', message: '...' }),
|
|
} as Response)
|
|
|
|
await expect(apiFetch('/simulations', { method: 'POST' })).rejects.toMatchObject({
|
|
code: 'VALIDATION_ERROR',
|
|
})
|
|
expect(fetch).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('applique le timeout configuré', async () => {
|
|
vi.useFakeTimers()
|
|
vi.mocked(fetch).mockImplementationOnce(
|
|
() => new Promise((resolve) => setTimeout(resolve, 10_000))
|
|
)
|
|
|
|
const promise = apiFetch('/plans/status', { timeoutMs: 1000 })
|
|
vi.advanceTimersByTime(1500)
|
|
|
|
await expect(promise).rejects.toMatchObject({ code: 'TIMEOUT' })
|
|
vi.useRealTimers()
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
**Nombre minimum de tests : 12.**
|
|
|
|
#### `src/shared/lib/__tests__/auth-client.test.ts`
|
|
|
|
Teste la gestion du token Supabase (avec mock de `supabase.auth`).
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
|
|
vi.mock('../supabase', () => ({
|
|
supabase: {
|
|
auth: {
|
|
getSession: vi.fn(),
|
|
signInWithPassword: vi.fn(),
|
|
signOut: vi.fn(),
|
|
},
|
|
},
|
|
}))
|
|
|
|
import { getAccessToken, signIn, signOut } from '../auth-client'
|
|
import { supabase } from '../supabase'
|
|
|
|
describe('getAccessToken', () => {
|
|
it('retourne le token si session valide', async () => {
|
|
vi.mocked(supabase.auth.getSession).mockResolvedValueOnce({
|
|
data: { session: { access_token: 'token-xyz' } },
|
|
error: null,
|
|
} as any)
|
|
|
|
const token = await getAccessToken()
|
|
expect(token).toBe('token-xyz')
|
|
})
|
|
|
|
it('retourne null si pas de session', async () => {
|
|
vi.mocked(supabase.auth.getSession).mockResolvedValueOnce({
|
|
data: { session: null },
|
|
error: null,
|
|
} as any)
|
|
|
|
const token = await getAccessToken()
|
|
expect(token).toBeNull()
|
|
})
|
|
})
|
|
|
|
// + tests signIn, signOut
|
|
```
|
|
|
|
**Nombre minimum de tests : 6.**
|
|
|
|
---
|
|
|
|
### Sprint 2 — Dashboard conditionnel
|
|
|
|
#### `src/features/dashboard/hooks/__tests__/usePlan.test.tsx`
|
|
|
|
Teste le hook `usePlan` avec TanStack Query (cache, refetch, invalidation).
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { usePlan } from '../usePlan'
|
|
|
|
vi.mock('@/entities/user/api', () => ({
|
|
getPlanStatus: vi.fn(),
|
|
}))
|
|
|
|
import { getPlanStatus } from '@/entities/user/api'
|
|
|
|
describe('usePlan', () => {
|
|
it('retourne les données de /plans/status en cas de succès', async () => {
|
|
vi.mocked(getPlanStatus).mockResolvedValueOnce({
|
|
plan: 'standard',
|
|
permissions: { /* ... */ },
|
|
simulations_used: 5,
|
|
simulations_remaining: null,
|
|
plan_expires_at: null,
|
|
})
|
|
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
const wrapper = ({ children }: any) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
)
|
|
|
|
const { result } = renderHook(() => usePlan(), { wrapper })
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
expect(result.current.data?.plan).toBe('standard')
|
|
})
|
|
|
|
// + tests : cache reuse, error handling, invalidation after upgrade
|
|
})
|
|
```
|
|
|
|
**Nombre minimum de tests : 3.**
|
|
|
|
---
|
|
|
|
### Sprint 3 — Simulations EE + affichage rapport
|
|
|
|
#### `src/entities/report/__tests__/floutage.test.ts`
|
|
|
|
Teste la logique de floutage du rapport selon le plan.
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
import { applyReportFloutage } from '../lib'
|
|
|
|
const fullReport = {
|
|
score: 15,
|
|
nclc: 8,
|
|
feedback_court: 'Bon travail',
|
|
criteres: [
|
|
{ nom: 'Cohérence', score: 4, explication: '...' },
|
|
{ nom: 'Lexique', score: 3, explication: '...' },
|
|
],
|
|
erreurs: [{ type: 'grammaire', explication: '...' }],
|
|
production_modele: 'Voici un modèle complet...',
|
|
suggestions_idees: ['Idée 1', 'Idée 2', 'Idée 3'],
|
|
exercices: [{ titre: 'Exo 1', contenu: '...' }],
|
|
}
|
|
|
|
describe('applyReportFloutage', () => {
|
|
describe('Plan FREE', () => {
|
|
it('garde score et NCLC visibles', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.score).toBe(15)
|
|
expect(floute.nclc).toBe(8)
|
|
})
|
|
|
|
it('garde feedback_court visible', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.feedback_court).toBe('Bon travail')
|
|
})
|
|
|
|
it('floute les critères détaillés', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.criteres).toBeNull()
|
|
})
|
|
|
|
it('floute les erreurs détaillées', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.erreurs).toBeNull()
|
|
})
|
|
|
|
it('tronque la production modèle à 1 phrase', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.production_modele?.split('.').length).toBeLessThanOrEqual(2)
|
|
})
|
|
|
|
it('tronque les suggestions à 1 idée', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.suggestions_idees?.length).toBeLessThanOrEqual(1)
|
|
})
|
|
|
|
it('masque les exercices', () => {
|
|
const floute = applyReportFloutage(fullReport, 'free')
|
|
expect(floute.exercices).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Plan STANDARD et PREMIUM', () => {
|
|
it('retourne le rapport complet pour standard', () => {
|
|
const result = applyReportFloutage(fullReport, 'standard')
|
|
expect(result).toEqual(fullReport)
|
|
})
|
|
|
|
it('retourne le rapport complet pour premium', () => {
|
|
const result = applyReportFloutage(fullReport, 'premium')
|
|
expect(result).toEqual(fullReport)
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
**Nombre minimum de tests : 9.**
|
|
|
|
---
|
|
|
|
### Sprint 2.5 — Spike T2 Live
|
|
|
|
#### `src/features/t2-live/state/__tests__/t2-machine.test.ts`
|
|
|
|
Teste la state machine (transitions d'états).
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
import { t2LiveReducer, initialT2State } from '../t2-machine'
|
|
|
|
describe('t2LiveReducer', () => {
|
|
it('transition idle → connecting sur CONNECT', () => {
|
|
const next = t2LiveReducer(initialT2State, { type: 'CONNECT' })
|
|
expect(next.status).toBe('connecting')
|
|
})
|
|
|
|
it('transition connecting → listening sur CONNECTED', () => {
|
|
const state = { ...initialT2State, status: 'connecting' as const }
|
|
const next = t2LiveReducer(state, { type: 'CONNECTED' })
|
|
expect(next.status).toBe('listening')
|
|
})
|
|
|
|
it('transition * → error sur ERROR avec close code 4001', () => {
|
|
const state = { ...initialT2State, status: 'listening' as const }
|
|
const next = t2LiveReducer(state, { type: 'ERROR', code: 4001, message: 'Auth' })
|
|
expect(next.status).toBe('error')
|
|
expect(next.errorCode).toBe(4001)
|
|
})
|
|
|
|
it('transition listening → speaking sur USER_SPEAKING', () => {
|
|
const state = { ...initialT2State, status: 'listening' as const }
|
|
const next = t2LiveReducer(state, { type: 'USER_SPEAKING' })
|
|
expect(next.status).toBe('speaking')
|
|
})
|
|
|
|
it('transition speaking → listening sur USER_SILENT', () => {
|
|
const state = { ...initialT2State, status: 'speaking' as const }
|
|
const next = t2LiveReducer(state, { type: 'USER_SILENT' })
|
|
expect(next.status).toBe('listening')
|
|
})
|
|
|
|
it('transition * → ended sur END', () => {
|
|
const state = { ...initialT2State, status: 'listening' as const }
|
|
const next = t2LiveReducer(state, { type: 'END' })
|
|
expect(next.status).toBe('ended')
|
|
})
|
|
|
|
it('ignore les actions invalides selon l\'état', () => {
|
|
const state = { ...initialT2State, status: 'ended' as const }
|
|
const next = t2LiveReducer(state, { type: 'USER_SPEAKING' })
|
|
expect(next.status).toBe('ended') // pas de changement
|
|
})
|
|
})
|
|
```
|
|
|
|
**Nombre minimum de tests : 7.**
|
|
|
|
---
|
|
|
|
### Sprint 5 — Billing
|
|
|
|
#### `src/features/billing/hooks/__tests__/useStripeCheckout.test.ts`
|
|
|
|
Teste le flux de checkout Stripe (mock de l'API backend + redirection).
|
|
|
|
**Nombre minimum de tests : 5.**
|
|
|
|
---
|
|
|
|
## 5. Objectif de couverture
|
|
|
|
| Sprint | Tests cumulés | Fonctions critiques couvertes |
|
|
|---|---|---|
|
|
| Pré-scaffold | 37 | access.ts + lib.ts |
|
|
| Sprint 1 | 55 (+18) | + api-client + auth-client |
|
|
| Sprint 2 | 58 (+3) | + usePlan |
|
|
| Sprint 2.5 | 65 (+7) | + t2-machine |
|
|
| Sprint 3 | 74 (+9) | + floutage rapport |
|
|
| Sprint 5 | 79 (+5) | + useStripeCheckout |
|
|
|
|
**Cible MVP : ≥ 80 tests Vitest verts**, couvrant toutes les fonctions critiques identifiées au §1.
|
|
|
|
Pour comparaison, le backend a 124 tests (son périmètre de fonctions critiques est plus large — Stripe, Supabase, DeepSeek, Gemini). 80 tests frontend sur un périmètre plus restreint est un objectif proportionnellement équivalent.
|
|
|
|
---
|
|
|
|
## 6. Lancer les tests
|
|
|
|
```bash
|
|
# Dans D:\expria-frontend\
|
|
|
|
# Tous les tests une fois
|
|
npm run test
|
|
|
|
# Mode watch (relance automatiquement à chaque modification)
|
|
npm run test:watch
|
|
|
|
# Avec rapport de couverture
|
|
npm run test:coverage
|
|
```
|
|
|
|
**Résultat attendu après Sprint 3 (exemple) :**
|
|
```
|
|
✓ src/entities/user/__tests__/access.test.ts (26 tests)
|
|
✓ src/entities/user/__tests__/lib.test.ts (11 tests)
|
|
✓ src/shared/lib/__tests__/api-client.test.ts (12 tests)
|
|
✓ src/shared/lib/__tests__/auth-client.test.ts (6 tests)
|
|
✓ src/features/dashboard/hooks/__tests__/usePlan.test.tsx (3 tests)
|
|
✓ src/features/t2-live/state/__tests__/t2-machine.test.ts (7 tests)
|
|
✓ src/entities/report/__tests__/floutage.test.ts (9 tests)
|
|
|
|
Test Files 7 passed (7)
|
|
Tests 74 passed (74)
|
|
Duration ~2s
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Règle d'utilisation avec Claude Code
|
|
|
|
**Avant chaque session Claude Code :**
|
|
```bash
|
|
npm run test
|
|
# Tous les tests doivent être verts avant de commencer
|
|
```
|
|
|
|
**Après chaque session Claude Code :**
|
|
```bash
|
|
npm run typecheck && npm run test
|
|
# Typecheck OK + tous les tests verts = ok pour commit
|
|
# Typecheck KO ou test rouge = régression, ne pas continuer
|
|
```
|
|
|
|
**Dans le prompt à Claude Code, toujours inclure :**
|
|
> "Après chaque modification, lance `npm run typecheck` puis `npm run test`. Si un test échoue, corrige la régression avant de passer à l'étape suivante. Ne me montre le résultat final que quand tous les tests sont verts."
|
|
|
|
---
|
|
|
|
## 8. Quand ajouter de nouveaux tests
|
|
|
|
Ajouter un test automatisé chaque fois que Claude Code crée une nouvelle fonction qui :
|
|
- Vérifie un droit d'accès ou une permission
|
|
- Modifie des données (état local, mutation API)
|
|
- Calcule un montant, un score, un pourcentage
|
|
- Parse une réponse d'API externe
|
|
- Orchestre un flux métier complexe (state machine, workflow)
|
|
|
|
**Ne pas tester :**
|
|
- Les composants purement visuels (Button, Badge, Modal wrappers) — trop fragiles
|
|
- Les wrappers évidents (ex : `formatDate` qui wrap `Intl.DateTimeFormat`)
|
|
- Les appels Supabase SDK (c'est leur responsabilité)
|
|
- Les intégrations TanStack Query au niveau composant (couvert par `usePlan` au niveau hook)
|
|
|
|
---
|
|
|
|
## 9. Historique
|
|
|
|
| Version | Date | Changements |
|
|
|---|---|---|
|
|
| 1.0 | 2026-04-17 | Création initiale, plan de tests par sprint avec objectif 80 tests MVP |
|