From a419ee4c03cba7b864839acf4122f78358a48056 Mon Sep 17 00:00:00 2001 From: JACOB STANLEY Date: Thu, 18 Jun 2026 01:55:57 +0100 Subject: [PATCH] https://github.com/jaynomyaro/Remitwise-Frontend.git --- components/Toast.test.tsx | 324 +++++++++++++++++++++++++ components/ToastContext.test.tsx | 314 +++++++++++++++++++++++++ components/ToastRegion.test.tsx | 392 +++++++++++++++++++++++++++++++ vitest.config.mts | 7 +- vitest.setup.ts | 1 + 5 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 components/Toast.test.tsx create mode 100644 components/ToastContext.test.tsx create mode 100644 components/ToastRegion.test.tsx create mode 100644 vitest.setup.ts diff --git a/components/Toast.test.tsx b/components/Toast.test.tsx new file mode 100644 index 0000000..ca23157 --- /dev/null +++ b/components/Toast.test.tsx @@ -0,0 +1,324 @@ +/** + * Component tests for Toast + * Tests individual toast rendering, variants, interactions, and accessibility + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import Toast from './Toast'; +import type { Toast as ToastType } from '@/lib/context/ToastContext'; + +// Extend Vitest expect with jest-axe matchers +expect.extend(toHaveNoViolations); + +describe('Toast', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const mockToast: ToastType = { + id: 'test-toast-1', + variant: 'success', + title: 'Test Toast', + description: 'Test description', + duration: 5000, + }; + + const mockOnDismiss = vi.fn(); + + describe('Rendering', () => { + it('should render toast with title', () => { + render(); + expect(screen.getByText('Test Toast')).toBeInTheDocument(); + }); + + it('should render toast with description when provided', () => { + render(); + expect(screen.getByText('Test description')).toBeInTheDocument(); + }); + + it('should not render description when not provided', () => { + const toastWithoutDesc: ToastType = { + id: 'test-2', + variant: 'info', + title: 'No Description', + }; + render(); + expect(screen.queryByText('Test description')).not.toBeInTheDocument(); + }); + + it('should render action button when provided', () => { + const actionClick = vi.fn(); + const toastWithAction: ToastType = { + id: 'test-3', + variant: 'info', + title: 'With Action', + action: { label: 'Click Me', onClick: actionClick }, + }; + render(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('should render dismiss button', () => { + render(); + const dismissButton = screen.getByLabelText('Dismiss notification'); + expect(dismissButton).toBeInTheDocument(); + }); + }); + + describe('Variants', () => { + it('should render success variant', () => { + const successToast: ToastType = { ...mockToast, variant: 'success' }; + const { container } = render(); + const toastElement = container.querySelector('[role="status"]'); + expect(toastElement).toHaveClass('border-status-success-border', 'bg-status-success-soft'); + }); + + it('should render error variant', () => { + const errorToast: ToastType = { ...mockToast, variant: 'error' }; + const { container } = render(); + const toastElement = container.querySelector('[role="status"]'); + expect(toastElement).toHaveClass('border-status-error-border', 'bg-status-error-soft'); + }); + + it('should render warning variant', () => { + const warningToast: ToastType = { ...mockToast, variant: 'warning' }; + const { container } = render(); + const toastElement = container.querySelector('[role="status"]'); + expect(toastElement).toHaveClass('border-status-warning-border', 'bg-status-warning-soft'); + }); + + it('should render info variant', () => { + const infoToast: ToastType = { ...mockToast, variant: 'info' }; + const { container } = render(); + const toastElement = container.querySelector('[role="status"]'); + expect(toastElement).toHaveClass('border-status-info-border', 'bg-status-info-soft'); + }); + }); + + describe('Accessibility', () => { + it('should have no accessibility violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have role="status"', () => { + render(); + const toast = screen.getByRole('status'); + expect(toast).toBeInTheDocument(); + }); + + it('should have aria-atomic="true"', () => { + render(); + const toast = screen.getByRole('status'); + expect(toast).toHaveAttribute('aria-atomic', 'true'); + }); + + it('should have aria-hidden="true" on icon', () => { + render(); + const icon = document.querySelector('[aria-hidden="true"]'); + expect(icon).toBeInTheDocument(); + }); + + it('should have aria-label on dismiss button', () => { + render(); + const dismissButton = screen.getByLabelText('Dismiss notification'); + expect(dismissButton).toBeInTheDocument(); + }); + + it('should have focus-visible ring on dismiss button', () => { + render(); + const dismissButton = screen.getByLabelText('Dismiss notification'); + expect(dismissButton).toHaveClass('focus-visible:ring-2'); + }); + }); + + describe('Interactions', () => { + it('should call onDismiss when dismiss button is clicked', () => { + render(); + const dismissButton = screen.getByLabelText('Dismiss notification'); + fireEvent.click(dismissButton); + expect(mockOnDismiss).toHaveBeenCalledWith('test-toast-1'); + }); + + it('should call action onClick when action button is clicked', () => { + const actionClick = vi.fn(); + const toastWithAction: ToastType = { + ...mockToast, + action: { label: 'Action', onClick: actionClick }, + }; + render(); + const actionButton = screen.getByText('Action'); + fireEvent.click(actionButton); + expect(actionClick).toHaveBeenCalledTimes(1); + }); + + it('should pause timer on mouse enter', () => { + render(); + const toast = screen.getByRole('status'); + fireEvent.mouseEnter(toast); + // Timer should be paused - verify by advancing timers + act(() => { + vi.advanceTimersByTime(6000); + }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + + it('should resume timer on mouse leave', () => { + render(); + const toast = screen.getByRole('status'); + + // Pause + fireEvent.mouseEnter(toast); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + + // Resume + fireEvent.mouseLeave(toast); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(mockOnDismiss).toHaveBeenCalledWith('test-toast-1'); + }); + + it('should pause timer on focus', () => { + render(); + const toast = screen.getByRole('status'); + fireEvent.focus(toast); + act(() => { + vi.advanceTimersByTime(6000); + }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + + it('should resume timer on blur', () => { + render(); + const toast = screen.getByRole('status'); + + // Pause + fireEvent.focus(toast); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + + // Resume + fireEvent.blur(toast); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(mockOnDismiss).toHaveBeenCalledWith('test-toast-1'); + }); + }); + + describe('Auto-dismiss', () => { + it('should auto-dismiss after duration', () => { + render(); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(mockOnDismiss).toHaveBeenCalledWith('test-toast-1'); + }); + + it('should not auto-dismiss when duration is 0', () => { + const persistentToast: ToastType = { ...mockToast, duration: 0 }; + render(); + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + + it('should use custom duration when provided', () => { + const customDurationToast: ToastType = { ...mockToast, duration: 3000 }; + render(); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(mockOnDismiss).toHaveBeenCalledWith('test-toast-1'); + }); + + it('should clear timer on unmount', () => { + const { unmount } = render(); + unmount(); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + }); + + describe('Styling', () => { + it('should have correct base classes', () => { + const { container } = render(); + const toast = container.querySelector('[role="status"]'); + expect(toast).toHaveClass('flex', 'w-full', 'max-w-sm', 'items-start', 'gap-3', 'rounded-2xl'); + }); + + it('should have shadow', () => { + const { container } = render(); + const toast = container.querySelector('[role="status"]'); + expect(toast).toHaveClass('shadow-lg'); + }); + + it('should have backdrop blur', () => { + const { container } = render(); + const toast = container.querySelector('[role="status"]'); + expect(toast).toHaveClass('backdrop-blur-md'); + }); + + it('should have animation classes', () => { + const { container } = render(); + const toast = container.querySelector('[role="status"]'); + expect(toast).toHaveClass('animate-slide-in-bottom'); + }); + }); + + describe('Edge Cases', () => { + it('should handle very long titles', () => { + const longTitleToast: ToastType = { + ...mockToast, + title: 'A'.repeat(200), + }; + render(); + expect(screen.getByText('A'.repeat(200))).toBeInTheDocument(); + }); + + it('should handle very long descriptions', () => { + const longDescToast: ToastType = { + ...mockToast, + description: 'B'.repeat(500), + }; + render(); + expect(screen.getByText('B'.repeat(500))).toBeInTheDocument(); + }); + + it('should handle special characters in title', () => { + const specialToast: ToastType = { + ...mockToast, + title: 'Test ', + }; + render(); + expect(screen.getByText('Test ')).toBeInTheDocument(); + }); + + it('should handle multiple rapid dismiss calls', () => { + render(); + const dismissButton = screen.getByLabelText('Dismiss notification'); + fireEvent.click(dismissButton); + fireEvent.click(dismissButton); + fireEvent.click(dismissButton); + expect(mockOnDismiss).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/components/ToastContext.test.tsx b/components/ToastContext.test.tsx new file mode 100644 index 0000000..6ce72a0 --- /dev/null +++ b/components/ToastContext.test.tsx @@ -0,0 +1,314 @@ +/** + * Component tests for ToastContext + * Tests context provider, hook behavior, and accessibility + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { ToastProvider, useToast } from '@/lib/context/ToastContext'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +// Extend Vitest expect with jest-axe matchers +expect.extend(toHaveNoViolations); + +describe('ToastContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ToastProvider', () => { + it('should render children without errors', () => { + render( + +
Test Child
+
+ ); + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + it('should have no accessibility violations', async () => { + const { container } = render( + +
Test Content
+
+ ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should provide toast context to children', () => { + const TestComponent = () => { + const toast = useToast(); + return
{typeof toast.toast === 'function' ? 'yes' : 'no'}
; + }; + + render( + + + + ); + + expect(screen.getByTestId('has-context')).toHaveTextContent('yes'); + }); + }); + + describe('useToast hook', () => { + it('should throw error when used outside ToastProvider', () => { + const TestComponent = () => { + useToast(); + return
Test
; + }; + + expect(() => render()).toThrow('useToast must be used within a ToastProvider'); + }); + + it('should return toast context value', () => { + const TestComponent = () => { + const context = useToast(); + return ( +
+
{typeof context.toast === 'function' ? 'yes' : 'no'}
+
{typeof context.dismiss === 'function' ? 'yes' : 'no'}
+
{Array.isArray(context.toasts) ? 'yes' : 'no'}
+
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('has-toast')).toHaveTextContent('yes'); + expect(screen.getByTestId('has-dismiss')).toHaveTextContent('yes'); + expect(screen.getByTestId('has-toasts')).toHaveTextContent('yes'); + }); + + it('should add toast when toast function is called', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ +
{toasts.length}
+
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('0'); + + act(() => { + screen.getByText('Add Toast').click(); + }); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('1'); + }); + + it('should generate unique toast IDs', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ + +
{toasts.map(t => t.id).join(',')}
+
+ ); + }; + + render( + + + + ); + + act(() => { + screen.getByText('Add 1').click(); + screen.getByText('Add 2').click(); + }); + + const ids = screen.getByTestId('toast-ids').textContent?.split(','); + expect(ids).toHaveLength(2); + expect(ids?.[0]).not.toBe(ids?.[1]); + }); + + it('should dismiss toast when dismiss function is called', () => { + const TestComponent = () => { + const { toast, dismiss, toasts } = useToast(); + const toastId = toast({ variant: 'error', title: 'Test' }); + return ( +
+ +
{toasts.length}
+
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('1'); + + act(() => { + screen.getByText('Dismiss').click(); + }); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('0'); + }); + + it('should set default duration to 5000ms', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ +
{toasts[0]?.duration ?? 0}
+
+ ); + }; + + render( + + + + ); + + act(() => { + screen.getByText('Add').click(); + }); + + expect(screen.getByTestId('toast-duration')).toHaveTextContent('5000'); + }); + + it('should set duration to 0 when action is provided', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ +
{toasts[0]?.duration ?? 0}
+
+ ); + }; + + render( + + + + ); + + act(() => { + screen.getByText('Add').click(); + }); + + expect(screen.getByTestId('toast-duration')).toHaveTextContent('0'); + }); + + it('should use custom duration when provided', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ +
{toasts[0]?.duration ?? 0}
+
+ ); + }; + + render( + + + + ); + + act(() => { + screen.getByText('Add').click(); + }); + + expect(screen.getByTestId('toast-duration')).toHaveTextContent('10000'); + }); + + it('should support all toast variants', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ + + + +
{toasts.length}
+
+ ); + }; + + render( + + + + ); + + act(() => { + screen.getByText('Success').click(); + screen.getByText('Error').click(); + screen.getByText('Warning').click(); + screen.getByText('Info').click(); + }); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('4'); + }); + + it('should include description when provided', () => { + const TestComponent = () => { + const { toast, toasts } = useToast(); + return ( +
+ +
{toasts[0]?.description ?? ''}
+
+ ); + }; + + render( + + + + ); + + act(() => { + screen.getByText('Add').click(); + }); + + expect(screen.getByTestId('toast-description')).toHaveTextContent('Description'); + }); + }); +}); diff --git a/components/ToastRegion.test.tsx b/components/ToastRegion.test.tsx new file mode 100644 index 0000000..b850e2a --- /dev/null +++ b/components/ToastRegion.test.tsx @@ -0,0 +1,392 @@ +/** + * Component tests for ToastRegion + * Tests toast rendering, positioning, and accessibility + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { ToastProvider, useToast } from '@/lib/context/ToastContext'; +import ToastRegion from './ToastRegion'; + +// Extend Vitest expect with jest-axe matchers +expect.extend(toHaveNoViolations); + +describe('ToastRegion', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderWithProvider = (component: React.ReactNode) => { + return render({component}); + }; + + describe('Rendering', () => { + it('should not render when no toasts are present', () => { + renderWithProvider(); + expect(screen.queryByLabelText('Notifications')).not.toBeInTheDocument(); + }); + + it('should render when toasts are present', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + expect(screen.getByLabelText('Notifications')).toBeInTheDocument(); + }); + + it('should render up to 3 toasts', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + + + + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add 1').click(); + screen.getByText('Add 2').click(); + screen.getByText('Add 3').click(); + screen.getByText('Add 4').click(); + }); + + // Should only render 3 toasts (limit in ToastRegion) + const toasts = screen.getAllByRole('status'); + expect(toasts).toHaveLength(3); + }); + + it('should render toasts in reverse order (newest first)', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add First').click(); + screen.getByText('Add Second').click(); + }); + + const toasts = screen.getAllByRole('status'); + expect(toasts[0]).toHaveTextContent('Second'); + expect(toasts[1]).toHaveTextContent('First'); + }); + }); + + describe('Accessibility', () => { + it('should have no accessibility violations when empty', async () => { + const { container } = renderWithProvider(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have no accessibility violations with toasts', async () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + const { container } = renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have aria-label="Notifications"', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const region = screen.getByLabelText('Notifications'); + expect(region).toBeInTheDocument(); + }); + + it('should render toasts with role="status"', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const toasts = screen.getAllByRole('status'); + expect(toasts).toHaveLength(1); + }); + + it('should render toasts with aria-atomic="true"', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const toast = screen.getByRole('status'); + expect(toast).toHaveAttribute('aria-atomic', 'true'); + }); + }); + + describe('Positioning', () => { + it('should have fixed positioning classes', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const region = screen.getByLabelText('Notifications'); + expect(region).toHaveClass('fixed'); + }); + + it('should have z-index for layering', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const region = screen.getByLabelText('Notifications'); + expect(region).toHaveClass('z-[100]'); + }); + }); + + describe('Dismissal', () => { + it('should remove toast when dismiss is called', () => { + const TestComponent = () => { + const { toast, dismiss } = useToast(); + const toastId = toast({ variant: 'success', title: 'Test' }); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + expect(screen.getAllByRole('status')).toHaveLength(1); + + act(() => { + screen.getByText('Dismiss').click(); + }); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('should update toast count when toasts are dismissed', () => { + const TestComponent = () => { + const { toast, dismiss, toasts } = useToast(); + const toastId1 = toast({ variant: 'info', title: 'Toast 1' }); + const toastId2 = toast({ variant: 'info', title: 'Toast 2' }); + return ( +
+ + + +
{toasts.length}
+
+ ); + }; + + renderWithProvider(); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('2'); + + act(() => { + screen.getByText('Dismiss 1').click(); + }); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('1'); + + act(() => { + screen.getByText('Dismiss 2').click(); + }); + + expect(screen.getByTestId('toast-count')).toHaveTextContent('0'); + }); + }); + + describe('Multiple Toasts', () => { + it('should render multiple toasts of different variants', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + + + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Success').click(); + screen.getByText('Error').click(); + screen.getByText('Warning').click(); + }); + + const toasts = screen.getAllByRole('status'); + expect(toasts).toHaveLength(3); + expect(screen.getByText('Success')).toBeInTheDocument(); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Warning')).toBeInTheDocument(); + }); + + it('should render toasts with descriptions', () => { + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + expect(screen.getByText('Description text')).toBeInTheDocument(); + }); + + it('should render toasts with actions', () => { + const actionClick = vi.fn(); + const TestComponent = () => { + const { toast } = useToast(); + return ( +
+ + +
+ ); + }; + + renderWithProvider(); + + act(() => { + screen.getByText('Add').click(); + }); + + const actionButton = screen.getByText('Action Button'); + expect(actionButton).toBeInTheDocument(); + + act(() => { + actionButton.click(); + }); + + expect(actionClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 6b5c80d..31edb61 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -13,16 +13,19 @@ export default defineConfig({ 'tests/property/**/*.test.cjs', 'tests/session/**/*.test.ts', 'tests/session/**/*.test.cjs', + 'components/**/*.test.tsx', ], - environment: 'node', + environment: 'jsdom', globals: true, + setupFiles: ['./vitest.setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - include: ['lib/contracts/**/*.ts', 'app/**/*.ts', 'lib/**/*.ts'], + include: ['lib/contracts/**/*.ts', 'app/**/*.ts', 'lib/**/*.ts', 'components/**/*.tsx'], exclude: [ 'lib/contracts/**/*.test.ts', 'tests/**', + 'components/**/*.test.tsx', ], }, alias: { diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom';