This commit is contained in:
64
tests/hooks/useCooldownTimer.test.tsx
Normal file
64
tests/hooks/useCooldownTimer.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useCooldownTimer } from '../../src/hooks/useCooldownTimer';
|
||||
import { renderHook } from '../helpers/renderHook';
|
||||
|
||||
describe('useCooldownTimer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('decrements cooldown every second while enabled', () => {
|
||||
const { result } = renderHook(() => useCooldownTimer(2, true));
|
||||
|
||||
expect(result.current.cooldown).toBe(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current.cooldown).toBe(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current.cooldown).toBe(0);
|
||||
});
|
||||
|
||||
it('does not decrement when disabled', () => {
|
||||
const { result } = renderHook(() => useCooldownTimer(2, false));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(result.current.cooldown).toBe(2);
|
||||
});
|
||||
|
||||
it('startCooldown floors values and clamps negatives', () => {
|
||||
const { result } = renderHook(() => useCooldownTimer(0, true));
|
||||
|
||||
act(() => {
|
||||
result.current.startCooldown(2.8);
|
||||
});
|
||||
expect(result.current.cooldown).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.startCooldown(-4);
|
||||
});
|
||||
expect(result.current.cooldown).toBe(0);
|
||||
});
|
||||
|
||||
it('stays at zero when already expired', () => {
|
||||
const { result } = renderHook(() => useCooldownTimer(0, true));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(result.current.cooldown).toBe(0);
|
||||
});
|
||||
});
|
||||
81
tests/hooks/useEditableForm.test.tsx
Normal file
81
tests/hooks/useEditableForm.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { act } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useEditableForm } from '../../src/hooks/useEditableForm';
|
||||
import { renderHook } from '../helpers/renderHook';
|
||||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const INITIAL_VALUES: FormValues = {
|
||||
username: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
function validate(values: FormValues) {
|
||||
return {
|
||||
username: values.username.trim().length < 3 ? 'Username too short' : undefined,
|
||||
email: values.email.includes('@') ? undefined : 'Invalid email',
|
||||
};
|
||||
}
|
||||
|
||||
describe('useEditableForm', () => {
|
||||
it('supports load/start/discard/commit edit lifecycle', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditableForm({
|
||||
initialValues: INITIAL_VALUES,
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isEditing).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.loadFromSource({ username: 'alice', email: 'alice@example.com' });
|
||||
});
|
||||
expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' });
|
||||
expect(result.current.errors).toEqual({});
|
||||
|
||||
act(() => {
|
||||
result.current.startEditing({ username: 'al', email: 'aliceexample.com' });
|
||||
});
|
||||
expect(result.current.isEditing).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setFieldValue('username', 'a');
|
||||
});
|
||||
expect(result.current.errors.username).toBe('Username too short');
|
||||
|
||||
act(() => {
|
||||
result.current.discardChanges({ username: 'alice', email: 'alice@example.com' });
|
||||
});
|
||||
expect(result.current.isEditing).toBe(false);
|
||||
expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' });
|
||||
|
||||
act(() => {
|
||||
result.current.startEditing({ username: 'alice', email: 'alice@example.com' });
|
||||
result.current.setFieldValue('username', 'alice_2');
|
||||
result.current.commitSaved({ username: 'alice_2', email: 'alice@example.com' });
|
||||
});
|
||||
expect(result.current.isEditing).toBe(false);
|
||||
expect(result.current.values.username).toBe('alice_2');
|
||||
});
|
||||
|
||||
it('exposes direct editing toggles and field error injection', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditableForm({
|
||||
initialValues: INITIAL_VALUES,
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setIsEditing(true);
|
||||
result.current.setFieldError('username', 'Username already taken');
|
||||
});
|
||||
|
||||
expect(result.current.isEditing).toBe(true);
|
||||
expect(result.current.errors.username).toBe('Username already taken');
|
||||
});
|
||||
});
|
||||
170
tests/hooks/usePaginatedResource.test.tsx
Normal file
170
tests/hooks/usePaginatedResource.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { usePaginatedResource } from '../../src/hooks/usePaginatedResource';
|
||||
import { renderHook } from '../helpers/renderHook';
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
async function flushDebounce(delay = 250) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(delay);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('usePaginatedResource', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('loads first page after debounce and stores response fields', async () => {
|
||||
const loadMock = vi.fn(async () => ({
|
||||
items: [{ id: '1' }] as Item[],
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 25,
|
||||
totalPages: 3,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedResource<Item>({
|
||||
load: loadMock,
|
||||
sort: '-createdAt',
|
||||
}),
|
||||
);
|
||||
|
||||
await flushDebounce();
|
||||
|
||||
expect(loadMock).toHaveBeenCalledWith({
|
||||
q: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
sort: '-createdAt',
|
||||
});
|
||||
expect(result.current.items).toEqual([{ id: '1' }]);
|
||||
expect(result.current.total).toBe(25);
|
||||
expect(result.current.totalPages).toBe(3);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('resets page to 1 when query or page size changes', async () => {
|
||||
const loadMock = vi.fn(
|
||||
async ({ q, page, pageSize }: { q: string; page: number; pageSize: number }) => ({
|
||||
items: [{ id: `${q}:${page}:${pageSize}` }] as Item[],
|
||||
page,
|
||||
pageSize,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedResource<Item>({
|
||||
load: loadMock,
|
||||
}),
|
||||
);
|
||||
|
||||
await flushDebounce();
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(2);
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(loadMock).toHaveBeenLastCalledWith({
|
||||
q: '',
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
sort: undefined,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery('search');
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(loadMock).toHaveBeenLastCalledWith({
|
||||
q: 'search',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
sort: undefined,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(3);
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
act(() => {
|
||||
result.current.setPageSize(20);
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(loadMock).toHaveBeenLastCalledWith({
|
||||
q: 'search',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
sort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels stale debounce timers when inputs change quickly', async () => {
|
||||
const loadMock = vi.fn(async () => ({
|
||||
items: [] as Item[],
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedResource<Item>({
|
||||
load: loadMock,
|
||||
debounceMs: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery('latest');
|
||||
});
|
||||
|
||||
await flushDebounce(100);
|
||||
|
||||
expect(loadMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadMock).toHaveBeenCalledWith({
|
||||
q: 'latest',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
sort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps thrown errors into string error state', async () => {
|
||||
const loadMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Load failed'))
|
||||
.mockRejectedValueOnce('unknown');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedResource<Item>({
|
||||
load: loadMock,
|
||||
}),
|
||||
);
|
||||
|
||||
await flushDebounce();
|
||||
expect(result.current.error).toBe('Load failed');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(2);
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.error).toBe('Request failed. Please try again.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
97
tests/hooks/useSorting.test.tsx
Normal file
97
tests/hooks/useSorting.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { act } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatSortParam, useSorting } from '../../src/hooks/useSorting';
|
||||
import { renderHook } from '../helpers/renderHook';
|
||||
|
||||
describe('useSorting', () => {
|
||||
it('starts from default sort and cycles asc/desc/default', () => {
|
||||
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'asc' }));
|
||||
|
||||
expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' });
|
||||
expect(result.current.sortParam).toBe('createdAt');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('name');
|
||||
});
|
||||
expect(result.current.activeSort).toEqual({ field: 'name', direction: 'asc' });
|
||||
expect(result.current.sortParam).toBe('name');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('name');
|
||||
});
|
||||
expect(result.current.activeSort).toEqual({ field: 'name', direction: 'desc' });
|
||||
expect(result.current.sortParam).toBe('-name');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('name');
|
||||
});
|
||||
expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' });
|
||||
expect(result.current.sortParam).toBe('createdAt');
|
||||
});
|
||||
|
||||
it('cycles sort state without a baseline default', () => {
|
||||
const { result } = renderHook(() => useSorting(null));
|
||||
|
||||
expect(result.current.activeSort).toBeNull();
|
||||
expect(result.current.sortParam).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('updatedAt');
|
||||
});
|
||||
expect(result.current.sortParam).toBe('updatedAt');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('updatedAt');
|
||||
});
|
||||
expect(result.current.sortParam).toBe('-updatedAt');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('updatedAt');
|
||||
});
|
||||
expect(result.current.sortParam).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports manual setSort and resetSort', () => {
|
||||
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' }));
|
||||
|
||||
expect(result.current.sortParam).toBe('-createdAt');
|
||||
|
||||
act(() => {
|
||||
result.current.setSort({ field: 'title', direction: 'asc' });
|
||||
});
|
||||
expect(result.current.sortParam).toBe('title');
|
||||
|
||||
act(() => {
|
||||
result.current.resetSort();
|
||||
});
|
||||
expect(result.current.sortParam).toBe('-createdAt');
|
||||
});
|
||||
|
||||
it('toggles baseline field directly between baseline and opposite direction', () => {
|
||||
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' }));
|
||||
|
||||
expect(result.current.sortParam).toBe('-createdAt');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('createdAt');
|
||||
});
|
||||
expect(result.current.sortParam).toBe('createdAt');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('createdAt');
|
||||
});
|
||||
expect(result.current.sortParam).toBe('-createdAt');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSort('createdAt');
|
||||
});
|
||||
expect(result.current.sortParam).toBe('createdAt');
|
||||
});
|
||||
|
||||
it('formats sort params safely', () => {
|
||||
expect(formatSortParam(null)).toBeUndefined();
|
||||
expect(formatSortParam(undefined)).toBeUndefined();
|
||||
expect(formatSortParam({ field: 'updatedAt', direction: 'desc' })).toBe('-updatedAt');
|
||||
expect(formatSortParam({ field: 'updatedAt', direction: 'asc' })).toBe('updatedAt');
|
||||
});
|
||||
});
|
||||
44
tests/hooks/useSubmitState.test.tsx
Normal file
44
tests/hooks/useSubmitState.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { act } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useSubmitState } from '../../src/hooks/useSubmitState';
|
||||
import { renderHook } from '../helpers/renderHook';
|
||||
|
||||
describe('useSubmitState', () => {
|
||||
it('tracks submit lifecycle and feedback state', () => {
|
||||
const { result } = renderHook(() => useSubmitState<string | null>(null));
|
||||
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
expect(result.current.submitError).toBeNull();
|
||||
expect(result.current.status).toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.startSubmitting();
|
||||
result.current.setSubmitError('Oops');
|
||||
result.current.setStatus('Done');
|
||||
});
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.submitError).toBe('Oops');
|
||||
expect(result.current.status).toBe('Done');
|
||||
|
||||
act(() => {
|
||||
result.current.finishSubmitting();
|
||||
result.current.clearFeedback();
|
||||
});
|
||||
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
expect(result.current.submitError).toBeNull();
|
||||
expect(result.current.status).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the typed initial status in clearFeedback', () => {
|
||||
const { result } = renderHook(() => useSubmitState<'idle' | 'done'>('idle'));
|
||||
|
||||
act(() => {
|
||||
result.current.setStatus('done');
|
||||
result.current.clearFeedback();
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
158
tests/hooks/useValidatedFields.test.tsx
Normal file
158
tests/hooks/useValidatedFields.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { act } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useValidatedFields } from '../../src/hooks/useValidatedFields';
|
||||
import { renderHook } from '../helpers/renderHook';
|
||||
|
||||
type FormValues = {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
function validate(values: FormValues) {
|
||||
return {
|
||||
password: values.password.length < 3 ? 'Password too short' : undefined,
|
||||
confirmPassword:
|
||||
values.confirmPassword !== values.password ? 'Passwords do not match' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useValidatedFields', () => {
|
||||
it('initializes values and keeps errors hidden until fields are touched', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: '', confirmPassword: '' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.values).toEqual({ password: '', confirmPassword: '' });
|
||||
expect(result.current.errors).toEqual({});
|
||||
expect(result.current.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('setFieldValue touches and validates by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: '', confirmPassword: '' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setFieldValue('password', 'ab');
|
||||
});
|
||||
|
||||
expect(result.current.values.password).toBe('ab');
|
||||
expect(result.current.errors.password).toBe('Password too short');
|
||||
});
|
||||
|
||||
it('setFieldValue can skip touch and validation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: '', confirmPassword: '' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setFieldValue('password', 'ab', { touch: false, validate: false });
|
||||
});
|
||||
|
||||
expect(result.current.values.password).toBe('ab');
|
||||
expect(result.current.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('validateAll can avoid touching fields when requested', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: 'abcd', confirmPassword: 'abce' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
let validationErrors: ReturnType<typeof validate> | undefined;
|
||||
act(() => {
|
||||
validationErrors = result.current.validateAll({ touchAll: false });
|
||||
});
|
||||
|
||||
expect(validationErrors).toEqual({
|
||||
password: undefined,
|
||||
confirmPassword: 'Passwords do not match',
|
||||
});
|
||||
expect(result.current.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('validateAll touches all fields by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: '', confirmPassword: '' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.validateAll();
|
||||
});
|
||||
|
||||
expect(result.current.errors.password).toBe('Password too short');
|
||||
expect(result.current.errors.confirmPassword).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports setFieldError, setErrors and clearErrors', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: 'abcd', confirmPassword: 'abcd' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setFieldError('password', 'Server-side password issue');
|
||||
});
|
||||
expect(result.current.errors.password).toBe('Server-side password issue');
|
||||
|
||||
act(() => {
|
||||
result.current.setErrors({
|
||||
password: undefined,
|
||||
confirmPassword: 'Still invalid',
|
||||
});
|
||||
});
|
||||
expect(result.current.errors.confirmPassword).toBe('Still invalid');
|
||||
|
||||
act(() => {
|
||||
result.current.clearErrors();
|
||||
});
|
||||
expect(result.current.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('setValues with clearErrors resets touched state and revalidates', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useValidatedFields({
|
||||
initialValues: { password: '', confirmPassword: '' },
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setFieldValue('password', 'ab');
|
||||
});
|
||||
expect(result.current.errors.password).toBe('Password too short');
|
||||
|
||||
act(() => {
|
||||
result.current.setValues(
|
||||
{
|
||||
password: 'abcd',
|
||||
confirmPassword: 'abcd',
|
||||
},
|
||||
{ clearErrors: true },
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.values).toEqual({
|
||||
password: 'abcd',
|
||||
confirmPassword: 'abcd',
|
||||
});
|
||||
expect(result.current.errors).toEqual({});
|
||||
expect(result.current.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user