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:
Hermann_Kitio 2026-04-23 03:56:57 +03:00
parent bc2a1174d1
commit cab9c8c92b
8 changed files with 379 additions and 20 deletions

View file

@ -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),

View 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)
})
})