fix(simulations): resolve FTD-23 autosave after correction + FTD-24 auto-polling pending jobs
- FTD-23: propagate enabled=false to useAutosave when step is done/correcting, preventing 400 PATCH after correction - FTD-24: add conditional refetchInterval (3s) in useRapport for pending exercices/modele, 2min timeout with retry UI - 7 new tests (2 regression + 5 polling), 122/122 green Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc2a1174d1
commit
cab9c8c92b
8 changed files with 379 additions and 20 deletions
|
|
@ -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
|
## [Unreleased] — 2026-04-23 — FTD-28 — Semgrep CI + CI verte
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# TECH_DEBT.md — Expria Frontend
|
# 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.
|
> 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.
|
> À 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`
|
### FTD-24 — Pas de polling automatique pour exercices / modèle `pending`
|
||||||
**Priorité :** 🟡 Important
|
**Priorité :** 🟡 Important
|
||||||
**Statut :** Ouvert — accepté au Sprint 3.6b, refresh manuel requis entre-temps
|
**Statut :** Résolu — 2026-04-23
|
||||||
**Estimation de session :** 2h
|
**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.
|
**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
|
### FTD-23 — `useAutosave` continue après correction → 400 VALIDATION_ERROR
|
||||||
**Priorité :** 🟡 Important
|
**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
|
**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).
|
**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-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-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-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`. |
|
| 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.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.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.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). |
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@ export function SimulationForm({
|
||||||
|
|
||||||
const timer = useTimer(config.dureeMinutes, !isSubmitting)
|
const timer = useTimer(config.dureeMinutes, !isSubmitting)
|
||||||
const idees = useIdees()
|
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.
|
// FTD-21 — marquer la simulation en cours pour le resume au refresh.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Affiche un fallback visuel pour les sections générées en asynchrone par le
|
||||||
* backend (exercices, production modèle) :
|
* backend (exercices, production modèle) :
|
||||||
* - 'pending' → "Génération en cours…" avec spinner (refresh manuel côté user)
|
* - 'pending' → "Génération en cours…" avec spinner. Polling automatique
|
||||||
* - 'error' → "Indisponible pour le moment"
|
* géré par useRapport (cf. FTD-24).
|
||||||
*
|
* - 'pending' + hasTimedOut → message "La génération prend plus de temps
|
||||||
* FTD-24 tracera le polling automatique (laissé pour une session ultérieure).
|
* que prévu" + bouton Réessayer.
|
||||||
|
* - 'error' → "Indisponible pour le moment".
|
||||||
*
|
*
|
||||||
* Règle L : tokens Direction H exclusivement.
|
* Règle L : tokens Direction H exclusivement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { Card } from '@/shared/ui/Card'
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
import type { JobStatus } from '@/entities/report/types'
|
import type { JobStatus } from '@/entities/report/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
pendingLabel?: string
|
pendingLabel?: string
|
||||||
errorLabel?: string
|
errorLabel?: string
|
||||||
|
hasTimedOut?: boolean
|
||||||
|
onRetry?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobStatusFallback({
|
export function JobStatusFallback({
|
||||||
status,
|
status,
|
||||||
pendingLabel = 'Génération en cours…',
|
pendingLabel = 'Génération en cours…',
|
||||||
errorLabel = 'Indisponible pour le moment.',
|
errorLabel = 'Indisponible pour le moment.',
|
||||||
|
hasTimedOut = false,
|
||||||
|
onRetry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
|
if (hasTimedOut) {
|
||||||
|
return (
|
||||||
|
<Card variant="default" className="space-y-3 p-4">
|
||||||
|
<p className="text-sm text-ink-2" role="alert">
|
||||||
|
La génération prend plus de temps que prévu.
|
||||||
|
</p>
|
||||||
|
{onRetry && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={onRetry}>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="default" className="flex items-center gap-3 p-4">
|
<Card variant="default" className="flex items-center gap-3 p-4">
|
||||||
<Loader2 className="size-4 animate-spin text-ink-4" aria-hidden="true" />
|
<Loader2 className="size-4 animate-spin text-ink-4" aria-hidden="true" />
|
||||||
<p className="text-sm text-ink-3" aria-live="polite">
|
<p className="text-sm text-ink-3" aria-live="polite">
|
||||||
{pendingLabel}{' '}
|
{pendingLabel}
|
||||||
<span className="text-ink-4">Rafraîchissez la page dans quelques instants.</span>
|
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,48 @@ describe('useAutosave', () => {
|
||||||
expect(mocked).not.toHaveBeenCalled()
|
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 () => {
|
it("dédoublonnage : pas de second appel si le contenu n'a pas changé", async () => {
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
|
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
|
||||||
|
|
|
||||||
207
src/features/simulations/hooks/__tests__/useRapport.test.tsx
Normal file
207
src/features/simulations/hooks/__tests__/useRapport.test.tsx
Normal file
|
|
@ -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> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -2,22 +2,68 @@
|
||||||
* Hook de récupération d'un rapport de correction.
|
* Hook de récupération d'un rapport de correction.
|
||||||
*
|
*
|
||||||
* Appelle GET /simulations/:id (cache TanStack Query).
|
* 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 { useQuery } from '@tanstack/react-query'
|
||||||
import { getReport } from '@/entities/report/api'
|
import { getReport } from '@/entities/report/api'
|
||||||
import type { Report } from '@/entities/report/types'
|
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) {
|
export function useRapport(id: string) {
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery<Report, Error>({
|
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||||
|
|
||||||
|
const query = useQuery<Report, Error>({
|
||||||
queryKey: ['rapport', id],
|
queryKey: ['rapport', id],
|
||||||
queryFn: () => getReport(id),
|
queryFn: () => getReport(id),
|
||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
staleTime: Infinity,
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if (rapport.exercices_status !== 'ready' || !rapport.exercices) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Exercices personnalisés">
|
<section aria-label="Exercices personnalisés">
|
||||||
|
|
@ -117,6 +125,8 @@ function ExercicesSection({ rapport }: { rapport: Report }) {
|
||||||
status={rapport.exercices_status}
|
status={rapport.exercices_status}
|
||||||
pendingLabel="Génération des exercices en cours…"
|
pendingLabel="Génération des exercices en cours…"
|
||||||
errorLabel="Exercices indisponibles. Réessayez plus tard."
|
errorLabel="Exercices indisponibles. Réessayez plus tard."
|
||||||
|
hasTimedOut={hasTimedOut}
|
||||||
|
onRetry={onRetry}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
@ -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) {
|
if (rapport.modele_status !== 'ready' || !rapport.modele) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Production modèle">
|
<section aria-label="Production modèle">
|
||||||
|
|
@ -143,6 +161,8 @@ function ModeleSection({ rapport }: { rapport: Report }) {
|
||||||
status={rapport.modele_status}
|
status={rapport.modele_status}
|
||||||
pendingLabel="Production modèle en cours de génération…"
|
pendingLabel="Production modèle en cours de génération…"
|
||||||
errorLabel="Production modèle indisponible. Réessayez plus tard."
|
errorLabel="Production modèle indisponible. Réessayez plus tard."
|
||||||
|
hasTimedOut={hasTimedOut}
|
||||||
|
onRetry={onRetry}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
@ -162,7 +182,7 @@ export function RapportPage() {
|
||||||
const { id = '' } = useParams<{ id: string }>()
|
const { id = '' } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
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 isInProgress = isError && isReportNotReady(error)
|
||||||
|
|
||||||
const { reset } = useSimulation()
|
const { reset } = useSimulation()
|
||||||
|
|
@ -245,11 +265,19 @@ export function RapportPage() {
|
||||||
visible={isSectionVisible(planData.plan, 'exercices')}
|
visible={isSectionVisible(planData.plan, 'exercices')}
|
||||||
onUpgrade={onUpgrade}
|
onUpgrade={onUpgrade}
|
||||||
>
|
>
|
||||||
<ExercicesSection rapport={rapport} />
|
<ExercicesSection
|
||||||
|
rapport={rapport}
|
||||||
|
hasTimedOut={hasTimedOut}
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
</BlurredSection>
|
</BlurredSection>
|
||||||
|
|
||||||
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
|
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
|
||||||
<ModeleSection rapport={rapport} />
|
<ModeleSection
|
||||||
|
rapport={rapport}
|
||||||
|
hasTimedOut={hasTimedOut}
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
</BlurredSection>
|
</BlurredSection>
|
||||||
|
|
||||||
{/* Action de sortie — reset + nouvelle simulation */}
|
{/* Action de sortie — reset + nouvelle simulation */}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue