diff --git a/frontend/__tests__/unit/components/NavDropDown.test.tsx b/frontend/__tests__/unit/components/NavDropDown.test.tsx new file mode 100644 index 0000000000..4dab4c437a --- /dev/null +++ b/frontend/__tests__/unit/components/NavDropDown.test.tsx @@ -0,0 +1,587 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom' +import type { Link as LinkType } from 'types/link' +import NavDropdown from 'components/NavDropDown' + +// Mock Next.js Link component +jest.mock('next/link', () => { + return ({ href, children, ...props }) => { + return ( + { + e.preventDefault() + props.onClick?.(e) + }} + > + {children} + + ) + } +}) + +// Mock FontAwesome icons +jest.mock('@fortawesome/react-fontawesome', () => ({ + FontAwesomeIcon: ({ className }) => , +})) + +// Mock utility function +jest.mock('utils/utility', () => ({ + cn: (...classes) => classes.filter(Boolean).join(' '), +})) + +describe('NavDropdown Component', () => { + // Test data matching your component's expected structure + const mockLink: LinkType = { + text: 'Documentation', + href: '/docs', + submenu: [ + { text: 'Getting Started', href: '/docs/getting-started' }, + { text: 'API Reference', href: '/docs/api' }, + { text: 'Examples', href: '/docs/examples' }, + ], + } + + const defaultProps = { + pathname: '/current-page', + link: mockLink, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Basic Rendering', () => { + it('renders successfully with required props', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText('Documentation')).toBeInTheDocument() + expect(screen.getByTestId('chevron-icon')).toBeInTheDocument() + }) + + it('renders dropdown button with correct text', () => { + render() + + const button = screen.getByRole('button') + expect(button).toHaveTextContent('Documentation') + }) + + it('initially does not show submenu items', () => { + render() + + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + expect(screen.queryByText('API Reference')).not.toBeInTheDocument() + expect(screen.queryByText('Examples')).not.toBeInTheDocument() + }) + }) + + describe('Conditional Rendering Logic', () => { + it('shows submenu items when dropdown is opened', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(screen.getByText('Getting Started')).toBeInTheDocument() + expect(screen.getByText('API Reference')).toBeInTheDocument() + expect(screen.getByText('Examples')).toBeInTheDocument() + }) + }) + + it('hides submenu items when dropdown is closed', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + + // Open dropdown + await user.click(button) + expect(screen.getByText('Getting Started')).toBeInTheDocument() + + // Close dropdown + await user.click(button) + await waitFor(() => { + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + }) + }) + + it('applies active styling when current pathname matches submenu item', () => { + const propsWithActiveSubmenu = { + pathname: '/docs/getting-started', + link: mockLink, + } + + render() + + const dropdown = screen.getByRole('button').parentElement + expect(dropdown).toHaveClass('font-bold', 'text-blue-800', 'dark:text-white') + }) + + it('does not apply active styling when pathname does not match any submenu', () => { + render() + + const dropdown = screen.getByRole('button').parentElement + expect(dropdown).not.toHaveClass('font-bold', 'text-blue-800') + }) + }) + + describe('Prop-based Behavior', () => { + it('renders different link text based on props', () => { + const customLink: LinkType = { + text: 'Custom Menu', + href: '/custom', + submenu: [{ text: 'Sub Item', href: '/custom/sub' }], + } + + render() + + expect(screen.getByText('Custom Menu')).toBeInTheDocument() + }) + + it('renders correct number of submenu items', async () => { + const linkWithManyItems: LinkType = { + text: 'Menu', + href: '/menu', + submenu: [ + { text: 'Item 1', href: '/item1' }, + { text: 'Item 2', href: '/item2' }, + { text: 'Item 3', href: '/item3' }, + { text: 'Item 4', href: '/item4' }, + ], + } + + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const links = screen.getAllByRole('link') + expect(links).toHaveLength(4) + }) + + it('handles submenu items with different href values', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const gettingStartedLink = screen.getByText('Getting Started').closest('a') + const apiLink = screen.getByText('API Reference').closest('a') + + expect(gettingStartedLink).toHaveAttribute('href', '/docs/getting-started') + expect(apiLink).toHaveAttribute('href', '/docs/api') + }) + }) + + describe('Event Handling', () => { + it('toggles dropdown when button is clicked', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + + // Initially closed + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + + // Click to open + await user.click(button) + expect(screen.getByText('Getting Started')).toBeInTheDocument() + + // Click to close + await user.click(button) + await waitFor(() => { + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + }) + }) + + it('closes dropdown when submenu item is clicked', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const submenuItem = screen.getByText('Getting Started') + await user.click(submenuItem) + await waitFor(() => { + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + }) + }) + + it('closes dropdown when clicking outside', async () => { + const user = userEvent.setup() + render( +
+ + +
+ ) + + const dropdownButton = screen.getByRole('button', { name: /documentation/i }) + await user.click(dropdownButton) + + expect(screen.getByText('Getting Started')).toBeInTheDocument() + + const outsideButton = screen.getByRole('button', { name: /outside button/i }) + await user.click(outsideButton) + + await waitFor(() => { + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + }) + }) + + it('handles keyboard events on dropdown button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + button.focus() + + // Open with Enter key + await user.keyboard('{Enter}') + expect(screen.getByText('Getting Started')).toBeInTheDocument() + + // Close with Escape key + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + }) + }) + + it('handles keyboard events on submenu items', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const submenuItem = screen.getByText('Getting Started') + submenuItem.focus() + + // Close with Enter key on submenu item + await user.keyboard('{Enter}') + await waitFor(() => { + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument() + }) + }) + + it('handles space key to toggle dropdown', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + button.focus() + + // Open with Space key + await user.keyboard(' ') + expect(screen.getByText('Getting Started')).toBeInTheDocument() + }) + }) + + describe('State Changes and Internal Logic', () => { + it('rotates chevron icon when dropdown opens and closes', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + const chevronContainer = button.querySelector('span[style]') + + // Initially not rotated + expect(chevronContainer).toHaveStyle('transform: rotate(0deg)') + + // Open dropdown - should rotate + await user.click(button) + expect(chevronContainer).toHaveStyle('transform: rotate(180deg)') + + // Close dropdown - should rotate back + await user.click(button) + expect(chevronContainer).toHaveStyle('transform: rotate(0deg)') + }) + }) + + describe('Default Values and Fallbacks', () => { + it('handles empty submenu array', async () => { + const linkWithEmptySubmenu: LinkType = { + text: 'Empty Menu', + href: '/empty', + submenu: [], + } + + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + // Should render dropdown container but no items + const dropdownContainer = screen.getByRole('button').parentElement?.querySelector('[id]') + expect(dropdownContainer).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('handles submenu items without href', async () => { + const linkWithMissingHref: LinkType = { + text: 'Menu', + href: '/menu', + submenu: [ + { text: 'Valid Item', href: '/valid' }, + { text: 'Invalid Item' }, // Missing href + ], + } + + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const validLink = screen.getByText('Valid Item').closest('a') + const invalidLink = screen.getByText('Invalid Item').closest('a') + + expect(validLink).toHaveAttribute('href', '/valid') + expect(invalidLink).toHaveAttribute('href', '/') // Fallback to '/' + }) + }) + + describe('Text and Content Rendering', () => { + it('renders link text correctly', () => { + render() + + expect(screen.getByText('Documentation')).toBeInTheDocument() + }) + + it('renders all submenu item texts', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(screen.getByText('Getting Started')).toBeInTheDocument() + expect(screen.getByText('API Reference')).toBeInTheDocument() + expect(screen.getByText('Examples')).toBeInTheDocument() + }) + + it('handles special characters in text', async () => { + const specialLink: LinkType = { + text: 'Special & ', + href: '/special', + submenu: [ + { text: 'Item with & symbol', href: '/item1' }, + { text: 'Item with ', href: '/item2' }, + ], + } + + const user = userEvent.setup() + render() + + expect(screen.getByText('Special & ')).toBeInTheDocument() + + const button = screen.getByRole('button') + await user.click(button) + + expect(screen.getByText('Item with & symbol')).toBeInTheDocument() + expect(screen.getByText('Item with ')).toBeInTheDocument() + }) + }) + + describe('Edge Cases and Invalid Inputs', () => { + it('handles very long menu text', () => { + const longTextLink: LinkType = { + text: 'A'.repeat(100), + href: '/long', + submenu: [{ text: 'B'.repeat(50), href: '/long-sub' }], + } + + render() + + const button = screen.getByRole('button') + expect(button).toHaveTextContent('A'.repeat(100)) + }) + + it('handles large number of submenu items', async () => { + const manySubmenuItems = Array.from({ length: 50 }, (_, i) => ({ + text: `Item ${i + 1}`, + href: `/item${i + 1}`, + })) + + const linkWithManyItems: LinkType = { + text: 'Many Items', + href: '/many', + submenu: manySubmenuItems, + } + + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 50')).toBeInTheDocument() + + const links = screen.getAllByRole('link') + expect(links).toHaveLength(50) + }) + }) + + describe('Accessibility', () => { + it('has proper ARIA attributes on button', () => { + render() + + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute('aria-haspopup', 'true') + expect(button).toHaveAttribute('aria-controls') + }) + + it('updates aria-expanded when dropdown state changes', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-expanded', 'false') + + await user.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + await user.click(button) + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + + it('has proper id relationship between button and dropdown', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + const ariaControls = button.getAttribute('aria-controls') + + await user.click(button) + + const dropdown = screen.getByRole('button').parentElement?.querySelector('[id]') + expect(dropdown).toHaveAttribute('id', ariaControls) + }) + + it('marks chevron icon as decorative', () => { + render() + + const chevronContainer = screen.getByRole('button').querySelector('span[aria-hidden="true"]') + expect(chevronContainer).toHaveAttribute('aria-hidden', 'true') + }) + + it('supports keyboard navigation', async () => { + const user = userEvent.setup() + render() + + // Focus button with tab + await user.tab() + const button = screen.getByRole('button') + expect(button).toHaveFocus() + }) + }) + + describe('DOM Structure and Styling', () => { + it('applies correct CSS classes to dropdown container', () => { + render() + + const container = screen.getByRole('button').parentElement + expect(container).toHaveClass('dropdown', 'navlink', 'relative', 'px-3', 'py-2') + }) + + it('applies correct classes to dropdown button', () => { + render() + + const button = screen.getByRole('button') + expect(button).toHaveClass('flex', 'items-center', 'gap-2', 'whitespace-nowrap') + }) + + it('applies correct classes to dropdown menu when open', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const menu = screen.getByRole('button').parentElement?.querySelector('[id]') + expect(menu).toHaveClass( + 'absolute', + 'left-0', + 'top-full', + 'z-10', + 'mt-1', + 'w-48', + 'overflow-hidden', + 'rounded-md', + 'bg-white', + 'shadow-lg' + ) + }) + + it('applies hover styling to inactive submenu items', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const submenuItem = screen.getByRole('link', { name: 'API Reference' }) + expect(submenuItem).toHaveClass( + 'text-slate-600', + 'hover:bg-slate-100', + 'hover:text-slate-900' + ) + }) + }) + + describe('Integration Tests', () => { + it('works with Next.js Link component', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + const gettingStartedLink = screen.getByText('Getting Started').closest('a') + expect(gettingStartedLink).toHaveAttribute('href', '/docs/getting-started') + }) + + it('maintains dropdown state across re-renders', async () => { + const user = userEvent.setup() + const { rerender } = render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(screen.getByText('Getting Started')).toBeInTheDocument() + + // Re-render with same props + rerender() + + // Dropdown should still be open + expect(screen.getByText('Getting Started')).toBeInTheDocument() + }) + + it('cleans up event listeners on unmount', () => { + const addEventListenerSpy = jest.spyOn(document, 'addEventListener') + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') + + const { unmount } = render() + + expect(addEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)) + + addEventListenerSpy.mockRestore() + removeEventListenerSpy.mockRestore() + }) + }) +})