BillTracker/client/hooks/useAutoSave.test.jsx

94 lines
3.5 KiB
React
Raw Normal View History

// @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 });
});
});