// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAutoSave } from './useAutoSave'; describe('useAutoSave', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('debounces: only the latest payload is saved, once', async () => { const save = vi.fn().mockResolvedValue(); const { result } = renderHook(() => useAutoSave(save, 400)); act(() => { result.current.schedule({ a: 1 }); result.current.schedule({ a: 2 }); result.current.schedule({ a: 3 }); }); expect(save).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(400); }); expect(save).toHaveBeenCalledTimes(1); expect(save).toHaveBeenCalledWith({ a: 3 }); expect(result.current.status).toBe('saved'); }); it('per-call delay overrides the default', async () => { const save = vi.fn().mockResolvedValue(); const { result } = renderHook(() => useAutoSave(save, 400)); act(() => { result.current.schedule({ slow: true }, 900); }); await act(async () => { await vi.advanceTimersByTimeAsync(400); }); expect(save).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(500); }); expect(save).toHaveBeenCalledWith({ slow: true }); }); it('flush() saves a pending payload immediately and is a no-op when idle', async () => { const save = vi.fn().mockResolvedValue(); const { result } = renderHook(() => useAutoSave(save, 400)); await act(async () => { result.current.flush(); }); expect(save).not.toHaveBeenCalled(); act(() => { result.current.schedule({ b: 1 }, 900); }); await act(async () => { result.current.flush(); }); expect(save).toHaveBeenCalledTimes(1); expect(save).toHaveBeenCalledWith({ b: 1 }); // payload no longer pending — flushing again must not double-save await act(async () => { result.current.flush(); }); expect(save).toHaveBeenCalledTimes(1); }); it('reports error status when the save rejects, then recovers on next save', async () => { const save = vi.fn() .mockRejectedValueOnce(new Error('boom')) .mockResolvedValueOnce(); const { result } = renderHook(() => useAutoSave(save, 100)); act(() => { result.current.schedule({ x: 1 }); }); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(result.current.status).toBe('error'); act(() => { result.current.schedule({ x: 2 }); }); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(result.current.status).toBe('saved'); }); it('"saved" fades back to idle after 2 seconds', async () => { const save = vi.fn().mockResolvedValue(); const { result } = renderHook(() => useAutoSave(save, 100)); act(() => { result.current.schedule({ y: 1 }); }); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(result.current.status).toBe('saved'); await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(result.current.status).toBe('idle'); }); it('flushes a pending payload on unmount so edits are never lost', async () => { const save = vi.fn().mockResolvedValue(); const { result, unmount } = renderHook(() => useAutoSave(save, 900)); act(() => { result.current.schedule({ unsaved: true }); }); expect(save).not.toHaveBeenCalled(); unmount(); expect(save).toHaveBeenCalledTimes(1); expect(save).toHaveBeenCalledWith({ unsaved: true }); }); });