diff --git a/web/package.json b/web/package.json index d12a906..aad254c 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,10 @@ "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --ext ts,tsx --fix", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@hookform/resolvers": "^3.3.3", @@ -27,6 +30,9 @@ "zustand": "^4.4.7" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.14.0", @@ -36,10 +42,12 @@ "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^28.0.0", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^4.0.18" }, "engines": { "node": ">=18.0.0", diff --git a/web/src/hooks/useToast.test.ts b/web/src/hooks/useToast.test.ts new file mode 100644 index 0000000..019602e --- /dev/null +++ b/web/src/hooks/useToast.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for useToast hook + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useToast } from './useToast'; +import { useToastStore } from '../stores/toastStore'; + +describe('useToast', () => { + beforeEach(() => { + // Reset store before each test + useToastStore.setState({ toasts: [] }); + }); + + describe('success', () => { + it('should add a success toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Operation successful'); + }); + + const state = useToastStore.getState(); + expect(state.toasts).toHaveLength(1); + expect(state.toasts[0].type).toBe('success'); + expect(state.toasts[0].message).toBe('Operation successful'); + }); + + it('should support custom title and duration', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Saved', { title: 'Success!', duration: 3000 }); + }); + + const state = useToastStore.getState(); + expect(state.toasts[0].title).toBe('Success!'); + expect(state.toasts[0].duration).toBe(3000); + }); + }); + + describe('error', () => { + it('should add an error toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.error('Something went wrong'); + }); + + const state = useToastStore.getState(); + expect(state.toasts[0].type).toBe('error'); + }); + }); + + describe('warning', () => { + it('should add a warning toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.warning('Please check your input'); + }); + + const state = useToastStore.getState(); + expect(state.toasts[0].type).toBe('warning'); + }); + }); + + describe('info', () => { + it('should add an info toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.info('FYI: New feature available'); + }); + + const state = useToastStore.getState(); + expect(state.toasts[0].type).toBe('info'); + }); + }); + + describe('dismiss', () => { + it('should dismiss a specific toast', () => { + const { result } = renderHook(() => useToast()); + let toastId: string; + + act(() => { + toastId = result.current.success('Test'); + }); + + expect(useToastStore.getState().toasts).toHaveLength(1); + + act(() => { + result.current.dismiss(toastId); + }); + + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + }); + + describe('dismissAll', () => { + it('should dismiss all toasts', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('First'); + result.current.error('Second'); + result.current.warning('Third'); + }); + + expect(useToastStore.getState().toasts).toHaveLength(3); + + act(() => { + result.current.dismissAll(); + }); + + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + }); + + describe('toast (generic)', () => { + it('should add toast with specified type', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.toast('info', 'Generic toast'); + }); + + const state = useToastStore.getState(); + expect(state.toasts[0].type).toBe('info'); + expect(state.toasts[0].message).toBe('Generic toast'); + }); + }); +}); diff --git a/web/src/stores/toastStore.test.ts b/web/src/stores/toastStore.test.ts new file mode 100644 index 0000000..3f56a49 --- /dev/null +++ b/web/src/stores/toastStore.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for toastStore + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useToastStore } from './toastStore'; + +describe('toastStore', () => { + beforeEach(() => { + // Reset store before each test + useToastStore.setState({ toasts: [] }); + }); + + describe('addToast', () => { + it('should add a toast with generated id', () => { + const { addToast } = useToastStore.getState(); + + const id = addToast({ + type: 'success', + message: 'Test message', + }); + + const state = useToastStore.getState(); + expect(state.toasts).toHaveLength(1); + expect(state.toasts[0]).toMatchObject({ + id, + type: 'success', + message: 'Test message', + }); + }); + + it('should add toast with optional title and duration', () => { + const { addToast } = useToastStore.getState(); + + addToast({ + type: 'error', + message: 'Error message', + title: 'Error Title', + duration: 3000, + }); + + const state = useToastStore.getState(); + expect(state.toasts[0]).toMatchObject({ + type: 'error', + message: 'Error message', + title: 'Error Title', + duration: 3000, + }); + }); + + it('should add multiple toasts', () => { + const { addToast } = useToastStore.getState(); + + addToast({ type: 'success', message: 'First' }); + addToast({ type: 'error', message: 'Second' }); + addToast({ type: 'warning', message: 'Third' }); + + const state = useToastStore.getState(); + expect(state.toasts).toHaveLength(3); + }); + }); + + describe('removeToast', () => { + it('should remove a toast by id', () => { + const { addToast, removeToast } = useToastStore.getState(); + + const id = addToast({ type: 'info', message: 'Test' }); + expect(useToastStore.getState().toasts).toHaveLength(1); + + removeToast(id); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('should only remove specified toast', () => { + const { addToast, removeToast } = useToastStore.getState(); + + const id1 = addToast({ type: 'success', message: 'First' }); + const id2 = addToast({ type: 'error', message: 'Second' }); + + removeToast(id1); + + const state = useToastStore.getState(); + expect(state.toasts).toHaveLength(1); + expect(state.toasts[0].id).toBe(id2); + }); + }); + + describe('clearToasts', () => { + it('should remove all toasts', () => { + const { addToast, clearToasts } = useToastStore.getState(); + + addToast({ type: 'success', message: 'First' }); + addToast({ type: 'error', message: 'Second' }); + addToast({ type: 'warning', message: 'Third' }); + + expect(useToastStore.getState().toasts).toHaveLength(3); + + clearToasts(); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + }); +}); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..de1af3d --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,17 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'src/test/'], + }, + }, +});