diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f26ae67..0352038 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,6 +29,18 @@ Chaque entrée suit ce format : --- +## [Unreleased] — 2026-04-23 — Clean FTD-23 + FTD-24 + +### Fixed +- **FTD-23 résolu** : `useAutosave` ne fire plus après correction — `enabled` propagé avec `step !== 'done' && step !== 'correcting'` depuis `SimulationForm`. 2 tests de régression ajoutés. +- **FTD-24 résolu** : polling automatique 3s dans `useRapport` quand `exercices_status` ou `modele_status === 'pending'`. Arrêt auto dès ready/error. Timeout 2 min avec message + bouton Réessayer dans `JobStatusFallback`. 5 tests ajoutés. + +### Notes +- Tests frontend : 122/122 verts (+7 vs baseline 115). +- TECH_DEBT.md → v1.19. 10 FTD actives (cap 15). + +--- + ## [Unreleased] — 2026-04-23 — FTD-28 — Semgrep CI + CI verte ### Added diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index b42acc8..2e50230 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.18** +> **Document de référence — Version 1.19** > 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. > @@ -120,7 +120,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s ### FTD-24 — Pas de polling automatique pour exercices / modèle `pending` **Priorité :** 🟡 Important -**Statut :** Ouvert — accepté au Sprint 3.6b, refresh manuel requis entre-temps +**Statut :** Résolu — 2026-04-23 **Estimation de session :** 2h **Description :** Après soumission d'une correction EE, le backend génère la correction en bloquant (jusqu'à 45 s), puis retourne 200 dès que la correction est prête. Les jobs `modele` et `exercices` (fire-and-forget côté backend) peuvent mettre 10-30 s supplémentaires après la réponse HTTP. Pendant ce temps, `exercices_status` et `modele_status` valent `'pending'` côté `GET /simulations/:id`. Côté frontend, `RapportPage` affiche un `JobStatusFallback` invitant l'utilisateur à **rafraîchir manuellement** la page pour voir les résultats. @@ -140,7 +140,7 @@ 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 :** Ouvert — pré-existant au Sprint 3.6a, détecté lors des tests manuels 3.6a +**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). @@ -364,6 +364,8 @@ Frontend : | 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`. | --- @@ -391,3 +393,4 @@ Frontend : | 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). | diff --git a/src/features/simulations/components/SimulationForm.tsx b/src/features/simulations/components/SimulationForm.tsx index 26b3f5d..3536868 100644 --- a/src/features/simulations/components/SimulationForm.tsx +++ b/src/features/simulations/components/SimulationForm.tsx @@ -99,7 +99,8 @@ export function SimulationForm({ const timer = useTimer(config.dureeMinutes, !isSubmitting) const idees = useIdees() - const autosave = useAutosave(simulationId, texte, !isSubmitting) + const autosaveEnabled = !isSubmitting && step !== 'done' && step !== 'correcting' + const autosave = useAutosave(simulationId, texte, autosaveEnabled) // FTD-21 — marquer la simulation en cours pour le resume au refresh. useEffect(() => { diff --git a/src/features/simulations/components/rapport/JobStatusFallback.tsx b/src/features/simulations/components/rapport/JobStatusFallback.tsx index c1bd7c4..7871b93 100644 --- a/src/features/simulations/components/rapport/JobStatusFallback.tsx +++ b/src/features/simulations/components/rapport/JobStatusFallback.tsx @@ -1,38 +1,58 @@ /** - * JobStatusFallback — Sprint 3.6b. + * JobStatusFallback — Sprint 3.6b + FTD-24. * * Affiche un fallback visuel pour les sections générées en asynchrone par le * backend (exercices, production modèle) : - * - 'pending' → "Génération en cours…" avec spinner (refresh manuel côté user) - * - 'error' → "Indisponible pour le moment" - * - * FTD-24 tracera le polling automatique (laissé pour une session ultérieure). + * - 'pending' → "Génération en cours…" avec spinner. Polling automatique + * géré par useRapport (cf. FTD-24). + * - 'pending' + hasTimedOut → message "La génération prend plus de temps + * que prévu" + bouton Réessayer. + * - 'error' → "Indisponible pour le moment". * * Règle L : tokens Direction H exclusivement. */ import { Loader2 } from 'lucide-react' import { Card } from '@/shared/ui/Card' +import { Button } from '@/shared/ui/Button' import type { JobStatus } from '@/entities/report/types' interface Props { status: JobStatus pendingLabel?: string errorLabel?: string + hasTimedOut?: boolean + onRetry?: () => void } export function JobStatusFallback({ status, pendingLabel = 'Génération en cours…', errorLabel = 'Indisponible pour le moment.', + hasTimedOut = false, + onRetry, }: Props) { if (status === 'pending') { + if (hasTimedOut) { + return ( + +

+ La génération prend plus de temps que prévu. +

+ {onRetry && ( + + )} +
+ ) + } + return ( ) diff --git a/src/features/simulations/hooks/__tests__/useAutosave.test.ts b/src/features/simulations/hooks/__tests__/useAutosave.test.ts index 7d0e214..d7dd16f 100644 --- a/src/features/simulations/hooks/__tests__/useAutosave.test.ts +++ b/src/features/simulations/hooks/__tests__/useAutosave.test.ts @@ -112,6 +112,48 @@ describe('useAutosave', () => { expect(mocked).not.toHaveBeenCalled() }) + it('FTD-23 : enabled true→false annule le debounce en cours', async () => { + const { rerender } = renderHook( + ({ contenu, enabled }: { contenu: string; enabled: boolean }) => + useAutosave('sim-1', contenu, enabled), + { initialProps: { contenu: '', enabled: true } }, + ) + + rerender({ contenu: 'hello world', enabled: true }) + + // Avance partiellement le debounce. + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + expect(mocked).not.toHaveBeenCalled() + + // Passage à enabled=false (simule step='done' après correction). + rerender({ contenu: 'hello world', enabled: false }) + + // Fin du debounce — ne doit PAS déclencher d'appel. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(mocked).not.toHaveBeenCalled() + }) + + it('FTD-23 : enabled=false + beforeunload = aucun appel', async () => { + const { rerender } = renderHook( + ({ contenu, enabled }: { contenu: string; enabled: boolean }) => + useAutosave('sim-1', contenu, enabled), + { initialProps: { contenu: 'texte', enabled: true } }, + ) + + rerender({ contenu: 'texte', enabled: false }) + + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(mocked).not.toHaveBeenCalled() + }) + it("dédoublonnage : pas de second appel si le contenu n'a pas changé", async () => { const { rerender } = renderHook( ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), diff --git a/src/features/simulations/hooks/__tests__/useRapport.test.tsx b/src/features/simulations/hooks/__tests__/useRapport.test.tsx new file mode 100644 index 0000000..c7212ad --- /dev/null +++ b/src/features/simulations/hooks/__tests__/useRapport.test.tsx @@ -0,0 +1,207 @@ +/** + * Tests du hook useRapport — FTD-24. + * + * Valide : + * - Démarrage polling quand exercices_status ou modele_status === 'pending' + * - Arrêt polling quand les deux statuts sortent de 'pending' (ready) + * - Arrêt polling quand les deux statuts sont 'error' + * - hasTimedOut=true après 2 min de polling continu + * - refetch() remet hasTimedOut=false et relance le polling + * + * Note : fake timers + waitFor ne font pas bon ménage. On avance les timers + * manuellement via `vi.advanceTimersByTimeAsync` sous `act()`, ce qui déclenche + * les refetchs TanStack Query et les re-renders synchronement dans le test. + */ + +import React from 'react' +import { act, renderHook } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/entities/report/api', () => ({ + getReport: vi.fn(), +})) + +import { getReport } from '@/entities/report/api' +import type { Report } from '@/entities/report/types' +import { useRapport } from '../useRapport' + +const mockedGetReport = vi.mocked(getReport) + +function makeReport(overrides: Partial = {}): Report { + return { + simulation_id: 'sim-1', + score: 14, + nclc: 8, + 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', + ...overrides, + } +} + +function renderUseRapport() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + return renderHook(() => useRapport('sim-1'), { wrapper }) +} + +/** Flush microtasks + une tick timer pour laisser TanStack Query / React se stabiliser. */ +async function flush() { + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) +} + +describe('useRapport — FTD-24 polling', () => { + beforeEach(() => { + vi.useFakeTimers() + mockedGetReport.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("démarre le polling quand exercices_status='pending'", async () => { + mockedGetReport.mockResolvedValue( + makeReport({ exercices_status: 'pending', modele_status: 'ready' }), + ) + + const { result } = renderUseRapport() + + await flush() + expect(result.current.rapport).toBeDefined() + expect(mockedGetReport).toHaveBeenCalledTimes(1) + expect(result.current.isPolling).toBe(true) + + // Après 3 s : 2e appel (polling). + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(mockedGetReport).toHaveBeenCalledTimes(2) + + // Après 3 s de plus : 3e appel. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(mockedGetReport).toHaveBeenCalledTimes(3) + }) + + it('arrête le polling dès que les deux statuts sortent de pending (ready)', async () => { + mockedGetReport + .mockResolvedValueOnce( + makeReport({ exercices_status: 'pending', modele_status: 'pending' }), + ) + .mockResolvedValue(makeReport({ exercices_status: 'ready', modele_status: 'ready' })) + + const { result } = renderUseRapport() + + await flush() + expect(result.current.isPolling).toBe(true) + + // Tick polling : 2e appel renvoie ready/ready. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + await flush() + expect(result.current.isPolling).toBe(false) + + // 5 s de plus : pas de nouvel appel (polling stoppé). + const callsAfterReady = mockedGetReport.mock.calls.length + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(mockedGetReport).toHaveBeenCalledTimes(callsAfterReady) + }) + + it("n'active pas le polling quand les deux statuts sont 'error'", async () => { + mockedGetReport.mockResolvedValue( + makeReport({ exercices_status: 'error', modele_status: 'error' }), + ) + + const { result } = renderUseRapport() + + await flush() + expect(result.current.rapport).toBeDefined() + expect(result.current.isPolling).toBe(false) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(mockedGetReport).toHaveBeenCalledTimes(1) + }) + + it('hasTimedOut=true après 2 min de polling continu, puis arrêt', async () => { + mockedGetReport.mockResolvedValue( + makeReport({ exercices_status: 'pending', modele_status: 'pending' }), + ) + + const { result } = renderUseRapport() + + await flush() + expect(result.current.isPolling).toBe(true) + + // 120 s de polling continu → timeout. + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000) + }) + await flush() + + expect(result.current.hasTimedOut).toBe(true) + expect(result.current.isPolling).toBe(false) + + const callsAtTimeout = mockedGetReport.mock.calls.length + + // Après timeout, pas de nouvel appel déclenché par refetchInterval. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(mockedGetReport).toHaveBeenCalledTimes(callsAtTimeout) + }) + + it('refetch() remet hasTimedOut=false et relance le polling', async () => { + mockedGetReport.mockResolvedValue( + makeReport({ exercices_status: 'pending', modele_status: 'pending' }), + ) + + const { result } = renderUseRapport() + + await flush() + expect(result.current.isPolling).toBe(true) + + // Déclenche le timeout. + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000) + }) + await flush() + expect(result.current.hasTimedOut).toBe(true) + + const callsBeforeRetry = mockedGetReport.mock.calls.length + + // refetch() réinitialise le flag et refait un appel. + await act(async () => { + await result.current.refetch() + }) + + expect(result.current.hasTimedOut).toBe(false) + expect(mockedGetReport.mock.calls.length).toBe(callsBeforeRetry + 1) + + // Polling actif à nouveau : tick → nouvel appel. + expect(result.current.isPolling).toBe(true) + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(mockedGetReport.mock.calls.length).toBeGreaterThan(callsBeforeRetry + 1) + }) +}) diff --git a/src/features/simulations/hooks/useRapport.ts b/src/features/simulations/hooks/useRapport.ts index 415c89d..8a2c384 100644 --- a/src/features/simulations/hooks/useRapport.ts +++ b/src/features/simulations/hooks/useRapport.ts @@ -2,22 +2,68 @@ * Hook de récupération d'un rapport de correction. * * Appelle GET /simulations/:id (cache TanStack Query). - * staleTime Infinity : un rapport ne change jamais après correction. + * staleTime Infinity : un rapport figé ne doit pas être refetché au focus. * - * Règle H : aucune logique métier — expose les données brutes. + * FTD-24 — Polling automatique quand les jobs backend (exercices, modèle) + * sont encore `pending` après la réponse de correction : + * - refetchInterval : 3 s tant qu'un des deux statuts vaut 'pending'. + * - Arrêt automatique dès que les deux statuts sortent de 'pending' + * (ready ou error). + * - Timeout global : 2 min de polling actif → `hasTimedOut = true`, + * le polling s'arrête et l'UI propose un bouton Réessayer qui + * réinitialise le flag et relance un refetch. + * + * Règle H : aucune logique métier — expose les données brutes + flags UI. */ +import { useCallback, useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { getReport } from '@/entities/report/api' import type { Report } from '@/entities/report/types' +const POLL_INTERVAL_MS = 3000 +const POLL_TIMEOUT_MS = 120_000 + +function hasPendingJob(report: Report | undefined): boolean { + if (!report) return false + return report.exercices_status === 'pending' || report.modele_status === 'pending' +} + export function useRapport(id: string) { - const { data, isLoading, isError, error, refetch } = useQuery({ + const [hasTimedOut, setHasTimedOut] = useState(false) + + const query = useQuery({ queryKey: ['rapport', id], queryFn: () => getReport(id), enabled: Boolean(id), staleTime: Infinity, + refetchInterval: (q) => { + if (hasTimedOut) return false + return hasPendingJob(q.state.data) ? POLL_INTERVAL_MS : false + }, }) - return { rapport: data, isLoading, isError, error, refetch } + const isPolling = !hasTimedOut && hasPendingJob(query.data) + + // Timer 2 min armé au démarrage du polling, clear dès qu'il s'arrête. + useEffect(() => { + if (!isPolling) return + const timer = setTimeout(() => setHasTimedOut(true), POLL_TIMEOUT_MS) + return () => clearTimeout(timer) + }, [isPolling]) + + const refetch = useCallback(async () => { + setHasTimedOut(false) + await query.refetch() + }, [query]) + + return { + rapport: query.data, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + refetch, + isPolling, + hasTimedOut, + } } diff --git a/src/features/simulations/pages/RapportPage.tsx b/src/features/simulations/pages/RapportPage.tsx index 6ae5674..d3a4e6e 100644 --- a/src/features/simulations/pages/RapportPage.tsx +++ b/src/features/simulations/pages/RapportPage.tsx @@ -108,7 +108,15 @@ function CriteresSection({ rapport }: { rapport: Report }) { ) } -function ExercicesSection({ rapport }: { rapport: Report }) { +function ExercicesSection({ + rapport, + hasTimedOut, + onRetry, +}: { + rapport: Report + hasTimedOut: boolean + onRetry: () => void +}) { if (rapport.exercices_status !== 'ready' || !rapport.exercices) { return (
@@ -117,6 +125,8 @@ function ExercicesSection({ rapport }: { rapport: Report }) { status={rapport.exercices_status} pendingLabel="Génération des exercices en cours…" errorLabel="Exercices indisponibles. Réessayez plus tard." + hasTimedOut={hasTimedOut} + onRetry={onRetry} />
) @@ -134,7 +144,15 @@ function ExercicesSection({ rapport }: { rapport: Report }) { ) } -function ModeleSection({ rapport }: { rapport: Report }) { +function ModeleSection({ + rapport, + hasTimedOut, + onRetry, +}: { + rapport: Report + hasTimedOut: boolean + onRetry: () => void +}) { if (rapport.modele_status !== 'ready' || !rapport.modele) { return (
@@ -143,6 +161,8 @@ function ModeleSection({ rapport }: { rapport: Report }) { status={rapport.modele_status} pendingLabel="Production modèle en cours de génération…" errorLabel="Production modèle indisponible. Réessayez plus tard." + hasTimedOut={hasTimedOut} + onRetry={onRetry} />
) @@ -162,7 +182,7 @@ export function RapportPage() { const { id = '' } = useParams<{ id: string }>() const navigate = useNavigate() - const { rapport, isLoading, isError, error } = useRapport(id) + const { rapport, isLoading, isError, error, refetch, hasTimedOut } = useRapport(id) const isInProgress = isError && isReportNotReady(error) const { reset } = useSimulation() @@ -245,11 +265,19 @@ export function RapportPage() { visible={isSectionVisible(planData.plan, 'exercices')} onUpgrade={onUpgrade} > - + void refetch()} + /> - + void refetch()} + /> {/* Action de sortie — reset + nouvelle simulation */}