feat(simulations): autosave 30s + localStorage + reprise contenu (FTD-21)

This commit is contained in:
Hermann_Kitio 2026-04-21 03:57:10 +03:00
parent d395a04193
commit 549e5f698f
4 changed files with 244 additions and 1 deletions

View file

@ -0,0 +1,135 @@
/**
* Tests du hook useAutosave FTD-21.
*
* Valide :
* - debounce 30 s (pas de save avant, save après)
* - flush immédiat sur `beforeunload`
* - cleanup du listener au unmount
* - enabled=false aucun appel
*/
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/entities/production/api', () => ({
autosaveContenu: vi.fn().mockResolvedValue(undefined),
}))
import { autosaveContenu } from '@/entities/production/api'
import { useAutosave } from '../useAutosave'
const mocked = vi.mocked(autosaveContenu)
describe('useAutosave', () => {
beforeEach(() => {
vi.useFakeTimers()
mocked.mockClear()
mocked.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('debounce 30 s : pas d\'appel avant, appel après', async () => {
const { rerender } = renderHook(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } },
)
rerender({ contenu: 'hello world' })
// Avant 30 s : aucun appel
await act(async () => {
await vi.advanceTimersByTimeAsync(29_000)
})
expect(mocked).not.toHaveBeenCalled()
// Après 30 s : save
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(mocked).toHaveBeenCalledTimes(1)
expect(mocked).toHaveBeenCalledWith('sim-1', 'hello world')
})
it('flush immédiat sur beforeunload', async () => {
const { rerender } = renderHook(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } },
)
rerender({ contenu: 'texte à sauvegarder' })
await act(async () => {
window.dispatchEvent(new Event('beforeunload'))
await Promise.resolve()
})
expect(mocked).toHaveBeenCalledTimes(1)
expect(mocked).toHaveBeenCalledWith('sim-1', 'texte à sauvegarder')
})
it('cleanup : unmount retire le listener beforeunload', async () => {
const { unmount, rerender } = renderHook(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } },
)
rerender({ contenu: 'texte' })
unmount()
await act(async () => {
window.dispatchEvent(new Event('beforeunload'))
await Promise.resolve()
})
expect(mocked).not.toHaveBeenCalled()
})
it('enabled=false : aucun appel, même après 30 s', async () => {
const { rerender } = renderHook(
({ contenu, enabled }: { contenu: string; enabled: boolean }) =>
useAutosave('sim-1', contenu, enabled),
{ initialProps: { contenu: 'texte', enabled: false } },
)
rerender({ contenu: 'texte modifié', enabled: false })
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000)
})
expect(mocked).not.toHaveBeenCalled()
})
it('simulationId null : aucun appel', async () => {
renderHook(() => useAutosave(null, 'texte', true))
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000)
})
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),
{ initialProps: { contenu: '' } },
)
rerender({ contenu: 'identique' })
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000)
})
expect(mocked).toHaveBeenCalledTimes(1)
// Rerender avec même contenu → debounce ne repart pas (le contenu ref ne change pas côté React)
// Simule une édition puis retour au texte initial (déjà sauvegardé)
rerender({ contenu: 'identique' })
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000)
})
expect(mocked).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,72 @@
/**
* FTD-21 autosave du contenu d'une simulation en cours.
*
* Debounce 30 s sur chaque changement de `contenu`. Flush immédiat au
* `beforeunload` (best-effort : la promesse peut ne pas aboutir si la page
* se ferme avant la réponse le texte reste en `localStorage` côté texte
* utilisateur + state parent).
*
* Dédoublonnage : aucun appel réseau si le contenu n'a pas changé depuis
* le dernier save réussi.
*
* Règle H : pas de logique métier wrap autour de `entities/production/api.autosaveContenu`.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { autosaveContenu } from '@/entities/production/api'
const DEBOUNCE_MS = 30_000
export interface UseAutosaveResult {
savedAt: Date | null
isSaving: boolean
}
export function useAutosave(
simulationId: string | null,
contenu: string,
enabled: boolean,
): UseAutosaveResult {
const [savedAt, setSavedAt] = useState<Date | null>(null)
const [isSaving, setIsSaving] = useState(false)
const lastSavedContenuRef = useRef<string | null>(null)
const latestRef = useRef({ simulationId, contenu, enabled })
latestRef.current = { simulationId, contenu, enabled }
const flush = useCallback(async () => {
const { simulationId, contenu, enabled } = latestRef.current
if (!enabled || !simulationId || !contenu) return
if (lastSavedContenuRef.current === contenu) return
setIsSaving(true)
try {
await autosaveContenu(simulationId, contenu)
lastSavedContenuRef.current = contenu
setSavedAt(new Date())
} catch {
// best-effort — pas d'UI d'erreur, le texte reste en mémoire côté client
} finally {
setIsSaving(false)
}
}, [])
useEffect(() => {
if (!enabled || !simulationId || !contenu) return
if (lastSavedContenuRef.current === contenu) return
const timer = setTimeout(() => {
void flush()
}, DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [simulationId, contenu, enabled, flush])
useEffect(() => {
const handler = () => {
void flush()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [flush])
return { savedAt, isSaving }
}