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
|
|
@ -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),
|
||||
|
|
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue