diff --git a/web/app/components/base/avatar/index.spec.tsx b/web/app/components/base/avatar/index.spec.tsx new file mode 100644 index 0000000000..e85690880b --- /dev/null +++ b/web/app/components/base/avatar/index.spec.tsx @@ -0,0 +1,308 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Avatar from './index' + +describe('Avatar', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests - verify component renders correctly in different states + describe('Rendering', () => { + it('should render img element with correct alt and src when avatar URL is provided', () => { + const avatarUrl = 'https://example.com/avatar.jpg' + const props = { name: 'John Doe', avatar: avatarUrl } + + render() + + const img = screen.getByRole('img', { name: 'John Doe' }) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', avatarUrl) + }) + + it('should render fallback div with uppercase initial when avatar is null', () => { + const props = { name: 'alice', avatar: null } + + render() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + }) + + // Props tests - verify all props are applied correctly + describe('Props', () => { + describe('size prop', () => { + it.each([ + { size: undefined, expected: '30px', label: 'default (30px)' }, + { size: 50, expected: '50px', label: 'custom (50px)' }, + ])('should apply $label size to img element', ({ size, expected }) => { + const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size } + + render() + + expect(screen.getByRole('img')).toHaveStyle({ + width: expected, + height: expected, + fontSize: expected, + lineHeight: expected, + }) + }) + + it('should apply size to fallback div when avatar is null', () => { + const props = { name: 'Test', avatar: null, size: 40 } + + render() + + const textElement = screen.getByText('T') + const outerDiv = textElement.parentElement as HTMLElement + expect(outerDiv).toHaveStyle({ width: '40px', height: '40px' }) + }) + }) + + describe('className prop', () => { + it('should merge className with default avatar classes on img', () => { + const props = { + name: 'Test', + avatar: 'https://example.com/avatar.jpg', + className: 'custom-class', + } + + render() + + const img = screen.getByRole('img') + expect(img).toHaveClass('custom-class') + expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600') + }) + + it('should merge className with default avatar classes on fallback div', () => { + const props = { + name: 'Test', + avatar: null, + className: 'my-custom-class', + } + + render() + + const textElement = screen.getByText('T') + const outerDiv = textElement.parentElement as HTMLElement + expect(outerDiv).toHaveClass('my-custom-class') + expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600') + }) + }) + + describe('textClassName prop', () => { + it('should apply textClassName to the initial text element', () => { + const props = { + name: 'Test', + avatar: null, + textClassName: 'custom-text-class', + } + + render() + + const textElement = screen.getByText('T') + expect(textElement).toHaveClass('custom-text-class') + expect(textElement).toHaveClass('scale-[0.4]', 'text-center', 'text-white') + }) + }) + }) + + // State Management tests - verify useState and useEffect behavior + describe('State Management', () => { + it('should switch to fallback when image fails to load', async () => { + const props = { name: 'John', avatar: 'https://example.com/broken.jpg' } + render() + const img = screen.getByRole('img') + + fireEvent.error(img) + + await waitFor(() => { + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + expect(screen.getByText('J')).toBeInTheDocument() + }) + + it('should reset error state when avatar URL changes', async () => { + const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' } + const { rerender } = render() + const img = screen.getByRole('img') + + // First, trigger error + fireEvent.error(img) + await waitFor(() => { + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + expect(screen.getByText('J')).toBeInTheDocument() + + rerender() + + await waitFor(() => { + expect(screen.getByRole('img')).toBeInTheDocument() + }) + expect(screen.queryByText('J')).not.toBeInTheDocument() + }) + + it('should not reset error state if avatar becomes null', async () => { + const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' } + const { rerender } = render() + + // Trigger error + fireEvent.error(screen.getByRole('img')) + await waitFor(() => { + expect(screen.getByText('J')).toBeInTheDocument() + }) + + rerender() + + await waitFor(() => { + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + expect(screen.getByText('J')).toBeInTheDocument() + }) + }) + + // Event Handlers tests - verify onError callback behavior + describe('Event Handlers', () => { + it('should call onError with true when image fails to load', () => { + const onErrorMock = vi.fn() + const props = { + name: 'John', + avatar: 'https://example.com/broken.jpg', + onError: onErrorMock, + } + render() + + fireEvent.error(screen.getByRole('img')) + + expect(onErrorMock).toHaveBeenCalledTimes(1) + expect(onErrorMock).toHaveBeenCalledWith(true) + }) + + it('should call onError with false when image loads successfully', () => { + const onErrorMock = vi.fn() + const props = { + name: 'John', + avatar: 'https://example.com/avatar.jpg', + onError: onErrorMock, + } + render() + + fireEvent.load(screen.getByRole('img')) + + expect(onErrorMock).toHaveBeenCalledTimes(1) + expect(onErrorMock).toHaveBeenCalledWith(false) + }) + + it('should not throw when onError is not provided', async () => { + const props = { name: 'John', avatar: 'https://example.com/broken.jpg' } + render() + + expect(() => fireEvent.error(screen.getByRole('img'))).not.toThrow() + await waitFor(() => { + expect(screen.getByText('J')).toBeInTheDocument() + }) + }) + }) + + // Edge Cases tests - verify handling of unusual inputs + describe('Edge Cases', () => { + it('should handle empty string name gracefully', () => { + const props = { name: '', avatar: null } + + const { container } = render() + + // Note: Using querySelector here because empty name produces no visible text, + // making semantic queries (getByRole, getByText) impossible + const textElement = container.querySelector('.text-white') as HTMLElement + expect(textElement).toBeInTheDocument() + expect(textElement.textContent).toBe('') + }) + + it.each([ + { name: '中文名', expected: '中', label: 'Chinese characters' }, + { name: '123User', expected: '1', label: 'number' }, + ])('should display first character when name starts with $label', ({ name, expected }) => { + const props = { name, avatar: null } + + render() + + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it('should handle empty string avatar as falsy value', () => { + const props = { name: 'Test', avatar: '' as string | null } + + render() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByText('T')).toBeInTheDocument() + }) + + it('should handle undefined className and textClassName', () => { + const props = { name: 'Test', avatar: null } + + render() + + const textElement = screen.getByText('T') + const outerDiv = textElement.parentElement as HTMLElement + expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600') + }) + + it.each([ + { size: 0, expected: '0px', label: 'zero' }, + { size: 1000, expected: '1000px', label: 'very large' }, + ])('should handle $label size value', ({ size, expected }) => { + const props = { name: 'Test', avatar: null, size } + + render() + + const textElement = screen.getByText('T') + const outerDiv = textElement.parentElement as HTMLElement + expect(outerDiv).toHaveStyle({ width: expected, height: expected }) + }) + }) + + // Combined props tests - verify props work together correctly + describe('Combined Props', () => { + it('should apply all props correctly when used together', () => { + const onErrorMock = vi.fn() + const props = { + name: 'Test User', + avatar: 'https://example.com/avatar.jpg', + size: 64, + className: 'custom-avatar', + onError: onErrorMock, + } + + render() + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('alt', 'Test User') + expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg') + expect(img).toHaveStyle({ width: '64px', height: '64px' }) + expect(img).toHaveClass('custom-avatar') + + // Trigger load to verify onError callback + fireEvent.load(img) + expect(onErrorMock).toHaveBeenCalledWith(false) + }) + + it('should apply all fallback props correctly when used together', () => { + const props = { + name: 'Fallback User', + avatar: null, + size: 48, + className: 'fallback-custom', + textClassName: 'custom-text-style', + } + + render() + + const textElement = screen.getByText('F') + const outerDiv = textElement.parentElement as HTMLElement + expect(outerDiv).toHaveClass('fallback-custom') + expect(outerDiv).toHaveStyle({ width: '48px', height: '48px' }) + expect(textElement).toHaveClass('custom-text-style') + }) + }) +})